@ -0,0 +1,34 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [frontend-upgrade]
|
||||
pull_request:
|
||||
branches: [frontend-upgrade]
|
||||
|
||||
jobs:
|
||||
Frontend:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
working-directory: ./frontend
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [14.x]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
working-directory: ${{ env.working-directory }}
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: ${{ env.working-directory }}
|
@ -0,0 +1,8 @@
|
||||
# Please override by creating a .env.local file at the same directory
|
||||
# Required
|
||||
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
|
||||
|
||||
# Optional
|
||||
REACT_APP_CAN_UPDATE=true
|
||||
REACT_APP_HAS_UPDATE=false
|
||||
REACT_APP_LOG_REDUX_EVENT=false
|
@ -0,0 +1,6 @@
|
||||
/*
|
||||
!/frontend
|
||||
|
||||
build
|
||||
dist
|
||||
converage
|
@ -0,0 +1,4 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
# Bazarr Frontend
|
||||
|
||||
## How to Run
|
||||
|
||||
1. Duplicate `.env` file and rename to `.env.local`
|
||||
2. Fill any variable that defined in `.env.local`
|
||||
3. Run Bazarr backend (Backend must listening on `http://localhost:6767`)
|
||||
4. Start frontend by running `npm start`
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.
|
||||
Open `http://localhost:3000` to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.
|
||||
|
||||
### `npm run lint`
|
||||
|
||||
Format code for all files in `frontend` folder
|
||||
|
||||
This command will automatic trigger when you commit codes to git. Run manually if you modify `.prettierignore` or `.prettierrc`
|
@ -0,0 +1,91 @@
|
||||
{
|
||||
"name": "bazarr",
|
||||
"version": "1.0.0",
|
||||
"description": "Bazarr is a companion application to Sonarr and Radarr. It manages and downloads subtitles based on your requirements. You define your preferences by TV show or movie and Bazarr takes care of everything for you.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/morpheus65535/bazarr.git"
|
||||
},
|
||||
"author": "morpheus65535",
|
||||
"license": "GPL-3.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/morpheus65535/bazarr/issues"
|
||||
},
|
||||
"homepage": "./",
|
||||
"proxy": "http://localhost:6767",
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^4.2.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.15.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.15.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.0",
|
||||
"@fortawesome/react-fontawesome": "^0.1.11",
|
||||
"@types/bootstrap": "^5.0.0",
|
||||
"@types/lodash": "^4.0.0",
|
||||
"@types/node": "^14.0.0",
|
||||
"@types/rc-slider": "^8.6.6",
|
||||
"@types/react": "^16.0.0",
|
||||
"@types/react-dom": "^16.0.0",
|
||||
"@types/react-helmet": "^6.1.0",
|
||||
"@types/react-redux": "^7.0.0",
|
||||
"@types/react-router-dom": "^5.0.0",
|
||||
"@types/react-select": "^4.0.3",
|
||||
"@types/react-table": "^7.0.0",
|
||||
"@types/redux-actions": "^2.0.0",
|
||||
"@types/redux-logger": "^3.0.0",
|
||||
"@types/redux-promise": "^0.5.0",
|
||||
"axios": "^0.21.0",
|
||||
"bootstrap": "^4.0.0",
|
||||
"lodash": "^4.0.0",
|
||||
"moment": "^2.29.1",
|
||||
"rc-slider": "^9.7.1",
|
||||
"react": "^16.0.0",
|
||||
"react-bootstrap": "^1.0.0",
|
||||
"react-dom": "^16.0.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-redux": "^7.0.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "^4.0.0",
|
||||
"react-select": "^4.0.0",
|
||||
"react-table": "^7.0.0",
|
||||
"recharts": "^2.0.8",
|
||||
"redux-actions": "^2.0.0",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-promise": "^0.6.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"sass": "^1.0.0",
|
||||
"typescript": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "^4.0.0",
|
||||
"prettier": "^2.1.2",
|
||||
"prettier-plugin-organize-imports": "^1.1.1",
|
||||
"pretty-quick": "^3.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"lint": "prettier --write --ignore-unknown ."
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "pretty-quick --staged"
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Bazarr</title>
|
||||
<base href="{{baseUrl}}" />
|
||||
<meta charset="utf-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/x-icon"
|
||||
href="%PUBLIC_URL%/static/favicon.ico"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="Bazarr is a companion application to Sonarr and Radarr. It manages and downloads subtitles based on your requirements. You define your preferences by TV show or movie and Bazarr takes care of everything for you."
|
||||
/>
|
||||
<link rel="manifest" href="%PUBLIC_URL%/static/manifest.json" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
window.Bazarr = {{BAZARR_SERVER_INJECT | tojson | safe}};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@ -0,0 +1,14 @@
|
||||
{
|
||||
"short_name": "Bazarr",
|
||||
"name": "Bazarr Frontend",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff"
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
import { faEyeSlash as fasEyeSlash } from "@fortawesome/free-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Container } from "react-bootstrap";
|
||||
|
||||
export const RouterEmptyPath = "/empty-page";
|
||||
|
||||
const EmptyPage: FunctionComponent = () => {
|
||||
return (
|
||||
<Container className="d-flex flex-column align-items-center my-5">
|
||||
<h1>
|
||||
<FontAwesomeIcon className="mr-2" icon={fasEyeSlash}></FontAwesomeIcon>
|
||||
404
|
||||
</h1>
|
||||
<p>The Request URL No Found</p>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyPage;
|
@ -0,0 +1,155 @@
|
||||
import { isEqual } from "lodash";
|
||||
import { log } from "../../utilites/logger";
|
||||
import {
|
||||
ActionCallback,
|
||||
ActionDispatcher,
|
||||
AsyncActionCreator,
|
||||
AsyncActionDispatcher,
|
||||
AvailableCreator,
|
||||
AvailableType,
|
||||
PromiseCreator,
|
||||
} from "../types";
|
||||
|
||||
// Limiter the call to API
|
||||
const gLimiter: Map<PromiseCreator, Date> = new Map();
|
||||
const gArgs: Map<PromiseCreator, any[]> = new Map();
|
||||
|
||||
const LIMIT_CALL_MS = 200;
|
||||
|
||||
function asyncActionFactory<T extends PromiseCreator>(
|
||||
type: string,
|
||||
promise: T,
|
||||
args: Parameters<T>
|
||||
): AsyncActionDispatcher<PromiseType<ReturnType<T>>> {
|
||||
return (dispatch) => {
|
||||
const previousArgs = gArgs.get(promise);
|
||||
const date = new Date();
|
||||
|
||||
if (isEqual(previousArgs, args)) {
|
||||
// Get last execute date
|
||||
const previousExec = gLimiter.get(promise);
|
||||
if (previousExec) {
|
||||
const distInMs = date.getTime() - previousExec.getTime();
|
||||
if (distInMs < LIMIT_CALL_MS) {
|
||||
log(
|
||||
"warning",
|
||||
"Multiple calls to API within the range",
|
||||
promise,
|
||||
args
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
gArgs.set(promise, args);
|
||||
}
|
||||
|
||||
gLimiter.set(promise, date);
|
||||
|
||||
dispatch({
|
||||
type,
|
||||
payload: {
|
||||
loading: true,
|
||||
parameters: args,
|
||||
},
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
promise(...args)
|
||||
.then((val) => {
|
||||
dispatch({
|
||||
type,
|
||||
payload: {
|
||||
loading: false,
|
||||
item: val,
|
||||
parameters: args,
|
||||
},
|
||||
});
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
dispatch({
|
||||
type,
|
||||
error: true,
|
||||
payload: {
|
||||
loading: false,
|
||||
item: err,
|
||||
parameters: args,
|
||||
},
|
||||
});
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createAsyncAction<T extends PromiseCreator>(
|
||||
type: string,
|
||||
promise: T
|
||||
) {
|
||||
return (...args: Parameters<T>) => asyncActionFactory(type, promise, args);
|
||||
}
|
||||
|
||||
// Create a action which combine multiple ActionDispatcher and execute them at once
|
||||
function combineActionFactory(
|
||||
dispatchers: AvailableType<any>[]
|
||||
): ActionDispatcher {
|
||||
return (dispatch) => {
|
||||
dispatchers.forEach((fn) => {
|
||||
if (typeof fn === "function") {
|
||||
fn(dispatch);
|
||||
} else {
|
||||
dispatch(fn);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createCombineAction<T extends AvailableCreator>(fn: T) {
|
||||
return (...args: Parameters<T>) => combineActionFactory(fn(...args));
|
||||
}
|
||||
|
||||
function combineAsyncActionFactory(
|
||||
dispatchers: AsyncActionDispatcher<any>[]
|
||||
): AsyncActionDispatcher<any> {
|
||||
return (dispatch) => {
|
||||
const promises = dispatchers.map((v) => v(dispatch));
|
||||
return Promise.all(promises) as Promise<any>;
|
||||
};
|
||||
}
|
||||
|
||||
export function createAsyncCombineAction<T extends AsyncActionCreator>(fn: T) {
|
||||
return (...args: Parameters<T>) => combineAsyncActionFactory(fn(...args));
|
||||
}
|
||||
|
||||
export function callbackActionFactory(
|
||||
dispatchers: AsyncActionDispatcher<any>[],
|
||||
success: ActionCallback,
|
||||
error?: ActionCallback
|
||||
): ActionDispatcher<any> {
|
||||
return (dispatch) => {
|
||||
const promises = dispatchers.map((v) => v(dispatch));
|
||||
Promise.all(promises)
|
||||
.then(() => {
|
||||
const action = success();
|
||||
if (action !== undefined) {
|
||||
dispatch(action);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const action = error && error();
|
||||
if (action !== undefined) {
|
||||
dispatch(action);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createCallbackAction<T extends AsyncActionCreator>(
|
||||
fn: T,
|
||||
success: ActionCallback,
|
||||
error?: ActionCallback
|
||||
) {
|
||||
return (...args: Parameters<T>) =>
|
||||
callbackActionFactory(fn(args), success, error);
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
export * from "./movie";
|
||||
export * from "./providers";
|
||||
export * from "./series";
|
||||
export * from "./site";
|
||||
export * from "./system";
|
@ -0,0 +1,59 @@
|
||||
import { MoviesApi } from "../../apis";
|
||||
import {
|
||||
MOVIES_UPDATE_BLACKLIST,
|
||||
MOVIES_UPDATE_HISTORY_LIST,
|
||||
MOVIES_UPDATE_INFO,
|
||||
MOVIES_UPDATE_LIST,
|
||||
MOVIES_UPDATE_RANGE,
|
||||
MOVIES_UPDATE_WANTED_LIST,
|
||||
MOVIES_UPDATE_WANTED_RANGE,
|
||||
} from "../constants";
|
||||
import {
|
||||
createAsyncAction,
|
||||
createAsyncCombineAction,
|
||||
createCombineAction,
|
||||
} from "./factory";
|
||||
import { badgeUpdateAll } from "./site";
|
||||
|
||||
export const movieUpdateList = createAsyncAction(MOVIES_UPDATE_LIST, () =>
|
||||
MoviesApi.movies()
|
||||
);
|
||||
|
||||
const movieUpdateWantedList = createAsyncAction(
|
||||
MOVIES_UPDATE_WANTED_LIST,
|
||||
(radarrid?: number) => MoviesApi.wantedBy(radarrid)
|
||||
);
|
||||
|
||||
export const movieUpdateWantedByRange = createAsyncAction(
|
||||
MOVIES_UPDATE_WANTED_RANGE,
|
||||
(start: number, length: number) => MoviesApi.wanted(start, length)
|
||||
);
|
||||
|
||||
export const movieUpdateWantedBy = createCombineAction((radarrid?: number) => [
|
||||
movieUpdateWantedList(radarrid),
|
||||
badgeUpdateAll(),
|
||||
]);
|
||||
|
||||
export const movieUpdateHistoryList = createAsyncAction(
|
||||
MOVIES_UPDATE_HISTORY_LIST,
|
||||
() => MoviesApi.history()
|
||||
);
|
||||
|
||||
export const movieUpdateByRange = createAsyncAction(
|
||||
MOVIES_UPDATE_RANGE,
|
||||
(start: number, length: number) => MoviesApi.moviesBy(start, length)
|
||||
);
|
||||
|
||||
const movieUpdateInfo = createAsyncAction(MOVIES_UPDATE_INFO, (id?: number[]) =>
|
||||
MoviesApi.movies(id)
|
||||
);
|
||||
|
||||
export const movieUpdateInfoAll = createAsyncCombineAction((id?: number[]) => [
|
||||
movieUpdateInfo(id),
|
||||
badgeUpdateAll(),
|
||||
]);
|
||||
|
||||
export const movieUpdateBlacklist = createAsyncAction(
|
||||
MOVIES_UPDATE_BLACKLIST,
|
||||
() => MoviesApi.blacklist()
|
||||
);
|
@ -0,0 +1,13 @@
|
||||
import { ProvidersApi } from "../../apis";
|
||||
import { PROVIDER_UPDATE_LIST } from "../constants";
|
||||
import { createAsyncAction, createCombineAction } from "./factory";
|
||||
import { badgeUpdateAll } from "./site";
|
||||
|
||||
const providerUpdateList = createAsyncAction(PROVIDER_UPDATE_LIST, () =>
|
||||
ProvidersApi.providers()
|
||||
);
|
||||
|
||||
export const providerUpdateAll = createCombineAction(() => [
|
||||
providerUpdateList(),
|
||||
badgeUpdateAll(),
|
||||
]);
|
@ -0,0 +1,62 @@
|
||||
import { EpisodesApi, SeriesApi } from "../../apis";
|
||||
import {
|
||||
SERIES_UPDATE_BLACKLIST,
|
||||
SERIES_UPDATE_EPISODE_LIST,
|
||||
SERIES_UPDATE_HISTORY_LIST,
|
||||
SERIES_UPDATE_INFO,
|
||||
SERIES_UPDATE_RANGE,
|
||||
SERIES_UPDATE_WANTED_LIST,
|
||||
SERIES_UPDATE_WANTED_RANGE,
|
||||
} from "../constants";
|
||||
import {
|
||||
createAsyncAction,
|
||||
createAsyncCombineAction,
|
||||
createCombineAction,
|
||||
} from "./factory";
|
||||
import { badgeUpdateAll } from "./site";
|
||||
|
||||
const seriesUpdateWantedList = createAsyncAction(
|
||||
SERIES_UPDATE_WANTED_LIST,
|
||||
(episodeid?: number) => EpisodesApi.wantedBy(episodeid)
|
||||
);
|
||||
|
||||
const seriesUpdateBy = createAsyncAction(SERIES_UPDATE_INFO, (id?: number[]) =>
|
||||
SeriesApi.series(id)
|
||||
);
|
||||
|
||||
const episodeUpdateBy = createAsyncAction(
|
||||
SERIES_UPDATE_EPISODE_LIST,
|
||||
(seriesid: number) => EpisodesApi.bySeriesId(seriesid)
|
||||
);
|
||||
|
||||
export const seriesUpdateByRange = createAsyncAction(
|
||||
SERIES_UPDATE_RANGE,
|
||||
(start: number, length: number) => SeriesApi.seriesBy(start, length)
|
||||
);
|
||||
|
||||
export const seriesUpdateWantedByRange = createAsyncAction(
|
||||
SERIES_UPDATE_WANTED_RANGE,
|
||||
(start: number, length: number) => EpisodesApi.wanted(start, length)
|
||||
);
|
||||
|
||||
export const seriesUpdateWantedBy = createCombineAction(
|
||||
(episodeid?: number) => [seriesUpdateWantedList(episodeid), badgeUpdateAll()]
|
||||
);
|
||||
|
||||
export const episodeUpdateBySeriesId = createCombineAction(
|
||||
(seriesid: number) => [episodeUpdateBy(seriesid), badgeUpdateAll()]
|
||||
);
|
||||
|
||||
export const seriesUpdateHistoryList = createAsyncAction(
|
||||
SERIES_UPDATE_HISTORY_LIST,
|
||||
() => EpisodesApi.history()
|
||||
);
|
||||
|
||||
export const seriesUpdateInfoAll = createAsyncCombineAction(
|
||||
(seriesid?: number[]) => [seriesUpdateBy(seriesid), badgeUpdateAll()]
|
||||
);
|
||||
|
||||
export const seriesUpdateBlacklist = createAsyncAction(
|
||||
SERIES_UPDATE_BLACKLIST,
|
||||
() => EpisodesApi.blacklist()
|
||||
);
|
@ -0,0 +1,65 @@
|
||||
import { createAction } from "redux-actions";
|
||||
import { BadgesApi } from "../../apis";
|
||||
import {
|
||||
SITE_AUTH_SUCCESS,
|
||||
SITE_BADGE_UPDATE,
|
||||
SITE_INITIALIZED,
|
||||
SITE_INITIALIZE_FAILED,
|
||||
SITE_NEED_AUTH,
|
||||
SITE_NOTIFICATIONS_ADD,
|
||||
SITE_NOTIFICATIONS_REMOVE,
|
||||
SITE_NOTIFICATIONS_REMOVE_BY_TIMESTAMP,
|
||||
SITE_OFFLINE_UPDATE,
|
||||
SITE_SAVE_LOCALSTORAGE,
|
||||
SITE_SIDEBAR_UPDATE,
|
||||
} from "../constants";
|
||||
import { createAsyncAction, createCallbackAction } from "./factory";
|
||||
import { systemUpdateLanguagesAll, systemUpdateSettings } from "./system";
|
||||
|
||||
export const bootstrap = createCallbackAction(
|
||||
() => [systemUpdateLanguagesAll(), systemUpdateSettings()],
|
||||
() => siteInitialized(),
|
||||
() => siteInitializeFailed()
|
||||
);
|
||||
|
||||
const siteInitializeFailed = createAction(SITE_INITIALIZE_FAILED);
|
||||
|
||||
const siteInitialized = createAction(SITE_INITIALIZED);
|
||||
|
||||
export const siteRedirectToAuth = createAction(SITE_NEED_AUTH);
|
||||
|
||||
export const siteAuthSuccess = createAction(SITE_AUTH_SUCCESS);
|
||||
|
||||
export const badgeUpdateAll = createAsyncAction(SITE_BADGE_UPDATE, () =>
|
||||
BadgesApi.all()
|
||||
);
|
||||
|
||||
export const siteSaveLocalstorage = createAction(
|
||||
SITE_SAVE_LOCALSTORAGE,
|
||||
(settings: LooseObject) => settings
|
||||
);
|
||||
|
||||
export const siteAddError = createAction(
|
||||
SITE_NOTIFICATIONS_ADD,
|
||||
(err: ReduxStore.Notification) => err
|
||||
);
|
||||
|
||||
export const siteRemoveError = createAction(
|
||||
SITE_NOTIFICATIONS_REMOVE,
|
||||
(id: string) => id
|
||||
);
|
||||
|
||||
export const siteRemoveErrorByTimestamp = createAction(
|
||||
SITE_NOTIFICATIONS_REMOVE_BY_TIMESTAMP,
|
||||
(date: Date) => date
|
||||
);
|
||||
|
||||
export const siteChangeSidebar = createAction(
|
||||
SITE_SIDEBAR_UPDATE,
|
||||
(id: string) => id
|
||||
);
|
||||
|
||||
export const siteUpdateOffline = createAction(
|
||||
SITE_OFFLINE_UPDATE,
|
||||
(state: boolean) => state
|
||||
);
|
@ -0,0 +1,62 @@
|
||||
import { Action } from "redux-actions";
|
||||
import { SystemApi } from "../../apis";
|
||||
import {
|
||||
SYSTEM_RUN_TASK,
|
||||
SYSTEM_UPDATE_LANGUAGES_LIST,
|
||||
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST,
|
||||
SYSTEM_UPDATE_LOGS,
|
||||
SYSTEM_UPDATE_RELEASES,
|
||||
SYSTEM_UPDATE_SETTINGS,
|
||||
SYSTEM_UPDATE_STATUS,
|
||||
SYSTEM_UPDATE_TASKS,
|
||||
} from "../constants";
|
||||
import { createAsyncAction, createAsyncCombineAction } from "./factory";
|
||||
|
||||
export const systemUpdateLanguagesAll = createAsyncCombineAction(() => [
|
||||
systemUpdateLanguages(),
|
||||
systemUpdateLanguagesProfiles(),
|
||||
]);
|
||||
|
||||
export const systemUpdateLanguages = createAsyncAction(
|
||||
SYSTEM_UPDATE_LANGUAGES_LIST,
|
||||
() => SystemApi.languages()
|
||||
);
|
||||
|
||||
export const systemUpdateLanguagesProfiles = createAsyncAction(
|
||||
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST,
|
||||
() => SystemApi.languagesProfileList()
|
||||
);
|
||||
|
||||
export const systemUpdateStatus = createAsyncAction(SYSTEM_UPDATE_STATUS, () =>
|
||||
SystemApi.status()
|
||||
);
|
||||
|
||||
export const systemUpdateTasks = createAsyncAction(SYSTEM_UPDATE_TASKS, () =>
|
||||
SystemApi.getTasks()
|
||||
);
|
||||
|
||||
export function systemRunTasks(id: string): Action<string> {
|
||||
return {
|
||||
type: SYSTEM_RUN_TASK,
|
||||
payload: id,
|
||||
};
|
||||
}
|
||||
|
||||
export const systemUpdateLogs = createAsyncAction(SYSTEM_UPDATE_LOGS, () =>
|
||||
SystemApi.logs()
|
||||
);
|
||||
|
||||
export const systemUpdateReleases = createAsyncAction(
|
||||
SYSTEM_UPDATE_RELEASES,
|
||||
() => SystemApi.releases()
|
||||
);
|
||||
|
||||
export const systemUpdateSettings = createAsyncAction(
|
||||
SYSTEM_UPDATE_SETTINGS,
|
||||
() => SystemApi.settings()
|
||||
);
|
||||
|
||||
export const systemUpdateSettingsAll = createAsyncCombineAction(() => [
|
||||
systemUpdateSettings(),
|
||||
systemUpdateLanguagesAll(),
|
||||
]);
|
@ -0,0 +1,45 @@
|
||||
// Provider action
|
||||
export const PROVIDER_UPDATE_LIST = "UPDATE_PROVIDER_LIST";
|
||||
|
||||
// System action
|
||||
export const SYSTEM_UPDATE_LANGUAGES_LIST = "UPDATE_ALL_LANGUAGES_LIST";
|
||||
export const SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST =
|
||||
"UPDATE_LANGUAGES_PROFILE_LIST";
|
||||
export const SYSTEM_UPDATE_STATUS = "UPDATE_SYSTEM_STATUS";
|
||||
export const SYSTEM_UPDATE_TASKS = "UPDATE_SYSTEM_TASKS";
|
||||
export const SYSTEM_UPDATE_LOGS = "UPDATE_SYSTEM_LOGS";
|
||||
export const SYSTEM_UPDATE_RELEASES = "SYSTEM_UPDATE_RELEASES";
|
||||
export const SYSTEM_UPDATE_SETTINGS = "UPDATE_SYSTEM_SETTINGS";
|
||||
export const SYSTEM_RUN_TASK = "SYSTEM_RUN_TASK";
|
||||
|
||||
// Series action
|
||||
export const SERIES_UPDATE_WANTED_RANGE = "SERIES_UPDATE_WANTED_RANGE";
|
||||
export const SERIES_UPDATE_WANTED_LIST = "UPDATE_SERIES_WANTED_LIST";
|
||||
export const SERIES_UPDATE_EPISODE_LIST = "UPDATE_SERIES_EPISODE_LIST";
|
||||
export const SERIES_UPDATE_HISTORY_LIST = "UPDATE_SERIES_HISTORY_LIST";
|
||||
export const SERIES_UPDATE_INFO = "UPDATE_SEIRES_INFO";
|
||||
export const SERIES_UPDATE_RANGE = "SERIES_UPDATE_RANGE";
|
||||
export const SERIES_UPDATE_BLACKLIST = "UPDATE_SERIES_BLACKLIST";
|
||||
|
||||
// Movie action
|
||||
export const MOVIES_UPDATE_LIST = "UPDATE_MOVIE_LIST";
|
||||
export const MOVIES_UPDATE_WANTED_RANGE = "MOVIES_UPDATE_WANTED_RANGE";
|
||||
export const MOVIES_UPDATE_WANTED_LIST = "UPDATE_MOVIE_WANTED_LIST";
|
||||
export const MOVIES_UPDATE_HISTORY_LIST = "UPDATE_MOVIE_HISTORY_LIST";
|
||||
export const MOVIES_UPDATE_INFO = "UPDATE_MOVIE_INFO";
|
||||
export const MOVIES_UPDATE_RANGE = "MOVIES_UPDATE_RANGE";
|
||||
export const MOVIES_UPDATE_BLACKLIST = "UPDATE_MOVIES_BLACKLIST";
|
||||
|
||||
// Site Action
|
||||
export const SITE_AUTH_SUCCESS = "SITE_AUTH_SUCCESS";
|
||||
export const SITE_NEED_AUTH = "SITE_NEED_AUTH";
|
||||
export const SITE_INITIALIZED = "SITE_SYSTEM_INITIALIZED";
|
||||
export const SITE_INITIALIZE_FAILED = "SITE_INITIALIZE_FAILED";
|
||||
export const SITE_SAVE_LOCALSTORAGE = "SITE_SAVE_LOCALSTORAGE";
|
||||
export const SITE_NOTIFICATIONS_ADD = "SITE_NOTIFICATIONS_ADD";
|
||||
export const SITE_NOTIFICATIONS_REMOVE = "SITE_NOTIFICATIONS_REMOVE";
|
||||
export const SITE_NOTIFICATIONS_REMOVE_BY_TIMESTAMP =
|
||||
"SITE_NOTIFICATIONS_REMOVE_BY_TIMESTAMP";
|
||||
export const SITE_SIDEBAR_UPDATE = "SITE_SIDEBAR_UPDATE";
|
||||
export const SITE_BADGE_UPDATE = "SITE_BADGE_UPDATE";
|
||||
export const SITE_OFFLINE_UPDATE = "SITE_OFFLINE_UPDATE";
|
@ -0,0 +1,36 @@
|
||||
import { useCallback } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { createCallbackAction } from "../actions/factory";
|
||||
import { ActionCallback, AsyncActionDispatcher } from "../types";
|
||||
|
||||
// function use
|
||||
export function useReduxStore<T extends (store: ReduxStore) => any>(
|
||||
selector: T
|
||||
) {
|
||||
return useSelector<ReduxStore, ReturnType<T>>(selector);
|
||||
}
|
||||
|
||||
export function useReduxAction<T extends (...args: any[]) => void>(action: T) {
|
||||
const dispatch = useDispatch();
|
||||
return useCallback((...args: Parameters<T>) => dispatch(action(...args)), [
|
||||
action,
|
||||
dispatch,
|
||||
]);
|
||||
}
|
||||
|
||||
export function useReduxActionWith<
|
||||
T extends (...args: any[]) => AsyncActionDispatcher<any>
|
||||
>(action: T, success: ActionCallback) {
|
||||
const dispatch = useDispatch();
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
const callbackAction = createCallbackAction(
|
||||
() => [action(...args)],
|
||||
success
|
||||
);
|
||||
|
||||
dispatch(callbackAction());
|
||||
},
|
||||
[dispatch, action, success]
|
||||
);
|
||||
}
|
@ -0,0 +1,265 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { buildOrderList } from "../../utilites";
|
||||
import {
|
||||
episodeUpdateBySeriesId,
|
||||
movieUpdateBlacklist,
|
||||
movieUpdateHistoryList,
|
||||
movieUpdateInfoAll,
|
||||
movieUpdateWantedBy,
|
||||
providerUpdateAll,
|
||||
seriesUpdateBlacklist,
|
||||
seriesUpdateHistoryList,
|
||||
seriesUpdateInfoAll,
|
||||
seriesUpdateWantedBy,
|
||||
systemUpdateLanguages,
|
||||
systemUpdateLanguagesProfiles,
|
||||
systemUpdateSettingsAll,
|
||||
} from "../actions";
|
||||
import { useReduxAction, useReduxStore } from "./base";
|
||||
|
||||
function stateBuilder<T, D extends (...args: any[]) => any>(
|
||||
t: T,
|
||||
d: D
|
||||
): [Readonly<T>, D] {
|
||||
return [t, d];
|
||||
}
|
||||
|
||||
export function useSystemSettings() {
|
||||
const action = useReduxAction(systemUpdateSettingsAll);
|
||||
const items = useReduxStore((s) => s.system.settings);
|
||||
return stateBuilder(items, action);
|
||||
}
|
||||
|
||||
export function useLanguageProfiles() {
|
||||
const action = useReduxAction(systemUpdateLanguagesProfiles);
|
||||
const items = useReduxStore((s) => s.system.languagesProfiles.data);
|
||||
|
||||
return stateBuilder(items, action);
|
||||
}
|
||||
|
||||
export function useProfileBy(id: number | null | undefined) {
|
||||
const [profiles] = useLanguageProfiles();
|
||||
return useMemo(() => profiles.find((v) => v.profileId === id), [
|
||||
id,
|
||||
profiles,
|
||||
]);
|
||||
}
|
||||
|
||||
export function useLanguages(enabled: boolean = false) {
|
||||
const action = useReduxAction(systemUpdateLanguages);
|
||||
const items = useReduxStore((s) =>
|
||||
enabled ? s.system.enabledLanguage.data : s.system.languages.data
|
||||
);
|
||||
return stateBuilder(items, action);
|
||||
}
|
||||
|
||||
function useLanguageGetter(enabled: boolean = false) {
|
||||
const [languages] = useLanguages(enabled);
|
||||
return useCallback(
|
||||
(code?: string) => {
|
||||
if (code === undefined) {
|
||||
return undefined;
|
||||
} else {
|
||||
return languages.find((v) => v.code2 === code);
|
||||
}
|
||||
},
|
||||
[languages]
|
||||
);
|
||||
}
|
||||
|
||||
export function useLanguageBy(code?: string) {
|
||||
const getter = useLanguageGetter();
|
||||
return useMemo(() => getter(code), [code, getter]);
|
||||
}
|
||||
|
||||
// Convert languageprofile items to language
|
||||
export function useProfileItems(profile?: Profile.Languages) {
|
||||
const getter = useLanguageGetter(true);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
profile?.items.map<Language>(({ language, hi, forced }) => {
|
||||
const name = getter(language)?.name ?? "";
|
||||
return {
|
||||
hi: hi === "True",
|
||||
forced: forced === "True",
|
||||
code2: language,
|
||||
name,
|
||||
};
|
||||
}) ?? [],
|
||||
[getter, profile?.items]
|
||||
);
|
||||
}
|
||||
|
||||
export function useRawSeries() {
|
||||
const action = useReduxAction(seriesUpdateInfoAll);
|
||||
const items = useReduxStore((d) => d.series.seriesList);
|
||||
return stateBuilder(items, action);
|
||||
}
|
||||
|
||||
export function useSeries(order = true) {
|
||||
const [rawSeries, action] = useRawSeries();
|
||||
const series = useMemo<AsyncState<Item.Series[]>>(() => {
|
||||
const state = rawSeries.data;
|
||||
if (order) {
|
||||
return {
|
||||
...rawSeries,
|
||||
data: buildOrderList(state),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...rawSeries,
|
||||
data: Object.values(state.items),
|
||||
};
|
||||
}
|
||||
}, [rawSeries, order]);
|
||||
return stateBuilder(series, action);
|
||||
}
|
||||
|
||||
export function useSerieBy(id?: number) {
|
||||
const [series, updateSerie] = useRawSeries();
|
||||
const updateEpisodes = useReduxAction(episodeUpdateBySeriesId);
|
||||
const serie = useMemo<AsyncState<Item.Series | null>>(() => {
|
||||
const items = series.data.items;
|
||||
let item: Item.Series | null = null;
|
||||
if (id && !isNaN(id) && id in items) {
|
||||
item = items[id];
|
||||
}
|
||||
return {
|
||||
...series,
|
||||
data: item,
|
||||
};
|
||||
}, [id, series]);
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (id && !isNaN(id)) {
|
||||
updateSerie([id]);
|
||||
updateEpisodes(id);
|
||||
}
|
||||
}, [id, updateSerie, updateEpisodes]);
|
||||
|
||||
return stateBuilder(serie, update);
|
||||
}
|
||||
|
||||
export function useEpisodesBy(seriesId?: number) {
|
||||
const action = useReduxAction(episodeUpdateBySeriesId);
|
||||
const callback = useCallback(() => {
|
||||
if (seriesId !== undefined && !isNaN(seriesId)) {
|
||||
action(seriesId);
|
||||
}
|
||||
}, [action, seriesId]);
|
||||
|
||||
const list = useReduxStore((d) => d.series.episodeList);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (seriesId !== undefined && !isNaN(seriesId)) {
|
||||
return list.data[seriesId] ?? [];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, [seriesId, list.data]);
|
||||
|
||||
const state: AsyncState<Item.Episode[]> = {
|
||||
...list,
|
||||
data: items,
|
||||
};
|
||||
|
||||
return stateBuilder(state, callback);
|
||||
}
|
||||
|
||||
export function useRawMovies() {
|
||||
const action = useReduxAction(movieUpdateInfoAll);
|
||||
const items = useReduxStore((d) => d.movie.movieList);
|
||||
return stateBuilder(items, action);
|
||||
}
|
||||
|
||||
export function useMovies(order = true) {
|
||||
const [rawMovies, action] = useRawMovies();
|
||||
const movies = useMemo<AsyncState<Item.Movie[]>>(() => {
|
||||
const state = rawMovies.data;
|
||||
if (order) {
|
||||
return {
|
||||
...rawMovies,
|
||||
data: buildOrderList(state),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...rawMovies,
|
||||
data: Object.values(state.items),
|
||||
};
|
||||
}
|
||||
}, [rawMovies, order]);
|
||||
return stateBuilder(movies, action);
|
||||
}
|
||||
|
||||
export function useMovieBy(id?: number) {
|
||||
const [movies, updateMovies] = useRawMovies();
|
||||
const movie = useMemo<AsyncState<Item.Movie | null>>(() => {
|
||||
const items = movies.data.items;
|
||||
let item: Item.Movie | null = null;
|
||||
if (id && !isNaN(id) && id in items) {
|
||||
item = items[id];
|
||||
}
|
||||
return {
|
||||
...movies,
|
||||
data: item,
|
||||
};
|
||||
}, [id, movies]);
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (id && !isNaN(id)) {
|
||||
updateMovies([id]);
|
||||
}
|
||||
}, [id, updateMovies]);
|
||||
|
||||
return stateBuilder(movie, update);
|
||||
}
|
||||
|
||||
export function useWantedSeries() {
|
||||
const action = useReduxAction(seriesUpdateWantedBy);
|
||||
const items = useReduxStore((d) => d.series.wantedEpisodesList);
|
||||
|
||||
return stateBuilder(items, action);
|
||||
}
|
||||
|
||||
export function useWantedMovies() {
|
||||
const action = useReduxAction(movieUpdateWantedBy);
|
||||
const items = useReduxStore((d) => d.movie.wantedMovieList);
|
||||
|
||||
return stateBuilder(items, action);
|
||||
}
|
||||
|
||||
export function useProviders() {
|
||||
const action = useReduxAction(providerUpdateAll);
|
||||
const items = useReduxStore((d) => d.system.providers);
|
||||
|
||||
return stateBuilder(items, action);
|
||||
}
|
||||
|
||||
export function useBlacklistMovies() {
|
||||
const action = useReduxAction(movieUpdateBlacklist);
|
||||
const items = useReduxStore((d) => d.movie.blacklist);
|
||||
|
||||
return stateBuilder(items, action);
|
||||
}
|
||||
|
||||
export function useBlacklistSeries() {
|
||||
const action = useReduxAction(seriesUpdateBlacklist);
|
||||
const items = useReduxStore((d) => d.series.blacklist);
|
||||
|
||||
return stateBuilder(items, action);
|
||||
}
|
||||
|
||||
export function useMoviesHistory() {
|
||||
const action = useReduxAction(movieUpdateHistoryList);
|
||||
const items = useReduxStore((s) => s.movie.historyList);
|
||||
|
||||
return stateBuilder(items, action);
|
||||
}
|
||||
|
||||
export function useSeriesHistory() {
|
||||
const action = useReduxAction(seriesUpdateHistoryList);
|
||||
const items = useReduxStore((s) => s.series.historyList);
|
||||
|
||||
return stateBuilder(items, action);
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
import { useCallback } from "react";
|
||||
import { useSystemSettings } from ".";
|
||||
import { siteAddError, siteRemoveErrorByTimestamp } from "../actions";
|
||||
import { useReduxAction, useReduxStore } from "./base";
|
||||
|
||||
export function useNotification(id: string, sec: number = 5) {
|
||||
const add = useReduxAction(siteAddError);
|
||||
const remove = useReduxAction(siteRemoveErrorByTimestamp);
|
||||
|
||||
return useCallback(
|
||||
(msg: Omit<ReduxStore.Notification, "id" | "timestamp">) => {
|
||||
const error: ReduxStore.Notification = {
|
||||
...msg,
|
||||
id,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
add(error);
|
||||
setTimeout(() => remove(error.timestamp), sec * 1000);
|
||||
},
|
||||
[add, remove, sec, id]
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsOffline() {
|
||||
return useReduxStore((s) => s.site.offline);
|
||||
}
|
||||
|
||||
export function useIsSonarrEnabled() {
|
||||
const [settings] = useSystemSettings();
|
||||
return settings.data?.general.use_sonarr ?? true;
|
||||
}
|
||||
|
||||
export function useIsRadarrEnabled() {
|
||||
const [settings] = useSystemSettings();
|
||||
return settings.data?.general.use_radarr ?? true;
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { combineReducers } from "redux";
|
||||
import movie from "./movie";
|
||||
import series from "./series";
|
||||
import site from "./site";
|
||||
import system from "./system";
|
||||
|
||||
export default combineReducers({
|
||||
system,
|
||||
series,
|
||||
movie,
|
||||
site,
|
||||
});
|
@ -0,0 +1,112 @@
|
||||
import { mergeArray } from "../../utilites";
|
||||
import { AsyncAction } from "../types";
|
||||
|
||||
export function updateAsyncState<Payload>(
|
||||
action: AsyncAction<Payload>,
|
||||
defVal: Readonly<Payload>
|
||||
): AsyncState<Payload> {
|
||||
if (action.payload.loading) {
|
||||
return {
|
||||
updating: true,
|
||||
data: defVal,
|
||||
};
|
||||
} else if (action.error !== undefined) {
|
||||
return {
|
||||
updating: false,
|
||||
error: action.payload.item as Error,
|
||||
data: defVal,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
updating: false,
|
||||
error: undefined,
|
||||
data: action.payload.item as Payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function updateOrderIdState<T extends LooseObject>(
|
||||
action: AsyncAction<AsyncDataWrapper<T>>,
|
||||
state: AsyncState<OrderIdState<T>>,
|
||||
id: ItemIdType<T>
|
||||
): AsyncState<OrderIdState<T>> {
|
||||
if (action.payload.loading) {
|
||||
return {
|
||||
...state,
|
||||
updating: true,
|
||||
};
|
||||
} else if (action.error !== undefined) {
|
||||
return {
|
||||
...state,
|
||||
updating: false,
|
||||
error: action.payload.item as Error,
|
||||
};
|
||||
} else {
|
||||
const { data, total } = action.payload.item as AsyncDataWrapper<T>;
|
||||
const [start, length] = action.payload.parameters;
|
||||
|
||||
// Convert item list to object
|
||||
const idState: IdState<T> = data.reduce<IdState<T>>((prev, curr) => {
|
||||
const tid = curr[id];
|
||||
prev[tid] = curr;
|
||||
return prev;
|
||||
}, {});
|
||||
|
||||
const dataOrder: number[] = data.map((v) => v[id]);
|
||||
|
||||
let newItems = { ...state.data.items, ...idState };
|
||||
let newOrder = state.data.order;
|
||||
|
||||
const countDist = total - newOrder.length;
|
||||
if (countDist > 0) {
|
||||
newOrder.push(...Array(countDist).fill(null));
|
||||
} else if (countDist < 0) {
|
||||
// Completely drop old data if list has shrinked
|
||||
newOrder = Array(total).fill(null);
|
||||
newItems = { ...idState };
|
||||
}
|
||||
|
||||
if (typeof start === "number" && typeof length === "number") {
|
||||
newOrder.splice(start, length, ...dataOrder);
|
||||
} else if (start === undefined) {
|
||||
// Full Update
|
||||
newOrder = dataOrder;
|
||||
}
|
||||
|
||||
return {
|
||||
updating: false,
|
||||
data: {
|
||||
items: newItems,
|
||||
order: newOrder,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAsyncList<T, ID extends keyof T>(
|
||||
action: AsyncAction<T[]>,
|
||||
state: AsyncState<T[]>,
|
||||
match: ID
|
||||
): AsyncState<T[]> {
|
||||
if (action.payload.loading) {
|
||||
return {
|
||||
...state,
|
||||
updating: true,
|
||||
};
|
||||
} else if (action.error !== undefined) {
|
||||
return {
|
||||
...state,
|
||||
updating: false,
|
||||
error: action.payload.item as Error,
|
||||
};
|
||||
} else {
|
||||
const list = state.data as T[];
|
||||
const payload = action.payload.item as T[];
|
||||
const result = mergeArray(list, payload, (l, r) => l[match] === r[match]);
|
||||
|
||||
return {
|
||||
updating: false,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
import { handleActions } from "redux-actions";
|
||||
import {
|
||||
MOVIES_UPDATE_BLACKLIST,
|
||||
MOVIES_UPDATE_HISTORY_LIST,
|
||||
MOVIES_UPDATE_INFO,
|
||||
MOVIES_UPDATE_RANGE,
|
||||
MOVIES_UPDATE_WANTED_LIST,
|
||||
MOVIES_UPDATE_WANTED_RANGE,
|
||||
} from "../constants";
|
||||
import { AsyncAction } from "../types";
|
||||
import { updateAsyncState, updateOrderIdState } from "./mapper";
|
||||
|
||||
const reducer = handleActions<ReduxStore.Movie, any>(
|
||||
{
|
||||
[MOVIES_UPDATE_WANTED_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Wanted.Movie>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
wantedMovieList: updateOrderIdState(
|
||||
action,
|
||||
state.wantedMovieList,
|
||||
"radarrId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[MOVIES_UPDATE_WANTED_RANGE]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Wanted.Movie>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
wantedMovieList: updateOrderIdState(
|
||||
action,
|
||||
state.wantedMovieList,
|
||||
"radarrId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[MOVIES_UPDATE_HISTORY_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<History.Movie[]>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
historyList: updateAsyncState(action, state.historyList.data),
|
||||
};
|
||||
},
|
||||
[MOVIES_UPDATE_INFO]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Item.Movie>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
movieList: updateOrderIdState(action, state.movieList, "radarrId"),
|
||||
};
|
||||
},
|
||||
[MOVIES_UPDATE_RANGE]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Item.Movie>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
movieList: updateOrderIdState(action, state.movieList, "radarrId"),
|
||||
};
|
||||
},
|
||||
[MOVIES_UPDATE_BLACKLIST]: (
|
||||
state,
|
||||
action: AsyncAction<Blacklist.Movie[]>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
blacklist: updateAsyncState(action, state.blacklist.data),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
movieList: { updating: true, data: { items: {}, order: [] } },
|
||||
wantedMovieList: { updating: true, data: { items: {}, order: [] } },
|
||||
historyList: { updating: true, data: [] },
|
||||
blacklist: { updating: true, data: [] },
|
||||
}
|
||||
);
|
||||
|
||||
export default reducer;
|
@ -0,0 +1,118 @@
|
||||
import { handleActions } from "redux-actions";
|
||||
import {
|
||||
SERIES_UPDATE_BLACKLIST,
|
||||
SERIES_UPDATE_EPISODE_LIST,
|
||||
SERIES_UPDATE_HISTORY_LIST,
|
||||
SERIES_UPDATE_INFO,
|
||||
SERIES_UPDATE_RANGE,
|
||||
SERIES_UPDATE_WANTED_LIST,
|
||||
SERIES_UPDATE_WANTED_RANGE,
|
||||
} from "../constants";
|
||||
import { AsyncAction } from "../types";
|
||||
import { updateAsyncState, updateOrderIdState } from "./mapper";
|
||||
|
||||
const reducer = handleActions<ReduxStore.Series, any>(
|
||||
{
|
||||
[SERIES_UPDATE_WANTED_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Wanted.Episode>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
wantedEpisodesList: updateOrderIdState(
|
||||
action,
|
||||
state.wantedEpisodesList,
|
||||
"sonarrEpisodeId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[SERIES_UPDATE_WANTED_RANGE]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Wanted.Episode>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
wantedEpisodesList: updateOrderIdState(
|
||||
action,
|
||||
state.wantedEpisodesList,
|
||||
"sonarrEpisodeId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[SERIES_UPDATE_EPISODE_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<Item.Episode[]>
|
||||
) => {
|
||||
const { updating, error, data: items } = updateAsyncState(action, []);
|
||||
|
||||
const stateItems = { ...state.episodeList.data };
|
||||
|
||||
if (items.length > 0) {
|
||||
const id = items[0].sonarrSeriesId;
|
||||
stateItems[id] = items;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
episodeList: {
|
||||
updating,
|
||||
error,
|
||||
data: stateItems,
|
||||
},
|
||||
};
|
||||
},
|
||||
[SERIES_UPDATE_HISTORY_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<History.Episode[]>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
historyList: updateAsyncState(action, state.historyList.data),
|
||||
};
|
||||
},
|
||||
[SERIES_UPDATE_INFO]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Item.Series>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
seriesList: updateOrderIdState(
|
||||
action,
|
||||
state.seriesList,
|
||||
"sonarrSeriesId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[SERIES_UPDATE_RANGE]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Item.Series>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
seriesList: updateOrderIdState(
|
||||
action,
|
||||
state.seriesList,
|
||||
"sonarrSeriesId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[SERIES_UPDATE_BLACKLIST]: (
|
||||
state,
|
||||
action: AsyncAction<Blacklist.Episode[]>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
blacklist: updateAsyncState(action, state.blacklist.data),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
seriesList: { updating: true, data: { items: {}, order: [] } },
|
||||
wantedEpisodesList: { updating: true, data: { items: {}, order: [] } },
|
||||
episodeList: { updating: true, data: {} },
|
||||
historyList: { updating: true, data: [] },
|
||||
blacklist: { updating: true, data: [] },
|
||||
}
|
||||
);
|
||||
|
||||
export default reducer;
|
@ -0,0 +1,109 @@
|
||||
import { Action, handleActions } from "redux-actions";
|
||||
import { storage } from "../../@storage/local";
|
||||
import {
|
||||
SITE_AUTH_SUCCESS,
|
||||
SITE_BADGE_UPDATE,
|
||||
SITE_INITIALIZED,
|
||||
SITE_INITIALIZE_FAILED,
|
||||
SITE_NEED_AUTH,
|
||||
SITE_NOTIFICATIONS_ADD,
|
||||
SITE_NOTIFICATIONS_REMOVE,
|
||||
SITE_NOTIFICATIONS_REMOVE_BY_TIMESTAMP,
|
||||
SITE_OFFLINE_UPDATE,
|
||||
SITE_SAVE_LOCALSTORAGE,
|
||||
SITE_SIDEBAR_UPDATE,
|
||||
} from "../constants";
|
||||
import { AsyncAction } from "../types";
|
||||
|
||||
function updateLocalStorage(): Partial<ReduxStore.Site> {
|
||||
return {
|
||||
pageSize: storage.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
const reducer = handleActions<ReduxStore.Site, any>(
|
||||
{
|
||||
[SITE_NEED_AUTH]: (state) => ({
|
||||
...state,
|
||||
auth: false,
|
||||
}),
|
||||
[SITE_AUTH_SUCCESS]: (state) => ({
|
||||
...state,
|
||||
auth: true,
|
||||
}),
|
||||
[SITE_INITIALIZED]: (state) => ({
|
||||
...state,
|
||||
initialized: true,
|
||||
}),
|
||||
[SITE_INITIALIZE_FAILED]: (state) => ({
|
||||
...state,
|
||||
initialized: "An Error Occurred When Initializing Bazarr UI",
|
||||
}),
|
||||
[SITE_SAVE_LOCALSTORAGE]: (state, action: Action<LooseObject>) => {
|
||||
const settings = action.payload;
|
||||
for (const key in settings) {
|
||||
const value = settings[key];
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
...updateLocalStorage(),
|
||||
};
|
||||
},
|
||||
[SITE_NOTIFICATIONS_ADD]: (
|
||||
state,
|
||||
action: Action<ReduxStore.Notification>
|
||||
) => {
|
||||
const alerts = [
|
||||
...state.notifications.filter((v) => v.id !== action.payload.id),
|
||||
action.payload,
|
||||
];
|
||||
return { ...state, notifications: alerts };
|
||||
},
|
||||
[SITE_NOTIFICATIONS_REMOVE]: (state, action: Action<string>) => {
|
||||
const alerts = state.notifications.filter((v) => v.id !== action.payload);
|
||||
return { ...state, notifications: alerts };
|
||||
},
|
||||
[SITE_NOTIFICATIONS_REMOVE_BY_TIMESTAMP]: (state, action: Action<Date>) => {
|
||||
const alerts = state.notifications.filter(
|
||||
(v) => v.timestamp !== action.payload
|
||||
);
|
||||
return { ...state, notifications: alerts };
|
||||
},
|
||||
[SITE_SIDEBAR_UPDATE]: (state, action: Action<string>) => {
|
||||
return {
|
||||
...state,
|
||||
sidebar: action.payload,
|
||||
};
|
||||
},
|
||||
[SITE_BADGE_UPDATE]: {
|
||||
next: (state, action: AsyncAction<Badge>) => {
|
||||
const badges = action.payload.item;
|
||||
if (badges && action.error !== true) {
|
||||
return { ...state, badges: badges as Badge };
|
||||
}
|
||||
return state;
|
||||
},
|
||||
throw: (state) => state,
|
||||
},
|
||||
[SITE_OFFLINE_UPDATE]: (state, action: Action<boolean>) => {
|
||||
return { ...state, offline: action.payload };
|
||||
},
|
||||
},
|
||||
{
|
||||
initialized: false,
|
||||
auth: true,
|
||||
pageSize: 50,
|
||||
notifications: [],
|
||||
sidebar: "",
|
||||
badges: {
|
||||
movies: 0,
|
||||
episodes: 0,
|
||||
providers: 0,
|
||||
},
|
||||
offline: false,
|
||||
...updateLocalStorage(),
|
||||
}
|
||||
);
|
||||
|
||||
export default reducer;
|
@ -0,0 +1,130 @@
|
||||
import { Action, handleActions } from "redux-actions";
|
||||
import {
|
||||
PROVIDER_UPDATE_LIST,
|
||||
SYSTEM_RUN_TASK,
|
||||
SYSTEM_UPDATE_LANGUAGES_LIST,
|
||||
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST,
|
||||
SYSTEM_UPDATE_LOGS,
|
||||
SYSTEM_UPDATE_RELEASES,
|
||||
SYSTEM_UPDATE_SETTINGS,
|
||||
SYSTEM_UPDATE_STATUS,
|
||||
SYSTEM_UPDATE_TASKS,
|
||||
} from "../constants";
|
||||
import { updateAsyncState } from "./mapper";
|
||||
|
||||
const reducer = handleActions<ReduxStore.System, any>(
|
||||
{
|
||||
[SYSTEM_UPDATE_LANGUAGES_LIST]: (state, action) => {
|
||||
const languages = updateAsyncState<Array<ApiLanguage>>(action, []);
|
||||
const enabledLanguage: AsyncState<ApiLanguage[]> = {
|
||||
...languages,
|
||||
data: languages.data.filter((v) => v.enabled),
|
||||
};
|
||||
const newState = {
|
||||
...state,
|
||||
languages,
|
||||
enabledLanguage,
|
||||
};
|
||||
return newState;
|
||||
},
|
||||
[SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST]: (state, action) => {
|
||||
const newState = {
|
||||
...state,
|
||||
languagesProfiles: updateAsyncState<Array<Profile.Languages>>(
|
||||
action,
|
||||
[]
|
||||
),
|
||||
};
|
||||
return newState;
|
||||
},
|
||||
[SYSTEM_UPDATE_STATUS]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
status: updateAsyncState<System.Status | undefined>(
|
||||
action,
|
||||
state.status.data
|
||||
),
|
||||
};
|
||||
},
|
||||
[SYSTEM_UPDATE_TASKS]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
tasks: updateAsyncState<Array<System.Task>>(action, state.tasks.data),
|
||||
};
|
||||
},
|
||||
[SYSTEM_RUN_TASK]: (state, action: Action<string>) => {
|
||||
const id = action.payload;
|
||||
const tasks = state.tasks;
|
||||
const newItems = [...tasks.data];
|
||||
|
||||
const idx = newItems.findIndex((v) => v.job_id === id);
|
||||
|
||||
if (idx !== -1) {
|
||||
newItems[idx].job_running = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
tasks: {
|
||||
...tasks,
|
||||
data: newItems,
|
||||
},
|
||||
};
|
||||
},
|
||||
[PROVIDER_UPDATE_LIST]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
providers: updateAsyncState(action, state.providers.data),
|
||||
};
|
||||
},
|
||||
[SYSTEM_UPDATE_LOGS]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
logs: updateAsyncState(action, state.logs.data),
|
||||
};
|
||||
},
|
||||
[SYSTEM_UPDATE_RELEASES]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
releases: updateAsyncState(action, state.releases.data),
|
||||
};
|
||||
},
|
||||
[SYSTEM_UPDATE_SETTINGS]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
settings: updateAsyncState(action, state.settings.data),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
languages: { updating: true, data: [] },
|
||||
enabledLanguage: { updating: true, data: [] },
|
||||
languagesProfiles: { updating: true, data: [] },
|
||||
status: {
|
||||
updating: true,
|
||||
data: undefined,
|
||||
},
|
||||
tasks: {
|
||||
updating: true,
|
||||
data: [],
|
||||
},
|
||||
providers: {
|
||||
updating: true,
|
||||
data: [],
|
||||
},
|
||||
logs: {
|
||||
updating: true,
|
||||
data: [],
|
||||
},
|
||||
releases: {
|
||||
updating: true,
|
||||
data: [],
|
||||
},
|
||||
settings: {
|
||||
updating: true,
|
||||
data: undefined,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export default reducer;
|
@ -0,0 +1,62 @@
|
||||
interface IdState<T> {
|
||||
[key: number]: Readonly<T>;
|
||||
}
|
||||
|
||||
interface OrderIdState<T> {
|
||||
items: IdState<T>;
|
||||
order: (number | null)[];
|
||||
}
|
||||
|
||||
interface ReduxStore {
|
||||
system: ReduxStore.System;
|
||||
series: ReduxStore.Series;
|
||||
movie: ReduxStore.Movie;
|
||||
site: ReduxStore.Site;
|
||||
}
|
||||
|
||||
namespace ReduxStore {
|
||||
interface Notification {
|
||||
type: "error" | "warning" | "info";
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface Site {
|
||||
// Initialization state or error message
|
||||
initialized: boolean | string;
|
||||
auth: boolean;
|
||||
pageSize: number;
|
||||
notifications: Notification[];
|
||||
sidebar: string;
|
||||
badges: Badge;
|
||||
offline: boolean;
|
||||
}
|
||||
|
||||
interface System {
|
||||
languages: AsyncState<Array<Language>>;
|
||||
enabledLanguage: AsyncState<Array<Language>>;
|
||||
languagesProfiles: AsyncState<Array<Profile.Languages>>;
|
||||
status: AsyncState<System.Status | undefined>;
|
||||
tasks: AsyncState<Array<System.Task>>;
|
||||
providers: AsyncState<Array<System.Provider>>;
|
||||
logs: AsyncState<Array<System.Log>>;
|
||||
releases: AsyncState<Array<ReleaseInfo>>;
|
||||
settings: AsyncState<Settings | undefined>;
|
||||
}
|
||||
|
||||
interface Series {
|
||||
seriesList: AsyncState<OrderIdState<Item.Series>>;
|
||||
wantedEpisodesList: AsyncState<OrderIdState<Wanted.Episode>>;
|
||||
episodeList: AsyncState<IdState<Item.Episode[]>>;
|
||||
historyList: AsyncState<Array<History.Episode>>;
|
||||
blacklist: AsyncState<Array<Blacklist.Episode>>;
|
||||
}
|
||||
|
||||
interface Movie {
|
||||
movieList: AsyncState<OrderIdState<Item.Movie>>;
|
||||
wantedMovieList: AsyncState<OrderIdState<Wanted.Movie>>;
|
||||
historyList: AsyncState<Array<History.Movie>>;
|
||||
blacklist: AsyncState<Array<Blacklist.Movie>>;
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
import { applyMiddleware, createStore } from "redux";
|
||||
import logger from "redux-logger";
|
||||
import promise from "redux-promise";
|
||||
import trunk from "redux-thunk";
|
||||
import rootReducer from "../reducers";
|
||||
|
||||
const plugins = [promise, trunk];
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
process.env["REACT_APP_LOG_REDUX_EVENT"] !== "false"
|
||||
) {
|
||||
plugins.push(logger);
|
||||
}
|
||||
|
||||
const store = createStore(rootReducer, applyMiddleware(...plugins));
|
||||
export default store;
|
@ -0,0 +1,22 @@
|
||||
import { Dispatch } from "redux";
|
||||
import { Action } from "redux-actions";
|
||||
|
||||
interface AsyncPayload<Payload> {
|
||||
loading: boolean;
|
||||
item?: Payload | Error;
|
||||
parameters: any[];
|
||||
}
|
||||
|
||||
type AvailableType<T> = Action<T> | ActionDispatcher<T>;
|
||||
|
||||
type AsyncAction<Payload> = Action<AsyncPayload<Payload>>;
|
||||
type ActionDispatcher<T = any> = (dispatch: Dispatch<Action<T>>) => void;
|
||||
type AsyncActionDispatcher<T> = (
|
||||
dispatch: Dispatch<AsyncAction<T>>
|
||||
) => Promise<void>;
|
||||
|
||||
type PromiseCreator = (...args: any[]) => Promise<any>;
|
||||
type AvailableCreator = (...args: any[]) => AvailableType<any>[];
|
||||
type AsyncActionCreator = (...args: any[]) => AsyncActionDispatcher<any>[];
|
||||
|
||||
type ActionCallback = () => Action<any> | void;
|
@ -0,0 +1,21 @@
|
||||
// Override bootstrap primary color
|
||||
$theme-colors: (
|
||||
"primary": #911f93,
|
||||
"dark": #4f566f,
|
||||
);
|
||||
|
||||
body {
|
||||
font-family: "Roboto", "open sans", "Helvetica Neue", "Helvetica", "Arial",
|
||||
sans-serif !important;
|
||||
font-weight: 300 !important;
|
||||
}
|
||||
|
||||
// Reduce padding of cells in datatables
|
||||
.table td,
|
||||
.table th {
|
||||
padding: 0.4rem !important;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
cursor: default;
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
@import "./variable.scss";
|
||||
|
||||
:root {
|
||||
.form-control {
|
||||
&:focus {
|
||||
outline-color: none !important;
|
||||
box-shadow: none !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.dropdown-hidden {
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.opacity-100 {
|
||||
opacity: 100% !important;
|
||||
}
|
||||
|
||||
.vh-100 {
|
||||
height: 100vh !important;
|
||||
}
|
||||
|
||||
.vh-75 {
|
||||
height: 75vh !important;
|
||||
}
|
||||
|
||||
.of-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.of-auto {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.vw-1 {
|
||||
width: 12rem;
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
@import "./global.scss";
|
||||
@import "./variable.scss";
|
||||
@import "./bazarr.scss";
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/bootstrap.scss";
|
||||
|
||||
@mixin sidebar-animation {
|
||||
transition: {
|
||||
duration: 0.2s;
|
||||
timing-function: ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
.sidebar-container {
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.main-router {
|
||||
max-width: calc(100% - #{$sidebar-width});
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
min-width: $sidebar-width;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
.sidebar-container {
|
||||
position: fixed !important;
|
||||
transform: translateX(-100%);
|
||||
|
||||
@include sidebar-animation();
|
||||
|
||||
&.open {
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.main-router {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
@include sidebar-animation();
|
||||
&.open {
|
||||
display: block !important;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
$sidebar-width: 190px;
|
||||
$header-height: 60px;
|
||||
|
||||
$theme-color-less-transparent: #911f9331;
|
||||
$theme-color-transparent: #911f9313;
|
||||
$theme-color-darked: #761977;
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,10 @@
|
||||
export const uiPageSizeKey = "storage-ui-pageSize";
|
||||
|
||||
export const storage: LocalStorageType = {
|
||||
get pageSize(): number {
|
||||
return parseInt(localStorage.getItem(uiPageSizeKey) ?? "50");
|
||||
},
|
||||
set pageSize(v: number) {
|
||||
localStorage.setItem(uiPageSizeKey, v.toString());
|
||||
},
|
||||
};
|
@ -0,0 +1,258 @@
|
||||
type LanguageCodeType = string;
|
||||
|
||||
interface Badge {
|
||||
episodes: number;
|
||||
movies: number;
|
||||
providers: number;
|
||||
}
|
||||
|
||||
interface ApiLanguage {
|
||||
code2: LanguageCodeType;
|
||||
name: string;
|
||||
hi?: boolean;
|
||||
forced?: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
type Language = Omit<ApiLanguage, "enabled">;
|
||||
|
||||
namespace Profile {
|
||||
interface Item {
|
||||
id: number;
|
||||
audio_exclude: PythonBoolean;
|
||||
forced: PythonBoolean;
|
||||
hi: PythonBoolean;
|
||||
language: LanguageCodeType;
|
||||
}
|
||||
interface Languages {
|
||||
name: string;
|
||||
profileId: number;
|
||||
cutoff: number | null;
|
||||
items: Item[];
|
||||
}
|
||||
}
|
||||
|
||||
interface Subtitle extends Language {
|
||||
forced: boolean;
|
||||
hi: boolean;
|
||||
path: string | null;
|
||||
}
|
||||
|
||||
interface PathType {
|
||||
path: string;
|
||||
exist: boolean;
|
||||
}
|
||||
|
||||
interface SubtitlePathType {
|
||||
subtitles_path: string;
|
||||
}
|
||||
|
||||
interface MonitoredType {
|
||||
monitored: boolean;
|
||||
}
|
||||
|
||||
interface SubtitleType {
|
||||
subtitles: Subtitle[];
|
||||
}
|
||||
|
||||
interface MissingSubtitleType {
|
||||
missing_subtitles: Subtitle[];
|
||||
}
|
||||
|
||||
interface SceneNameType {
|
||||
sceneName?: string;
|
||||
}
|
||||
|
||||
interface TagType {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface SeriesIdType {
|
||||
sonarrSeriesId: number;
|
||||
}
|
||||
|
||||
type EpisodeIdType = SeriesIdType & {
|
||||
sonarrEpisodeId: number;
|
||||
};
|
||||
|
||||
interface EpisodeTitleType {
|
||||
seriesTitle: string;
|
||||
episodeTitle: string;
|
||||
}
|
||||
|
||||
interface MovieIdType {
|
||||
radarrId: number;
|
||||
}
|
||||
|
||||
interface TitleType {
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface AudioLanguageType {
|
||||
audio_language: Language[];
|
||||
}
|
||||
|
||||
interface ItemHistoryType {
|
||||
language: Language;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
namespace Item {
|
||||
type Base = PathType &
|
||||
TitleType &
|
||||
TagType &
|
||||
AudioLanguageType & {
|
||||
profileId: number | null;
|
||||
fanart: string;
|
||||
overview: string;
|
||||
imdbId: string;
|
||||
alternativeTitles: string[];
|
||||
poster: string;
|
||||
year: string;
|
||||
};
|
||||
|
||||
type Series = Base &
|
||||
SeriesIdType & {
|
||||
hearing_impaired: boolean;
|
||||
episodeFileCount: number;
|
||||
episodeMissingCount: number;
|
||||
seriesType: SonarrSeriesType;
|
||||
tvdbId: number;
|
||||
};
|
||||
|
||||
type Movie = Base &
|
||||
MovieIdType &
|
||||
MonitoredType &
|
||||
SubtitleType &
|
||||
MissingSubtitleType &
|
||||
SceneNameType & {
|
||||
hearing_impaired: boolean;
|
||||
audio_codec: string;
|
||||
// movie_file_id: number;
|
||||
tmdbId: number;
|
||||
};
|
||||
|
||||
type Episode = PathType &
|
||||
TitleType &
|
||||
MonitoredType &
|
||||
EpisodeIdType &
|
||||
SubtitleType &
|
||||
MissingSubtitleType &
|
||||
SceneNameType &
|
||||
AudioLanguageType & {
|
||||
audio_codec: string;
|
||||
video_codec: string;
|
||||
season: number;
|
||||
episode: number;
|
||||
resolution: string;
|
||||
format: string;
|
||||
// episode_file_id: number;
|
||||
};
|
||||
}
|
||||
|
||||
namespace Wanted {
|
||||
type Base = MonitoredType &
|
||||
TagType &
|
||||
SceneNameType & {
|
||||
// failedAttempts?: any;
|
||||
hearing_impaired: boolean;
|
||||
missing_subtitles: Subtitle[];
|
||||
};
|
||||
|
||||
type Episode = Base &
|
||||
EpisodeIdType &
|
||||
EpisodeTitleType & {
|
||||
episode_number: string;
|
||||
seriesType: SonarrSeriesType;
|
||||
};
|
||||
|
||||
type Movie = Base & MovieIdType & TitleType;
|
||||
}
|
||||
|
||||
namespace Blacklist {
|
||||
type Base = ItemHistoryType & {
|
||||
timestamp: string;
|
||||
subs_id: string;
|
||||
};
|
||||
|
||||
type Movie = Base & MovieIdType & TitleType;
|
||||
|
||||
type Episode = Base &
|
||||
EpisodeTitleType &
|
||||
SeriesIdType & {
|
||||
episode_number: string;
|
||||
};
|
||||
}
|
||||
|
||||
namespace History {
|
||||
type Base = SubtitlePathType &
|
||||
TagType &
|
||||
MonitoredType &
|
||||
Partial<ItemHistoryType> & {
|
||||
action: number;
|
||||
blacklisted: boolean;
|
||||
score?: string;
|
||||
subs_id?: string;
|
||||
raw_timestamp: int;
|
||||
timestamp: string;
|
||||
description: string;
|
||||
upgradable: boolean;
|
||||
};
|
||||
|
||||
type Movie = History.Base & MovieIdType & TitleType;
|
||||
|
||||
type Episode = History.Base &
|
||||
EpisodeIdType &
|
||||
EpisodeTitleType & {
|
||||
episode_number: string;
|
||||
};
|
||||
|
||||
type StatItem = {
|
||||
count: number;
|
||||
date: string;
|
||||
};
|
||||
|
||||
type Stat = {
|
||||
movies: StatItem[];
|
||||
series: StatItem[];
|
||||
};
|
||||
|
||||
type TimeframeOptions = "week" | "month" | "trimester" | "year";
|
||||
type ActionOptions = 0 | 1 | 2;
|
||||
}
|
||||
|
||||
interface SearchResultType {
|
||||
matches: string[];
|
||||
dont_matches: string[];
|
||||
language: string;
|
||||
forced: PythonBoolean;
|
||||
hearing_impaired: PythonBoolean;
|
||||
orig_score: number;
|
||||
provider: string;
|
||||
release_info: string[];
|
||||
score: number;
|
||||
score_without_hash: number;
|
||||
subtitle: any;
|
||||
uploader?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface ReleaseInfo {
|
||||
current: boolean;
|
||||
date: string;
|
||||
name: string;
|
||||
prerelease: boolean;
|
||||
body: string[];
|
||||
}
|
||||
|
||||
interface SubtitleInfo {
|
||||
filename: string;
|
||||
episode: number;
|
||||
season: number;
|
||||
}
|
||||
|
||||
type ItemSearchResult = Partial<SeriesIdType> &
|
||||
Partial<MovieIdType> & {
|
||||
title: string;
|
||||
year: string;
|
||||
};
|
@ -0,0 +1,34 @@
|
||||
// Sonarr
|
||||
type SonarrSeriesType = "Standard" | "Daily" | "Anime";
|
||||
|
||||
type PythonBoolean = "True" | "False";
|
||||
|
||||
type FileTree = {
|
||||
children: boolean;
|
||||
path: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type StorageType = string | null;
|
||||
|
||||
interface AsyncState<T> {
|
||||
updating: boolean;
|
||||
error?: Error;
|
||||
data: Readonly<T>;
|
||||
}
|
||||
|
||||
type AsyncPayload<T> = T extends AsyncState<infer D> ? D : never;
|
||||
|
||||
type SelectorOption<PAYLOAD> = {
|
||||
label: string;
|
||||
value: PAYLOAD;
|
||||
};
|
||||
|
||||
type SelectorValueType<T, M extends boolean> = M extends true
|
||||
? ReadonlyArray<T>
|
||||
: Nullable<T>;
|
||||
|
||||
type SimpleStateType<T> = [
|
||||
T,
|
||||
((item: T) => void) | ((fn: (item: T) => T) => void)
|
||||
];
|
@ -0,0 +1,76 @@
|
||||
namespace FormType {
|
||||
interface ModifyItem {
|
||||
id: number[];
|
||||
profileid: (number | null)[];
|
||||
}
|
||||
|
||||
type SeriesAction = OneSerieAction | SearchWantedAction;
|
||||
|
||||
type MoviesAction = OneMovieAction | SearchWantedAction;
|
||||
|
||||
interface OneMovieAction {
|
||||
action: "search-missing" | "scan-disk";
|
||||
radarrid: number;
|
||||
}
|
||||
|
||||
interface OneSerieAction {
|
||||
action: "search-missing" | "scan-disk";
|
||||
seriesid: number;
|
||||
}
|
||||
|
||||
interface SearchWantedAction {
|
||||
action: "search-wanted";
|
||||
}
|
||||
|
||||
interface Subtitle {
|
||||
language: string;
|
||||
hi: boolean;
|
||||
forced: boolean;
|
||||
}
|
||||
|
||||
interface UploadSubtitle extends Subtitle {
|
||||
file: File;
|
||||
}
|
||||
|
||||
interface DeleteSubtitle extends Subtitle {
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface ModifySubtitle {
|
||||
id: number;
|
||||
type: "episode" | "movie";
|
||||
language: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface DownloadSeries {
|
||||
episodePath: string;
|
||||
sceneName?: string;
|
||||
language: string;
|
||||
hi: boolean;
|
||||
forced: boolean;
|
||||
sonarrSeriesId: number;
|
||||
sonarrEpisodeId: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface AddBlacklist {
|
||||
provider: string;
|
||||
subs_id: string;
|
||||
language: LanguageCodeType;
|
||||
subtitles_path: string;
|
||||
}
|
||||
|
||||
interface DeleteBlacklist {
|
||||
provider: string;
|
||||
subs_id: string;
|
||||
}
|
||||
|
||||
interface ManualDownload {
|
||||
language: string;
|
||||
hi: PythonBoolean;
|
||||
forced: PythonBoolean;
|
||||
provider: string;
|
||||
subtitle: any;
|
||||
}
|
||||
}
|
@ -0,0 +1,149 @@
|
||||
import {
|
||||
UseColumnOrderInstanceProps,
|
||||
UseColumnOrderState,
|
||||
UseExpandedHooks,
|
||||
UseExpandedInstanceProps,
|
||||
UseExpandedOptions,
|
||||
UseExpandedRowProps,
|
||||
UseExpandedState,
|
||||
UseFiltersColumnOptions,
|
||||
UseFiltersColumnProps,
|
||||
UseGroupByCellProps,
|
||||
UseGroupByColumnOptions,
|
||||
UseGroupByColumnProps,
|
||||
UseGroupByHooks,
|
||||
UseGroupByInstanceProps,
|
||||
UseGroupByOptions,
|
||||
UseGroupByRowProps,
|
||||
UseGroupByState,
|
||||
UsePaginationInstanceProps,
|
||||
UsePaginationOptions,
|
||||
UsePaginationState,
|
||||
UseRowSelectHooks,
|
||||
UseRowSelectInstanceProps,
|
||||
UseRowSelectOptions,
|
||||
UseRowSelectRowProps,
|
||||
UseRowSelectState,
|
||||
UseSortByColumnOptions,
|
||||
UseSortByColumnProps,
|
||||
UseSortByHooks,
|
||||
UseSortByInstanceProps,
|
||||
UseSortByOptions,
|
||||
UseSortByState,
|
||||
} from "react-table";
|
||||
import {} from "../components/tables/plugins";
|
||||
import { PageControlAction } from "../components/tables/types";
|
||||
|
||||
declare module "react-table" {
|
||||
// take this file as-is, or comment out the sections that don't apply to your plugin configuration
|
||||
|
||||
// Customize of React Table
|
||||
type TableUpdater<D extends object> = (row: Row<D>, ...others: any[]) => void;
|
||||
|
||||
interface useAsyncPaginationProps<D extends Record<string, unknown>> {
|
||||
asyncLoader?: (start: number, length: number) => void;
|
||||
asyncState?: AsyncState<OrderIdState<D>>;
|
||||
asyncId?: (item: D) => number;
|
||||
}
|
||||
|
||||
interface useAsyncPaginationState<D extends Record<string, unknown>> {
|
||||
pageToLoad?: PageControlAction;
|
||||
needLoadingScreen?: boolean;
|
||||
}
|
||||
|
||||
interface useSelectionProps<D extends Record<string, unknown>> {
|
||||
isSelecting?: boolean;
|
||||
onSelect?: (items: D[]) => void;
|
||||
}
|
||||
|
||||
interface useSelectionState<D extends Record<string, unknown>> {}
|
||||
|
||||
interface CustomTableProps<D extends Record<string, unknown>>
|
||||
extends useSelectionProps<D>,
|
||||
useAsyncPaginationProps<D> {
|
||||
externalUpdate?: TableUpdater<D>;
|
||||
loose?: any[];
|
||||
}
|
||||
|
||||
interface CustomTableState<D extends Record<string, unknown>>
|
||||
extends useSelectionState<D>,
|
||||
useAsyncPaginationState<D> {}
|
||||
|
||||
export interface TableOptions<
|
||||
D extends Record<string, unknown>
|
||||
> extends UseExpandedOptions<D>,
|
||||
// UseFiltersOptions<D>,
|
||||
// UseGlobalFiltersOptions<D>,
|
||||
UseGroupByOptions<D>,
|
||||
UsePaginationOptions<D>,
|
||||
// UseResizeColumnsOptions<D>,
|
||||
UseRowSelectOptions<D>,
|
||||
// UseRowStateOptions<D>,
|
||||
UseSortByOptions<D>,
|
||||
CustomTableProps<D> {
|
||||
data: readonly D[];
|
||||
}
|
||||
|
||||
export interface Hooks<
|
||||
D extends Record<string, unknown> = Record<string, unknown>
|
||||
> extends UseExpandedHooks<D>,
|
||||
UseGroupByHooks<D>,
|
||||
UseRowSelectHooks<D>,
|
||||
UseSortByHooks<D> {}
|
||||
|
||||
export interface TableInstance<
|
||||
D extends Record<string, unknown> = Record<string, unknown>
|
||||
> extends UseColumnOrderInstanceProps<D>,
|
||||
UseExpandedInstanceProps<D>,
|
||||
// UseFiltersInstanceProps<D>,
|
||||
// UseGlobalFiltersInstanceProps<D>,
|
||||
UseGroupByInstanceProps<D>,
|
||||
UsePaginationInstanceProps<D>,
|
||||
UseRowSelectInstanceProps<D>,
|
||||
// UseRowStateInstanceProps<D>,
|
||||
UseSortByInstanceProps<D>,
|
||||
CustomTableProps<D> {}
|
||||
|
||||
export interface TableState<
|
||||
D extends Record<string, unknown> = Record<string, unknown>
|
||||
> extends UseColumnOrderState<D>,
|
||||
UseExpandedState<D>,
|
||||
// UseFiltersState<D>,
|
||||
// UseGlobalFiltersState<D>,
|
||||
UseGroupByState<D>,
|
||||
UsePaginationState<D>,
|
||||
// UseResizeColumnsState<D>,
|
||||
UseRowSelectState<D>,
|
||||
// UseRowStateState<D>,
|
||||
UseSortByState<D>,
|
||||
CustomTableState<D> {}
|
||||
|
||||
export interface ColumnInterface<
|
||||
D extends Record<string, unknown> = Record<string, unknown>
|
||||
> extends UseFiltersColumnOptions<D>,
|
||||
// UseGlobalFiltersColumnOptions<D>,
|
||||
UseGroupByColumnOptions<D>,
|
||||
// UseResizeColumnsColumnOptions<D>,
|
||||
UseSortByColumnOptions<D> {
|
||||
selectHide?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface ColumnInstance<
|
||||
D extends Record<string, unknown> = Record<string, unknown>
|
||||
> extends UseFiltersColumnProps<D>,
|
||||
UseGroupByColumnProps<D>,
|
||||
// UseResizeColumnsColumnProps<D>,
|
||||
UseSortByColumnProps<D> {}
|
||||
|
||||
export interface Cell<
|
||||
D extends Record<string, unknown> = Record<string, unknown>,
|
||||
V = any
|
||||
> extends UseGroupByCellProps<D> {}
|
||||
|
||||
export interface Row<
|
||||
D extends Record<string, unknown> = Record<string, unknown>
|
||||
> extends UseExpandedRowProps<D>,
|
||||
UseGroupByRowProps<D>,
|
||||
UseRowSelectRowProps<D> {}
|
||||
}
|
@ -0,0 +1,201 @@
|
||||
interface Settings {
|
||||
general: Settings.General;
|
||||
proxy: Settings.Proxy;
|
||||
auth: Settings.Auth;
|
||||
subsync: Settings.Subsync;
|
||||
analytics: Settings.Analytic;
|
||||
sonarr: Settings.Sonarr;
|
||||
radarr: Settings.Radarr;
|
||||
// Anitcaptcha
|
||||
anticaptcha: Settings.Anticaptcha;
|
||||
deathbycaptcha: Settings.DeathByCaptche;
|
||||
// Providers
|
||||
opensubtitles: Settings.OpenSubtitles;
|
||||
opensubtitlescom: Settings.OpenSubtitlesCom;
|
||||
addic7ed: Settings.Addic7ed;
|
||||
legendasdivx: Settings.Legandasdivx;
|
||||
legendastv: Settings.Legendastv;
|
||||
xsubs: Settings.XSubs;
|
||||
assrt: Settings.Assrt;
|
||||
napisy24: Settings.Napisy24;
|
||||
subscene: Settings.Subscene;
|
||||
betaseries: Settings.Betaseries;
|
||||
titlovi: Settings.titlovi;
|
||||
notifications: Settings.Notifications;
|
||||
}
|
||||
|
||||
namespace Settings {
|
||||
interface General {
|
||||
adaptive_searching: boolean;
|
||||
anti_captcha_provider?: string;
|
||||
auto_update: boolean;
|
||||
base_url?: string;
|
||||
branch: string;
|
||||
chmod?: string;
|
||||
chmod_enabled: boolean;
|
||||
days_to_upgrade_subs: number;
|
||||
debug: boolean;
|
||||
dont_notify_manual_actions: boolean;
|
||||
embedded_subs_show_desired: boolean;
|
||||
enabled_providers: string[];
|
||||
ignore_pgs_subs: boolean;
|
||||
ignore_vobsub_subs: boolean;
|
||||
ip: string;
|
||||
multithreading: boolean;
|
||||
minimum_score: number;
|
||||
minimum_score_movie: number;
|
||||
movie_default_enabled: boolean;
|
||||
movie_default_profile?: number;
|
||||
serie_default_enabled: boolean;
|
||||
serie_default_profile?: number;
|
||||
path_mappings: [string, string][];
|
||||
path_mappings_movie: [string, string][];
|
||||
port: number;
|
||||
upgrade_subs: boolean;
|
||||
postprocessing_cmd?: string;
|
||||
postprocessing_threshold: number;
|
||||
postprocessing_threshold_movie: number;
|
||||
single_language: boolean;
|
||||
subfolder: string;
|
||||
subfolder_custom?: string;
|
||||
subzero_mods?: string[];
|
||||
subzero_color_selection?: string;
|
||||
update_restart: boolean;
|
||||
upgrade_frequency: number;
|
||||
upgrade_manual: boolean;
|
||||
use_embedded_subs: boolean;
|
||||
use_postprocessing: boolean;
|
||||
use_postprocessing_threshold: boolean;
|
||||
use_postprocessing_threshold_movie: boolean;
|
||||
use_radarr: boolean;
|
||||
use_scenename: boolean;
|
||||
use_sonarr: boolean;
|
||||
utf8_encode: boolean;
|
||||
wanted_search_frequency: number;
|
||||
wanted_search_frequency_movie: number;
|
||||
}
|
||||
|
||||
interface Proxy {
|
||||
exclude: string[];
|
||||
type?: string;
|
||||
url?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface Auth {
|
||||
type?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
apikey: string;
|
||||
}
|
||||
|
||||
interface Subsync {
|
||||
use_subsync: boolean;
|
||||
use_subsync_threshold: boolean;
|
||||
subsync_threshold: number;
|
||||
use_subsync_movie_threshold: boolean;
|
||||
subsync_movie_threshold: number;
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
interface Analytic {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface Notifications {
|
||||
providers: NotificationInfo[];
|
||||
}
|
||||
|
||||
interface NotificationInfo {
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
// Sonarr / Radarr
|
||||
type FullUpdateOptions = "Manually" | "Daily" | "Weekly";
|
||||
|
||||
interface Sonarr {
|
||||
ip: string;
|
||||
port: number;
|
||||
base_url?: string;
|
||||
ssl: boolean;
|
||||
apikey?: string;
|
||||
full_update: FullUpdateOptions;
|
||||
full_update_day: number;
|
||||
full_update_hour: number;
|
||||
only_monitored: boolean;
|
||||
series_sync: number;
|
||||
episodes_sync: number;
|
||||
excluded_tags: string[];
|
||||
excluded_series_types: SonarrSeriesType[];
|
||||
}
|
||||
|
||||
interface Radarr {
|
||||
ip: string;
|
||||
port: number;
|
||||
base_url?: string;
|
||||
ssl: boolean;
|
||||
apikey?: string;
|
||||
full_update: FullUpdateOptions;
|
||||
full_update_day: number;
|
||||
full_update_hour: number;
|
||||
only_monitored: boolean;
|
||||
movies_sync: number;
|
||||
excluded_tags: string[];
|
||||
}
|
||||
|
||||
interface Anticaptcha {
|
||||
anti_captcha_key?: string;
|
||||
}
|
||||
|
||||
interface DeathByCaptche {
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// Providers
|
||||
|
||||
interface BaseProvider {
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface OpenSubtitles extends BaseProvider {
|
||||
use_tag_search: boolean;
|
||||
vip: boolean;
|
||||
ssl: boolean;
|
||||
timeout: number;
|
||||
skip_wrong_fps: boolean;
|
||||
}
|
||||
|
||||
interface OpenSubtitlesCom extends BaseProvider {
|
||||
use_hash: boolean;
|
||||
}
|
||||
|
||||
interface Addic7ed extends BaseProvider {}
|
||||
|
||||
interface Legandasdivx extends BaseProvider {
|
||||
skip_wrong_fps: boolean;
|
||||
}
|
||||
|
||||
interface Legendastv extends BaseProvider {}
|
||||
|
||||
interface XSubs extends BaseProvider {}
|
||||
|
||||
interface Napisy24 extends BaseProvider {}
|
||||
|
||||
interface Subscene extends BaseProvider {}
|
||||
|
||||
interface Titlovi extends BaseProvider {}
|
||||
|
||||
interface Betaseries {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface Assrt {
|
||||
token?: string;
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
interface LocalStorageType {
|
||||
pageSize: number;
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
namespace System {
|
||||
interface Task {
|
||||
interval: string;
|
||||
job_id: string;
|
||||
job_running: boolean;
|
||||
name: string;
|
||||
next_run_in: string;
|
||||
next_run_time: string;
|
||||
}
|
||||
|
||||
interface Status {
|
||||
bazarr_config_directory: string;
|
||||
bazarr_directory: string;
|
||||
bazarr_version: string;
|
||||
operating_system: string;
|
||||
python_version: string;
|
||||
radarr_version: string;
|
||||
sonarr_version: string;
|
||||
}
|
||||
|
||||
interface Provider {
|
||||
name: string;
|
||||
status: string;
|
||||
retry: string;
|
||||
}
|
||||
|
||||
type LogType = "INFO" | "WARNING" | "ERROR" | "DEBUG";
|
||||
|
||||
interface Log {
|
||||
type: System.LogType;
|
||||
timestamp: string;
|
||||
message: string;
|
||||
exception?: string;
|
||||
}
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
type ValueOf<D> = D[keyof D];
|
||||
|
||||
type Unpacked<D> = D extends any[] | readonly any[] ? D[number] : D;
|
||||
|
||||
type Nullable<D> = D | null;
|
||||
|
||||
type LooseObject = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type StrictObject<T> = {
|
||||
[key: string]: T;
|
||||
};
|
||||
|
||||
type Pair<T = string> = {
|
||||
key: string;
|
||||
value: T;
|
||||
};
|
||||
|
||||
interface DataWrapper<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface AsyncDataWrapper<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
type PromiseType<T> = T extends Promise<infer D> ? D : never;
|
||||
|
||||
type Override<T, U> = T & Omit<U, keyof T>;
|
||||
|
||||
type Comparer<T> = (lhs: T, rhs: T) => boolean;
|
||||
|
||||
type KeysOfType<D, T> = NonNullable<
|
||||
ValueOf<{ [P in keyof D]: D[P] extends T ? P : never }>
|
||||
>;
|
||||
|
||||
type ItemIdType<T> = KeysOfType<T, number>;
|
@ -0,0 +1,12 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
Bazarr: BazarrServer;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BazarrServer {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
canUpdate: boolean;
|
||||
hasUpdate: boolean;
|
||||
}
|
@ -0,0 +1,153 @@
|
||||
import {
|
||||
faBars,
|
||||
faHeart,
|
||||
faNetworkWired,
|
||||
faUser,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Container,
|
||||
Dropdown,
|
||||
Image,
|
||||
Navbar,
|
||||
Row,
|
||||
} from "react-bootstrap";
|
||||
import { SidebarToggleContext } from ".";
|
||||
import { siteRedirectToAuth } from "../@redux/actions";
|
||||
import { useSystemSettings } from "../@redux/hooks";
|
||||
import { useReduxAction } from "../@redux/hooks/base";
|
||||
import { useIsOffline } from "../@redux/hooks/site";
|
||||
import logo from "../@static/logo64.png";
|
||||
import { SystemApi } from "../apis";
|
||||
import { ActionButton, SearchBar, SearchResult } from "../components";
|
||||
import { useBaseUrl } from "../utilites";
|
||||
import "./header.scss";
|
||||
|
||||
async function SearchItem(text: string) {
|
||||
const results = await SystemApi.search(text);
|
||||
|
||||
return results.map<SearchResult>((v) => {
|
||||
let link: string;
|
||||
if (v.sonarrSeriesId) {
|
||||
link = `/series/${v.sonarrSeriesId}`;
|
||||
} else if (v.radarrId) {
|
||||
link = `/movies/${v.radarrId}`;
|
||||
} else {
|
||||
link = "";
|
||||
}
|
||||
return {
|
||||
name: `${v.title} (${v.year})`,
|
||||
link,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
const Header: FunctionComponent<Props> = () => {
|
||||
const setNeedAuth = useReduxAction(siteRedirectToAuth);
|
||||
|
||||
const [settings] = useSystemSettings();
|
||||
|
||||
const canLogout = (settings.data?.auth.type ?? "none") !== "none";
|
||||
|
||||
const toggleSidebar = useContext(SidebarToggleContext);
|
||||
|
||||
const offline = useIsOffline();
|
||||
|
||||
const dropdown = useMemo(
|
||||
() => (
|
||||
<Dropdown alignRight>
|
||||
<Dropdown.Toggle className="dropdown-hidden" as={Button}>
|
||||
<FontAwesomeIcon icon={faUser}></FontAwesomeIcon>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
SystemApi.restart();
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
SystemApi.shutdown();
|
||||
}}
|
||||
>
|
||||
Shutdown
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Divider hidden={!canLogout}></Dropdown.Divider>
|
||||
<Dropdown.Item
|
||||
hidden={!canLogout}
|
||||
onClick={() => {
|
||||
SystemApi.logout().then(() => setNeedAuth());
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
),
|
||||
[canLogout, setNeedAuth]
|
||||
);
|
||||
|
||||
const [reconnecting, setReconnect] = useState(false);
|
||||
const reconnect = useCallback(() => {
|
||||
setReconnect(true);
|
||||
SystemApi.status().finally(() => setReconnect(false));
|
||||
}, []);
|
||||
|
||||
const baseUrl = useBaseUrl();
|
||||
|
||||
return (
|
||||
<Navbar bg="primary" className="flex-grow-1 px-0">
|
||||
<div className="header-icon px-3 m-0 d-none d-md-block">
|
||||
<Navbar.Brand href={baseUrl} className="">
|
||||
<Image alt="brand" src={logo} width="32" height="32"></Image>
|
||||
</Navbar.Brand>
|
||||
</div>
|
||||
<Button className="mx-2 m-0 d-md-none" onClick={toggleSidebar}>
|
||||
<FontAwesomeIcon icon={faBars}></FontAwesomeIcon>
|
||||
</Button>
|
||||
<Container fluid>
|
||||
<Row noGutters className="flex-grow-1">
|
||||
<Col xs={6} sm={4} className="d-flex align-items-center">
|
||||
<SearchBar onSearch={SearchItem}></SearchBar>
|
||||
</Col>
|
||||
<Col className="d-flex flex-row align-items-center justify-content-end pr-2">
|
||||
<Button
|
||||
href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XHHRWXT9YB7WE&source=url"
|
||||
target="_blank"
|
||||
>
|
||||
<FontAwesomeIcon icon={faHeart}></FontAwesomeIcon>
|
||||
</Button>
|
||||
{offline ? (
|
||||
<ActionButton
|
||||
loading={reconnecting}
|
||||
className="ml-2"
|
||||
variant="warning"
|
||||
icon={faNetworkWired}
|
||||
onClick={reconnect}
|
||||
>
|
||||
Reconnect
|
||||
</ActionButton>
|
||||
) : (
|
||||
dropdown
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
@ -0,0 +1,75 @@
|
||||
import React, { FunctionComponent, useEffect, useMemo } from "react";
|
||||
import { Redirect, Route, Switch, useHistory } from "react-router-dom";
|
||||
import EmptyPage, { RouterEmptyPath } from "../404";
|
||||
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site";
|
||||
import BlacklistRouter from "../Blacklist/Router";
|
||||
import HistoryRouter from "../History/Router";
|
||||
import MovieRouter from "../Movies/Router";
|
||||
import SeriesRouter from "../Series/Router";
|
||||
import SettingRouter from "../Settings/Router";
|
||||
import SystemRouter from "../System/Router";
|
||||
import { ScrollToTop } from "../utilites";
|
||||
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();
|
||||
|
||||
useEffect(() => {
|
||||
ScrollToTop();
|
||||
}, [history.location]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<Redirect exact to={redirectPath}></Redirect>
|
||||
</Route>
|
||||
{sonarr && (
|
||||
<Route path="/series">
|
||||
<SeriesRouter></SeriesRouter>
|
||||
</Route>
|
||||
)}
|
||||
{radarr && (
|
||||
<Route path="/movies">
|
||||
<MovieRouter></MovieRouter>
|
||||
</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;
|
@ -0,0 +1,30 @@
|
||||
@import "../@scss/variable.scss";
|
||||
|
||||
.header-container {
|
||||
height: $header-height;
|
||||
|
||||
input {
|
||||
&[type="text"] {
|
||||
// Fake Material Design Style
|
||||
padding: 0;
|
||||
transition: none;
|
||||
color: white;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
border-bottom: {
|
||||
color: white !important;
|
||||
width: 1px !important;
|
||||
style: solid !important;
|
||||
}
|
||||
background-color: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: lightgray;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,108 @@
|
||||
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Alert, Button, Container, Row } from "react-bootstrap";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import { bootstrap as ReduxBootstrap } from "../@redux/actions";
|
||||
import { useReduxAction, useReduxStore } from "../@redux/hooks/base";
|
||||
import { useNotification } from "../@redux/hooks/site";
|
||||
import { LoadingIndicator, ModalProvider } from "../components";
|
||||
import Sidebar from "../Sidebar";
|
||||
import { Reload, useHasUpdateInject } from "../utilites";
|
||||
import Header from "./Header";
|
||||
import NotificationContainer from "./notifications";
|
||||
import Router from "./Router";
|
||||
|
||||
// Sidebar Toggle
|
||||
export const SidebarToggleContext = React.createContext<() => void>(() => {});
|
||||
|
||||
interface Props {}
|
||||
|
||||
const App: FunctionComponent<Props> = () => {
|
||||
const bootstrap = useReduxAction(ReduxBootstrap);
|
||||
|
||||
const { initialized, auth } = useReduxStore((s) => s.site);
|
||||
|
||||
const notify = useNotification("has-update", 10);
|
||||
|
||||
// Has any update?
|
||||
const hasUpdate = useHasUpdateInject();
|
||||
useEffect(() => {
|
||||
if (initialized) {
|
||||
if (hasUpdate) {
|
||||
notify({
|
||||
type: "info",
|
||||
message: "A new version of Bazarr is ready, restart is required",
|
||||
// TODO: Restart action
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [initialized, hasUpdate, notify]);
|
||||
|
||||
useEffect(() => {
|
||||
bootstrap();
|
||||
}, [bootstrap]);
|
||||
|
||||
const [sidebar, setSidebar] = useState(false);
|
||||
const toggleSidebar = useCallback(() => setSidebar(!sidebar), [sidebar]);
|
||||
|
||||
if (!auth) {
|
||||
return <Redirect to="/login"></Redirect>;
|
||||
}
|
||||
|
||||
if (typeof initialized === "boolean" && initialized === false) {
|
||||
return (
|
||||
<LoadingIndicator>
|
||||
<span>Please wait</span>
|
||||
</LoadingIndicator>
|
||||
);
|
||||
} else if (typeof initialized === "string") {
|
||||
return <InitializationErrorView>{initialized}</InitializationErrorView>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarToggleContext.Provider value={toggleSidebar}>
|
||||
<Row noGutters className="header-container">
|
||||
<Header></Header>
|
||||
</Row>
|
||||
<Row noGutters className="flex-nowrap">
|
||||
<Sidebar open={sidebar}></Sidebar>
|
||||
<ModalProvider>
|
||||
<Router className="d-flex flex-row flex-grow-1 main-router"></Router>
|
||||
</ModalProvider>
|
||||
</Row>
|
||||
<NotificationContainer></NotificationContainer>
|
||||
</SidebarToggleContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const InitializationErrorView: FunctionComponent<{
|
||||
children: string;
|
||||
}> = ({ children }) => {
|
||||
return (
|
||||
<Container className="my-3">
|
||||
<Alert
|
||||
className="d-flex flex-nowrap justify-content-between align-items-center"
|
||||
variant="danger"
|
||||
>
|
||||
<div>
|
||||
<FontAwesomeIcon
|
||||
className="mr-2"
|
||||
icon={faExclamationTriangle}
|
||||
></FontAwesomeIcon>
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
<Button variant="outline-danger" onClick={Reload}>
|
||||
Reload
|
||||
</Button>
|
||||
</Alert>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
@ -0,0 +1,62 @@
|
||||
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { capitalize } from "lodash";
|
||||
import React, { FunctionComponent, useCallback, useMemo } from "react";
|
||||
import { Toast } from "react-bootstrap";
|
||||
import { siteRemoveError } from "../../@redux/actions";
|
||||
import { useReduxAction, useReduxStore } from "../../@redux/hooks/base";
|
||||
import "./style.scss";
|
||||
|
||||
function useNotificationList() {
|
||||
return useReduxStore((s) => s.site.notifications);
|
||||
}
|
||||
|
||||
function useRemoveNotification() {
|
||||
return useReduxAction(siteRemoveError);
|
||||
}
|
||||
|
||||
export interface NotificationContainerProps {}
|
||||
|
||||
const NotificationContainer: FunctionComponent<NotificationContainerProps> = () => {
|
||||
const list = useNotificationList();
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
list.map((v, idx) => (
|
||||
<NotificationToast key={v.id} {...v}></NotificationToast>
|
||||
)),
|
||||
[list]
|
||||
);
|
||||
return (
|
||||
<div className="alert-container">
|
||||
<div className="toast-container">{items}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type MessageHolderProps = ReduxStore.Notification & {};
|
||||
|
||||
const NotificationToast: FunctionComponent<MessageHolderProps> = (props) => {
|
||||
const { message, id, type } = props;
|
||||
const removeNotification = useRemoveNotification();
|
||||
|
||||
const remove = useCallback(() => removeNotification(id), [
|
||||
removeNotification,
|
||||
id,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Toast onClose={remove} animation={false}>
|
||||
<Toast.Header>
|
||||
<FontAwesomeIcon
|
||||
className="mr-1"
|
||||
icon={faExclamationTriangle}
|
||||
></FontAwesomeIcon>
|
||||
<strong className="mr-auto">{capitalize(type)}</strong>
|
||||
</Toast.Header>
|
||||
<Toast.Body>{message}</Toast.Body>
|
||||
</Toast>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationContainer;
|
@ -0,0 +1,23 @@
|
||||
@import "../../@scss/variable.scss";
|
||||
|
||||
.alert-container {
|
||||
position: fixed;
|
||||
display: block;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin-top: $header-height;
|
||||
|
||||
z-index: 9999;
|
||||
|
||||
.toast-container {
|
||||
padding: 1rem;
|
||||
|
||||
.toast {
|
||||
max-width: 260px;
|
||||
min-width: 200px;
|
||||
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Form,
|
||||
Image,
|
||||
Spinner,
|
||||
} from "react-bootstrap";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import { siteAuthSuccess } from "../@redux/actions";
|
||||
import { useReduxAction, useReduxStore } from "../@redux/hooks/base";
|
||||
import logo from "../@static/logo128.png";
|
||||
import { SystemApi } from "../apis";
|
||||
import "./style.scss";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const AuthPage: FunctionComponent<Props> = () => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [updating, setUpdate] = useState(false);
|
||||
|
||||
const updateError = useCallback((msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => setError(""), 2000);
|
||||
}, []);
|
||||
|
||||
const onSuccess = useReduxAction(siteAuthSuccess);
|
||||
|
||||
const authState = useReduxStore((s) => s.site.auth);
|
||||
|
||||
const onError = useCallback(() => {
|
||||
setUpdate(false);
|
||||
updateError("Login Failed");
|
||||
}, [updateError]);
|
||||
|
||||
if (authState) {
|
||||
return <Redirect to="/"></Redirect>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="d-flex bg-light vh-100 justify-content-center align-items-center">
|
||||
<Card className="auth-card shadow">
|
||||
<Form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!updating) {
|
||||
setUpdate(true);
|
||||
SystemApi.login(username, password)
|
||||
.then(onSuccess)
|
||||
.catch(onError);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card.Body>
|
||||
<Form.Group className="mb-5 d-flex justify-content-center">
|
||||
<Image width="64" height="64" src={logo}></Image>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
disabled={updating}
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
required
|
||||
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||
></Form.Control>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
disabled={updating}
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
></Form.Control>
|
||||
</Form.Group>
|
||||
<Collapse in={error.length !== 0}>
|
||||
<div>
|
||||
<Alert variant="danger" className="m-0">
|
||||
{error}
|
||||
</Alert>
|
||||
</div>
|
||||
</Collapse>
|
||||
</Card.Body>
|
||||
<Card.Footer>
|
||||
<Button type="submit" disabled={updating} block>
|
||||
{updating ? (
|
||||
<Spinner size="sm" animation="border"></Spinner>
|
||||
) : (
|
||||
"LOGIN"
|
||||
)}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthPage;
|
@ -0,0 +1,3 @@
|
||||
.auth-card {
|
||||
width: 24rem;
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Container, Row } from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useBlacklistMovies } from "../../@redux/hooks";
|
||||
import { MoviesApi } from "../../apis";
|
||||
import { AsyncStateOverlay, ContentHeader } from "../../components";
|
||||
import { useAutoUpdate } from "../../utilites/hooks";
|
||||
import Table from "./table";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const BlacklistMoviesView: FunctionComponent<Props> = () => {
|
||||
const [blacklist, update] = useBlacklistMovies();
|
||||
useAutoUpdate(update);
|
||||
return (
|
||||
<AsyncStateOverlay state={blacklist}>
|
||||
{(data) => (
|
||||
<Container fluid>
|
||||
<Helmet>
|
||||
<title>Movies Blacklist - Bazarr</title>
|
||||
</Helmet>
|
||||
<ContentHeader>
|
||||
<ContentHeader.AsyncButton
|
||||
icon={faTrash}
|
||||
disabled={data.length === 0}
|
||||
promise={() => MoviesApi.deleteBlacklist(true)}
|
||||
onSuccess={update}
|
||||
>
|
||||
Remove All
|
||||
</ContentHeader.AsyncButton>
|
||||
</ContentHeader>
|
||||
<Row>
|
||||
<Table blacklist={data} update={update}></Table>
|
||||
</Row>
|
||||
</Container>
|
||||
)}
|
||||
</AsyncStateOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlacklistMoviesView;
|
@ -0,0 +1,84 @@
|
||||
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Column } from "react-table";
|
||||
import { MoviesApi } from "../../apis";
|
||||
import { AsyncButton, LanguageText, PageTable } from "../../components";
|
||||
|
||||
interface Props {
|
||||
blacklist: readonly Blacklist.Movie[];
|
||||
update: () => void;
|
||||
}
|
||||
|
||||
const Table: FunctionComponent<Props> = ({ blacklist, update }) => {
|
||||
const columns = useMemo<Column<Blacklist.Movie>[]>(
|
||||
() => [
|
||||
{
|
||||
Header: "Name",
|
||||
accessor: "title",
|
||||
className: "text-nowrap",
|
||||
Cell: (row) => {
|
||||
const target = `/movies/${row.row.original.radarrId}`;
|
||||
return (
|
||||
<Link to={target}>
|
||||
<span>{row.value}</span>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Language",
|
||||
accessor: "language",
|
||||
Cell: ({ value }) => {
|
||||
if (value) {
|
||||
return <LanguageText text={value} long></LanguageText>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Provider",
|
||||
accessor: "provider",
|
||||
},
|
||||
{
|
||||
Header: "Date",
|
||||
accessor: "timestamp",
|
||||
},
|
||||
{
|
||||
accessor: "subs_id",
|
||||
Cell: (row) => {
|
||||
const subs_id = row.value;
|
||||
|
||||
return (
|
||||
<AsyncButton
|
||||
size="sm"
|
||||
variant="light"
|
||||
noReset
|
||||
promise={() =>
|
||||
MoviesApi.deleteBlacklist(false, {
|
||||
provider: row.row.original.provider,
|
||||
subs_id,
|
||||
})
|
||||
}
|
||||
onSuccess={update}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
|
||||
</AsyncButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[update]
|
||||
);
|
||||
return (
|
||||
<PageTable
|
||||
emptyText="No Blacklisted Movies Subtitles"
|
||||
columns={columns}
|
||||
data={blacklist}
|
||||
></PageTable>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
@ -0,0 +1,30 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { RouterEmptyPath } from "../404";
|
||||
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site";
|
||||
import BlacklistMovies from "./Movies";
|
||||
import BlacklistSeries from "./Series";
|
||||
|
||||
const Router: FunctionComponent = () => {
|
||||
const sonarr = useIsSonarrEnabled();
|
||||
const radarr = useIsRadarrEnabled();
|
||||
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;
|
@ -0,0 +1,42 @@
|
||||
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Container, Row } from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useBlacklistSeries } from "../../@redux/hooks";
|
||||
import { EpisodesApi } from "../../apis";
|
||||
import { AsyncStateOverlay, ContentHeader } from "../../components";
|
||||
import { useAutoUpdate } from "../../utilites";
|
||||
import Table from "./table";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const BlacklistSeriesView: FunctionComponent<Props> = () => {
|
||||
const [blacklist, update] = useBlacklistSeries();
|
||||
useAutoUpdate(update);
|
||||
return (
|
||||
<AsyncStateOverlay state={blacklist}>
|
||||
{(data) => (
|
||||
<Container fluid>
|
||||
<Helmet>
|
||||
<title>Series Blacklist - Bazarr</title>
|
||||
</Helmet>
|
||||
<ContentHeader>
|
||||
<ContentHeader.AsyncButton
|
||||
icon={faTrash}
|
||||
disabled={data.length === 0}
|
||||
promise={() => EpisodesApi.deleteBlacklist(true)}
|
||||
onSuccess={update}
|
||||
>
|
||||
Remove All
|
||||
</ContentHeader.AsyncButton>
|
||||
</ContentHeader>
|
||||
<Row>
|
||||
<Table blacklist={data} update={update}></Table>
|
||||
</Row>
|
||||
</Container>
|
||||
)}
|
||||
</AsyncStateOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlacklistSeriesView;
|
@ -0,0 +1,90 @@
|
||||
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Column } from "react-table";
|
||||
import { EpisodesApi } from "../../apis";
|
||||
import { AsyncButton, LanguageText, PageTable } from "../../components";
|
||||
|
||||
interface Props {
|
||||
blacklist: readonly Blacklist.Episode[];
|
||||
update: () => void;
|
||||
}
|
||||
|
||||
const Table: FunctionComponent<Props> = ({ blacklist, update }) => {
|
||||
const columns = useMemo<Column<Blacklist.Episode>[]>(
|
||||
() => [
|
||||
{
|
||||
Header: "Series",
|
||||
accessor: "seriesTitle",
|
||||
className: "text-nowrap",
|
||||
Cell: (row) => {
|
||||
const target = `/series/${row.row.original.sonarrSeriesId}`;
|
||||
return (
|
||||
<Link to={target}>
|
||||
<span>{row.value}</span>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Episode",
|
||||
accessor: "episode_number",
|
||||
},
|
||||
{
|
||||
accessor: "episodeTitle",
|
||||
},
|
||||
{
|
||||
Header: "Language",
|
||||
accessor: "language",
|
||||
Cell: ({ value }) => {
|
||||
if (value) {
|
||||
return <LanguageText text={value} long></LanguageText>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Provider",
|
||||
accessor: "provider",
|
||||
},
|
||||
{
|
||||
Header: "Date",
|
||||
accessor: "timestamp",
|
||||
},
|
||||
{
|
||||
accessor: "subs_id",
|
||||
Cell: (row) => {
|
||||
const subs_id = row.value;
|
||||
return (
|
||||
<AsyncButton
|
||||
size="sm"
|
||||
variant="light"
|
||||
noReset
|
||||
promise={() =>
|
||||
EpisodesApi.deleteBlacklist(false, {
|
||||
provider: row.row.original.provider,
|
||||
subs_id,
|
||||
})
|
||||
}
|
||||
onSuccess={update}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
|
||||
</AsyncButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[update]
|
||||
);
|
||||
return (
|
||||
<PageTable
|
||||
emptyText="No Blacklisted Series Subtitles"
|
||||
columns={columns}
|
||||
data={blacklist}
|
||||
></PageTable>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
@ -0,0 +1,113 @@
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent, useCallback, useMemo } from "react";
|
||||
import { Badge, OverlayTrigger, Popover } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Column, Row } from "react-table";
|
||||
import { useMoviesHistory } from "../../@redux/hooks";
|
||||
import { MoviesApi } from "../../apis";
|
||||
import { HistoryIcon, LanguageText } from "../../components";
|
||||
import { BlacklistButton } from "../../generic/blacklist";
|
||||
import { useAutoUpdate } from "../../utilites/hooks";
|
||||
import HistoryGenericView from "../generic";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const MoviesHistoryView: FunctionComponent<Props> = () => {
|
||||
const [movies, update] = useMoviesHistory();
|
||||
useAutoUpdate(update);
|
||||
|
||||
const tableUpdate = useCallback((row: Row<History.Base>) => update(), [
|
||||
update,
|
||||
]);
|
||||
|
||||
const columns: Column<History.Movie>[] = useMemo<Column<History.Movie>[]>(
|
||||
() => [
|
||||
{
|
||||
accessor: "action",
|
||||
className: "text-center",
|
||||
Cell: (row) => <HistoryIcon action={row.value}></HistoryIcon>,
|
||||
},
|
||||
{
|
||||
Header: "Name",
|
||||
accessor: "title",
|
||||
className: "text-nowrap",
|
||||
Cell: (row) => {
|
||||
const target = `/movies/${row.row.original.radarrId}`;
|
||||
|
||||
return (
|
||||
<Link to={target}>
|
||||
<span>{row.value}</span>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Language",
|
||||
accessor: "language",
|
||||
Cell: ({ value }) => {
|
||||
if (value) {
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<LanguageText text={value} long></LanguageText>
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Score",
|
||||
accessor: "score",
|
||||
},
|
||||
{
|
||||
Header: "Date",
|
||||
accessor: "timestamp",
|
||||
className: "text-nowrap",
|
||||
},
|
||||
{
|
||||
accessor: "description",
|
||||
Cell: ({ row, value }) => {
|
||||
const overlay = (
|
||||
<Popover id={`description-${row.id}`}>
|
||||
<Popover.Content>{value}</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
return (
|
||||
<OverlayTrigger overlay={overlay}>
|
||||
<FontAwesomeIcon size="sm" icon={faInfoCircle}></FontAwesomeIcon>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "blacklisted",
|
||||
Cell: ({ row, externalUpdate }) => {
|
||||
const original = row.original;
|
||||
return (
|
||||
<BlacklistButton
|
||||
history={original}
|
||||
update={() => externalUpdate && externalUpdate(row)}
|
||||
promise={(form) =>
|
||||
MoviesApi.addBlacklist(original.radarrId, form)
|
||||
}
|
||||
></BlacklistButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<HistoryGenericView
|
||||
type="movies"
|
||||
state={movies}
|
||||
columns={columns as Column<History.Base>[]}
|
||||
tableUpdater={tableUpdate}
|
||||
></HistoryGenericView>
|
||||
);
|
||||
};
|
||||
|
||||
export default MoviesHistoryView;
|
@ -0,0 +1,34 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { RouterEmptyPath } from "../404";
|
||||
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site";
|
||||
import MoviesHistory from "./Movies";
|
||||
import SeriesHistory from "./Series";
|
||||
import HistoryStats from "./Statistics";
|
||||
|
||||
const Router: FunctionComponent = () => {
|
||||
const sonarr = useIsSonarrEnabled();
|
||||
const radarr = useIsRadarrEnabled();
|
||||
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;
|
@ -0,0 +1,122 @@
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent, useCallback, useMemo } from "react";
|
||||
import { Badge, OverlayTrigger, Popover } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Column, Row } from "react-table";
|
||||
import { useSeriesHistory } from "../../@redux/hooks";
|
||||
import { EpisodesApi } from "../../apis";
|
||||
import { HistoryIcon, LanguageText } from "../../components";
|
||||
import { BlacklistButton } from "../../generic/blacklist";
|
||||
import { useAutoUpdate } from "../../utilites/hooks";
|
||||
import HistoryGenericView from "../generic";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const SeriesHistoryView: FunctionComponent<Props> = () => {
|
||||
const [series, update] = useSeriesHistory();
|
||||
useAutoUpdate(update);
|
||||
|
||||
const tableUpdate = useCallback((row: Row<History.Base>) => update(), [
|
||||
update,
|
||||
]);
|
||||
|
||||
const columns: Column<History.Episode>[] = useMemo<Column<History.Episode>[]>(
|
||||
() => [
|
||||
{
|
||||
accessor: "action",
|
||||
className: "text-center",
|
||||
Cell: ({ value }) => <HistoryIcon action={value}></HistoryIcon>,
|
||||
},
|
||||
{
|
||||
Header: "Series",
|
||||
accessor: "seriesTitle",
|
||||
Cell: (row) => {
|
||||
const target = `/series/${row.row.original.sonarrSeriesId}`;
|
||||
|
||||
return (
|
||||
<Link to={target}>
|
||||
<span>{row.value}</span>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Episode",
|
||||
accessor: "episode_number",
|
||||
},
|
||||
{
|
||||
Header: "Title",
|
||||
accessor: "episodeTitle",
|
||||
},
|
||||
{
|
||||
Header: "Language",
|
||||
accessor: "language",
|
||||
Cell: ({ value }) => {
|
||||
if (value) {
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<LanguageText text={value} long></LanguageText>
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Score",
|
||||
accessor: "score",
|
||||
},
|
||||
{
|
||||
Header: "Date",
|
||||
accessor: "timestamp",
|
||||
className: "text-nowrap",
|
||||
},
|
||||
{
|
||||
accessor: "description",
|
||||
Cell: ({ row, value }) => {
|
||||
const overlay = (
|
||||
<Popover id={`description-${row.id}`}>
|
||||
<Popover.Content>{value}</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
return (
|
||||
<OverlayTrigger overlay={overlay}>
|
||||
<FontAwesomeIcon size="sm" icon={faInfoCircle}></FontAwesomeIcon>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "blacklisted",
|
||||
Cell: ({ row, externalUpdate }) => {
|
||||
const original = row.original;
|
||||
|
||||
const { sonarrEpisodeId, sonarrSeriesId } = original;
|
||||
return (
|
||||
<BlacklistButton
|
||||
history={original}
|
||||
update={() => externalUpdate && externalUpdate(row)}
|
||||
promise={(form) =>
|
||||
EpisodesApi.addBlacklist(sonarrSeriesId, sonarrEpisodeId, form)
|
||||
}
|
||||
></BlacklistButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<HistoryGenericView
|
||||
type="series"
|
||||
state={series}
|
||||
columns={columns as Column<History.Base>[]}
|
||||
tableUpdater={tableUpdate}
|
||||
></HistoryGenericView>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeriesHistoryView;
|
@ -0,0 +1,131 @@
|
||||
import { merge } from "lodash";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
import { Col, Container } from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { useLanguages, useProviders } from "../../@redux/hooks";
|
||||
import { HistoryApi } from "../../apis";
|
||||
import {
|
||||
AsyncSelector,
|
||||
ContentHeader,
|
||||
LanguageSelector,
|
||||
PromiseOverlay,
|
||||
Selector,
|
||||
} from "../../components";
|
||||
import { useAutoUpdate } from "../../utilites/hooks";
|
||||
import { actionOptions, timeframeOptions } from "./options";
|
||||
|
||||
function converter(item: History.Stat) {
|
||||
const movies = item.movies.map((v) => ({
|
||||
date: v.date,
|
||||
movies: v.count,
|
||||
}));
|
||||
const series = item.series.map((v) => ({
|
||||
date: v.date,
|
||||
series: v.count,
|
||||
}));
|
||||
const result = merge(movies, series);
|
||||
return result;
|
||||
}
|
||||
|
||||
const providerLabel = (item: System.Provider) => item.name;
|
||||
|
||||
const SelectorContainer: FunctionComponent = ({ children }) => (
|
||||
<Col xs={6} lg={3} className="p-1">
|
||||
{children}
|
||||
</Col>
|
||||
);
|
||||
|
||||
const HistoryStats: FunctionComponent = () => {
|
||||
const [languages] = useLanguages(true);
|
||||
|
||||
const [providerList, update] = useProviders();
|
||||
useAutoUpdate(update);
|
||||
|
||||
const [timeframe, setTimeframe] = useState<History.TimeframeOptions>("month");
|
||||
const [action, setAction] = useState<Nullable<History.ActionOptions>>(null);
|
||||
const [lang, setLanguage] = useState<Nullable<Language>>(null);
|
||||
const [provider, setProvider] = useState<Nullable<System.Provider>>(null);
|
||||
|
||||
const promise = useCallback(() => {
|
||||
return HistoryApi.stats(
|
||||
timeframe,
|
||||
action ?? undefined,
|
||||
provider?.name,
|
||||
lang?.code2
|
||||
);
|
||||
}, [timeframe, lang?.code2, action, provider]);
|
||||
|
||||
return (
|
||||
// TODO: Responsive
|
||||
<Container fluid className="vh-75">
|
||||
<Helmet>
|
||||
<title>History Statistics - Bazarr</title>
|
||||
</Helmet>
|
||||
<PromiseOverlay promise={promise}>
|
||||
{(data) => (
|
||||
<React.Fragment>
|
||||
<ContentHeader scroll={false}>
|
||||
<SelectorContainer>
|
||||
<Selector
|
||||
placeholder="Time..."
|
||||
options={timeframeOptions}
|
||||
value={timeframe}
|
||||
onChange={(v) => setTimeframe(v ?? "month")}
|
||||
></Selector>
|
||||
</SelectorContainer>
|
||||
<SelectorContainer>
|
||||
<Selector
|
||||
placeholder="Action..."
|
||||
clearable
|
||||
options={actionOptions}
|
||||
value={action}
|
||||
onChange={setAction}
|
||||
></Selector>
|
||||
</SelectorContainer>
|
||||
<SelectorContainer>
|
||||
<AsyncSelector
|
||||
placeholder="Provider..."
|
||||
clearable
|
||||
state={providerList}
|
||||
label={providerLabel}
|
||||
onChange={setProvider}
|
||||
></AsyncSelector>
|
||||
</SelectorContainer>
|
||||
<SelectorContainer>
|
||||
<LanguageSelector
|
||||
clearable
|
||||
options={languages}
|
||||
value={lang}
|
||||
onChange={setLanguage}
|
||||
></LanguageSelector>
|
||||
</SelectorContainer>
|
||||
</ContentHeader>
|
||||
<ResponsiveContainer height="100%">
|
||||
<BarChart data={converter(data)}>
|
||||
<CartesianGrid strokeDasharray="4 2"></CartesianGrid>
|
||||
<XAxis dataKey="date"></XAxis>
|
||||
<YAxis allowDecimals={false}></YAxis>
|
||||
<Tooltip></Tooltip>
|
||||
<Legend verticalAlign="top"></Legend>
|
||||
<Bar name="Series" dataKey="series" fill="#2493B6"></Bar>
|
||||
<Bar name="Movies" dataKey="movies" fill="#FFC22F"></Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</PromiseOverlay>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryStats;
|
@ -0,0 +1,33 @@
|
||||
export const actionOptions: SelectorOption<History.ActionOptions>[] = [
|
||||
{
|
||||
label: "Automatically Downloaded",
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
label: "Manually Downloaded",
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: "Upgraded",
|
||||
value: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export const timeframeOptions: SelectorOption<History.TimeframeOptions>[] = [
|
||||
{
|
||||
label: "Last Week",
|
||||
value: "week",
|
||||
},
|
||||
{
|
||||
label: "Last Month",
|
||||
value: "month",
|
||||
},
|
||||
{
|
||||
label: "Last Trimester",
|
||||
value: "trimester",
|
||||
},
|
||||
{
|
||||
label: "Last Year",
|
||||
value: "year",
|
||||
},
|
||||
];
|
@ -0,0 +1,43 @@
|
||||
import { capitalize } from "lodash";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Container, Row } from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Column, TableUpdater } from "react-table";
|
||||
import { AsyncStateOverlay, PageTable } from "../../components";
|
||||
|
||||
interface Props {
|
||||
type: "movies" | "series";
|
||||
state: Readonly<AsyncState<History.Base[]>>;
|
||||
columns: Column<History.Base>[];
|
||||
tableUpdater?: TableUpdater<History.Base>;
|
||||
}
|
||||
|
||||
const HistoryGenericView: FunctionComponent<Props> = ({
|
||||
state,
|
||||
columns,
|
||||
type,
|
||||
tableUpdater,
|
||||
}) => {
|
||||
const typeName = capitalize(type);
|
||||
return (
|
||||
<Container fluid>
|
||||
<Helmet>
|
||||
<title>{typeName} History - Bazarr</title>
|
||||
</Helmet>
|
||||
<Row>
|
||||
<AsyncStateOverlay state={state}>
|
||||
{(data) => (
|
||||
<PageTable
|
||||
emptyText={`Nothing Found in ${typeName} History`}
|
||||
columns={columns}
|
||||
data={data}
|
||||
externalUpdate={tableUpdater}
|
||||
></PageTable>
|
||||
)}
|
||||
</AsyncStateOverlay>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryGenericView;
|
@ -0,0 +1,170 @@
|
||||
import {
|
||||
faCloudUploadAlt,
|
||||
faHistory,
|
||||
faSearch,
|
||||
faSync,
|
||||
faToolbox,
|
||||
faUser,
|
||||
faWrench,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
import { Container, Row } from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
|
||||
import { RouterEmptyPath } from "../../404";
|
||||
import { useMovieBy } from "../../@redux/hooks";
|
||||
import { MoviesApi, ProvidersApi } from "../../apis";
|
||||
import {
|
||||
ContentHeader,
|
||||
ItemEditorModal,
|
||||
LoadingIndicator,
|
||||
MovieHistoryModal,
|
||||
MovieUploadModal,
|
||||
SubtitleToolModal,
|
||||
useShowModal,
|
||||
} from "../../components";
|
||||
import { ManualSearchModal } from "../../components/modals/ManualSearchModal";
|
||||
import ItemOverview from "../../generic/ItemOverview";
|
||||
import { useAutoUpdate, useWhenLoadingFinish } from "../../utilites";
|
||||
import Table from "./table";
|
||||
|
||||
const download = (item: any, result: SearchResultType) => {
|
||||
item = item as Item.Movie;
|
||||
const { language, hearing_impaired, forced, provider, subtitle } = result;
|
||||
return ProvidersApi.downloadMovieSubtitle(item.radarrId, {
|
||||
language,
|
||||
hi: hearing_impaired,
|
||||
forced,
|
||||
provider,
|
||||
subtitle,
|
||||
});
|
||||
};
|
||||
|
||||
interface Params {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface Props extends RouteComponentProps<Params> {}
|
||||
|
||||
const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
|
||||
const id = Number.parseInt(match.params.id);
|
||||
const [movie, update] = useMovieBy(id);
|
||||
useAutoUpdate(update);
|
||||
const item = movie.data;
|
||||
|
||||
const showModal = useShowModal();
|
||||
|
||||
const [valid, setValid] = useState(true);
|
||||
|
||||
const validator = useCallback(() => {
|
||||
if (movie.data === null) {
|
||||
setValid(false);
|
||||
}
|
||||
}, [movie.data]);
|
||||
|
||||
useWhenLoadingFinish(movie, validator);
|
||||
|
||||
if (isNaN(id) || !valid) {
|
||||
return <Redirect to={RouterEmptyPath}></Redirect>;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return <LoadingIndicator></LoadingIndicator>;
|
||||
}
|
||||
|
||||
const allowEdit = item.profileId !== undefined;
|
||||
|
||||
return (
|
||||
<Container fluid>
|
||||
<Helmet>
|
||||
<title>{item.title} - Bazarr (Movies)</title>
|
||||
</Helmet>
|
||||
<ContentHeader>
|
||||
<ContentHeader.Group pos="start">
|
||||
<ContentHeader.AsyncButton
|
||||
icon={faSync}
|
||||
promise={() =>
|
||||
MoviesApi.action({ action: "scan-disk", radarrid: item.radarrId })
|
||||
}
|
||||
onSuccess={update}
|
||||
>
|
||||
Scan Disk
|
||||
</ContentHeader.AsyncButton>
|
||||
<ContentHeader.AsyncButton
|
||||
icon={faSearch}
|
||||
disabled={item.profileId === null}
|
||||
promise={() =>
|
||||
MoviesApi.action({
|
||||
action: "search-missing",
|
||||
radarrid: item.radarrId,
|
||||
})
|
||||
}
|
||||
onSuccess={update}
|
||||
>
|
||||
Search
|
||||
</ContentHeader.AsyncButton>
|
||||
<ContentHeader.Button
|
||||
icon={faUser}
|
||||
disabled={item.profileId === null}
|
||||
onClick={() => showModal<Item.Movie>("manual-search", item)}
|
||||
>
|
||||
Manual
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.Button
|
||||
icon={faHistory}
|
||||
onClick={() => showModal("history", item)}
|
||||
>
|
||||
History
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.Button
|
||||
icon={faToolbox}
|
||||
onClick={() => showModal("tools", [item])}
|
||||
>
|
||||
Tools
|
||||
</ContentHeader.Button>
|
||||
</ContentHeader.Group>
|
||||
|
||||
<ContentHeader.Group pos="end">
|
||||
<ContentHeader.Button
|
||||
disabled={!allowEdit || item.profileId === null}
|
||||
icon={faCloudUploadAlt}
|
||||
onClick={() => showModal("upload", item)}
|
||||
>
|
||||
Upload
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.Button
|
||||
icon={faWrench}
|
||||
onClick={() => showModal("edit", item)}
|
||||
>
|
||||
Edit Movie
|
||||
</ContentHeader.Button>
|
||||
</ContentHeader.Group>
|
||||
</ContentHeader>
|
||||
<Row>
|
||||
<ItemOverview item={item} details={[]}></ItemOverview>
|
||||
</Row>
|
||||
<Row>
|
||||
<Table movie={item} update={update}></Table>
|
||||
</Row>
|
||||
<ItemEditorModal
|
||||
modalKey="edit"
|
||||
submit={(form) => MoviesApi.modify(form)}
|
||||
onSuccess={update}
|
||||
></ItemEditorModal>
|
||||
<SubtitleToolModal
|
||||
modalKey="tools"
|
||||
size="lg"
|
||||
update={update}
|
||||
></SubtitleToolModal>
|
||||
<MovieHistoryModal modalKey="history" size="lg"></MovieHistoryModal>
|
||||
<MovieUploadModal modalKey="upload" size="lg"></MovieUploadModal>
|
||||
<ManualSearchModal
|
||||
modalKey="manual-search"
|
||||
onDownload={update}
|
||||
onSelect={download}
|
||||
></ManualSearchModal>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default withRouter(MovieDetailView);
|
@ -0,0 +1,119 @@
|
||||
import { faSearch, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { Badge } from "react-bootstrap";
|
||||
import { Column } from "react-table";
|
||||
import { MoviesApi } from "../../apis";
|
||||
import { AsyncButton, LanguageText, SimpleTable } from "../../components";
|
||||
|
||||
const missingText = "Missing Subtitles";
|
||||
|
||||
interface Props {
|
||||
movie: Item.Movie;
|
||||
update: (id: number) => void;
|
||||
}
|
||||
|
||||
const Table: FunctionComponent<Props> = (props) => {
|
||||
const { movie, update } = props;
|
||||
|
||||
const columns: Column<Subtitle>[] = useMemo<Column<Subtitle>[]>(
|
||||
() => [
|
||||
{
|
||||
Header: "Subtitle Path",
|
||||
accessor: "path",
|
||||
Cell: (row) => {
|
||||
if (row.value === null || row.value.length === 0) {
|
||||
return "Video File Subtitle Track";
|
||||
} else if (row.value === missingText) {
|
||||
return <span className="text-muted">{row.value}</span>;
|
||||
} else {
|
||||
return row.value;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Language",
|
||||
accessor: "name",
|
||||
Cell: ({ row }) => {
|
||||
if (row.original.path === missingText) {
|
||||
return (
|
||||
<Badge variant="primary">
|
||||
<LanguageText text={row.original} long></LanguageText>
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<LanguageText text={row.original} long></LanguageText>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "code2",
|
||||
Cell: (row) => {
|
||||
const { original } = row.row;
|
||||
if (original.path === null || original.path.length === 0) {
|
||||
return null;
|
||||
} else if (original.path === missingText) {
|
||||
return (
|
||||
<AsyncButton
|
||||
promise={() =>
|
||||
MoviesApi.downloadSubtitles(movie.radarrId, {
|
||||
language: original.code2,
|
||||
hi: original.hi,
|
||||
forced: original.forced,
|
||||
})
|
||||
}
|
||||
onSuccess={() => update(movie.radarrId)}
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
<FontAwesomeIcon icon={faSearch}></FontAwesomeIcon>
|
||||
</AsyncButton>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<AsyncButton
|
||||
variant="light"
|
||||
size="sm"
|
||||
promise={() =>
|
||||
MoviesApi.deleteSubtitles(movie.radarrId, {
|
||||
language: original.code2,
|
||||
hi: original.hi,
|
||||
forced: original.forced,
|
||||
path: original.path ?? "",
|
||||
})
|
||||
}
|
||||
onSuccess={() => update(movie.radarrId)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
|
||||
</AsyncButton>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
[movie, update]
|
||||
);
|
||||
|
||||
const data: Subtitle[] = useMemo(() => {
|
||||
const missing = movie.missing_subtitles.map((item) => {
|
||||
item.path = missingText;
|
||||
return item;
|
||||
});
|
||||
|
||||
return movie.subtitles.concat(missing);
|
||||
}, [movie.missing_subtitles, movie.subtitles]);
|
||||
|
||||
return (
|
||||
<SimpleTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
emptyText="No Subtitles Found For This Movie"
|
||||
></SimpleTable>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
@ -0,0 +1,21 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import Movie from ".";
|
||||
import MovieDetail from "./Detail";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const Router: FunctionComponent<Props> = () => {
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/movies">
|
||||
<Movie></Movie>
|
||||
</Route>
|
||||
<Route path="/movies/:id">
|
||||
<MovieDetail></MovieDetail>
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
export default Router;
|
@ -0,0 +1,131 @@
|
||||
import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faBookmark,
|
||||
faCheck,
|
||||
faExclamationTriangle,
|
||||
faWrench,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { Badge } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Column } from "react-table";
|
||||
import { movieUpdateByRange, movieUpdateInfoAll } from "../@redux/actions";
|
||||
import { useRawMovies } from "../@redux/hooks";
|
||||
import { useReduxAction } from "../@redux/hooks/base";
|
||||
import { MoviesApi } from "../apis";
|
||||
import { ActionBadge } from "../components";
|
||||
import BaseItemView from "../generic/BaseItemView";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const MovieView: FunctionComponent<Props> = () => {
|
||||
const [movies] = useRawMovies();
|
||||
const load = useReduxAction(movieUpdateByRange);
|
||||
const columns: Column<Item.Movie>[] = useMemo<Column<Item.Movie>[]>(
|
||||
() => [
|
||||
{
|
||||
accessor: "monitored",
|
||||
selectHide: true,
|
||||
Cell: ({ value }) => (
|
||||
<FontAwesomeIcon
|
||||
title={value ? "monitored" : "unmonitored"}
|
||||
icon={value ? faBookmark : farBookmark}
|
||||
></FontAwesomeIcon>
|
||||
),
|
||||
},
|
||||
{
|
||||
Header: "Name",
|
||||
accessor: "title",
|
||||
className: "text-nowrap",
|
||||
Cell: ({ row, value, isSelecting: select }) => {
|
||||
if (select) {
|
||||
return value;
|
||||
} else {
|
||||
const target = `/movies/${row.original.radarrId}`;
|
||||
return (
|
||||
<Link to={target} title={row.original.sceneName ?? value}>
|
||||
<span>{value}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Exist",
|
||||
accessor: "exist",
|
||||
selectHide: true,
|
||||
Cell: ({ row, value }) => {
|
||||
const exist = value;
|
||||
const { path } = row.original;
|
||||
return (
|
||||
<FontAwesomeIcon
|
||||
title={path}
|
||||
icon={exist ? faCheck : faExclamationTriangle}
|
||||
></FontAwesomeIcon>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Audio",
|
||||
accessor: "audio_language",
|
||||
Cell: (row) => {
|
||||
return row.value.map((v) => (
|
||||
<Badge variant="secondary" className="mr-2" key={v.code2}>
|
||||
{v.name}
|
||||
</Badge>
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Languages Profile",
|
||||
accessor: "profileId",
|
||||
Cell: ({ value, loose }) => {
|
||||
if (loose) {
|
||||
// Define in generic/BaseItemView/table.tsx
|
||||
const profiles = loose[0] as Profile.Languages[];
|
||||
return profiles.find((v) => v.profileId === value)?.name ?? null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "missing_subtitles",
|
||||
selectHide: true,
|
||||
Cell: (row) => {
|
||||
const missing = row.value;
|
||||
return missing.map((v) => (
|
||||
<Badge className="mx-2" variant="warning" key={v.code2}>
|
||||
{v.code2}
|
||||
</Badge>
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "radarrId",
|
||||
selectHide: true,
|
||||
Cell: ({ row, externalUpdate }) => (
|
||||
<ActionBadge
|
||||
icon={faWrench}
|
||||
onClick={() => externalUpdate && externalUpdate(row, "edit")}
|
||||
></ActionBadge>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseItemView
|
||||
state={movies}
|
||||
name="Movies"
|
||||
loader={load}
|
||||
updateAction={movieUpdateInfoAll}
|
||||
columns={columns as Column<Item.Base>[]}
|
||||
modify={(form) => MoviesApi.modify(form)}
|
||||
></BaseItemView>
|
||||
);
|
||||
};
|
||||
|
||||
export default MovieView;
|
@ -0,0 +1,68 @@
|
||||
import { faSearch, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Badge } from "react-bootstrap";
|
||||
import { useSerieBy } from "../../@redux/hooks";
|
||||
import { EpisodesApi } from "../../apis";
|
||||
import { AsyncButton, LanguageText } from "../../components";
|
||||
|
||||
interface Props {
|
||||
seriesid: number;
|
||||
episodeid: number;
|
||||
missing?: boolean;
|
||||
subtitle: Subtitle;
|
||||
}
|
||||
|
||||
export const SubtitleAction: FunctionComponent<Props> = ({
|
||||
seriesid,
|
||||
episodeid,
|
||||
missing,
|
||||
subtitle,
|
||||
}) => {
|
||||
const { hi, forced } = subtitle;
|
||||
|
||||
const [, update] = useSerieBy(seriesid);
|
||||
|
||||
const path = subtitle.path;
|
||||
|
||||
if (missing || path) {
|
||||
return (
|
||||
<AsyncButton
|
||||
promise={() => {
|
||||
if (missing) {
|
||||
return EpisodesApi.downloadSubtitles(seriesid, episodeid, {
|
||||
hi,
|
||||
forced,
|
||||
language: subtitle.code2,
|
||||
});
|
||||
} else if (path) {
|
||||
return EpisodesApi.deleteSubtitles(seriesid, episodeid, {
|
||||
hi,
|
||||
forced,
|
||||
path: path,
|
||||
language: subtitle.code2,
|
||||
});
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}}
|
||||
onSuccess={update}
|
||||
as={Badge}
|
||||
className="mr-1"
|
||||
variant={missing ? "primary" : "secondary"}
|
||||
>
|
||||
<LanguageText className="pr-1" text={subtitle}></LanguageText>
|
||||
<FontAwesomeIcon
|
||||
size="sm"
|
||||
icon={missing ? faSearch : faTrash}
|
||||
></FontAwesomeIcon>
|
||||
</AsyncButton>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Badge className="mr-1" variant="secondary">
|
||||
<LanguageText text={subtitle} long={false}></LanguageText>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
@ -0,0 +1,160 @@
|
||||
import {
|
||||
faAdjust,
|
||||
faBriefcase,
|
||||
faCloudUploadAlt,
|
||||
faHdd,
|
||||
faSearch,
|
||||
faSync,
|
||||
faWrench,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Container, Row } from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
|
||||
import { RouterEmptyPath } from "../../404";
|
||||
import { useEpisodesBy, useSerieBy } from "../../@redux/hooks";
|
||||
import { SeriesApi } from "../../apis";
|
||||
import {
|
||||
ContentHeader,
|
||||
ItemEditorModal,
|
||||
LoadingIndicator,
|
||||
SeriesUploadModal,
|
||||
useShowModal,
|
||||
} from "../../components";
|
||||
import ItemOverview from "../../generic/ItemOverview";
|
||||
import { useAutoUpdate, useWhenLoadingFinish } from "../../utilites";
|
||||
import Table from "./table";
|
||||
|
||||
interface Params {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface Props extends RouteComponentProps<Params> {}
|
||||
|
||||
const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
|
||||
const { match } = props;
|
||||
const id = Number.parseInt(match.params.id);
|
||||
const [serie, update] = useSerieBy(id);
|
||||
const item = serie.data;
|
||||
|
||||
const [episodes] = useEpisodesBy(serie.data?.sonarrSeriesId);
|
||||
|
||||
useAutoUpdate(update);
|
||||
|
||||
const available = episodes.data.length !== 0;
|
||||
|
||||
const details = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: faHdd,
|
||||
text: `${item?.episodeFileCount} files`,
|
||||
},
|
||||
{
|
||||
icon: faAdjust,
|
||||
text: item?.seriesType ?? "",
|
||||
},
|
||||
],
|
||||
[item]
|
||||
);
|
||||
|
||||
const showModal = useShowModal();
|
||||
|
||||
const [valid, setValid] = useState(true);
|
||||
|
||||
const validator = useCallback(() => {
|
||||
if (serie.data === null) {
|
||||
setValid(false);
|
||||
}
|
||||
}, [serie.data]);
|
||||
|
||||
useWhenLoadingFinish(serie, validator);
|
||||
|
||||
if (isNaN(id) || !valid) {
|
||||
return <Redirect to={RouterEmptyPath}></Redirect>;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return <LoadingIndicator></LoadingIndicator>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container fluid>
|
||||
<Helmet>
|
||||
<title>{item.title} - Bazarr (Series)</title>
|
||||
</Helmet>
|
||||
<ContentHeader>
|
||||
<ContentHeader.Group pos="start">
|
||||
<ContentHeader.AsyncButton
|
||||
icon={faSync}
|
||||
disabled={!available}
|
||||
promise={() =>
|
||||
SeriesApi.action({ action: "scan-disk", seriesid: id })
|
||||
}
|
||||
onSuccess={update}
|
||||
>
|
||||
Scan Disk
|
||||
</ContentHeader.AsyncButton>
|
||||
<ContentHeader.AsyncButton
|
||||
icon={faSearch}
|
||||
promise={() =>
|
||||
SeriesApi.action({ action: "search-missing", seriesid: id })
|
||||
}
|
||||
onSuccess={update}
|
||||
disabled={
|
||||
item.episodeFileCount === 0 ||
|
||||
item.profileId === null ||
|
||||
!available
|
||||
}
|
||||
>
|
||||
Search
|
||||
</ContentHeader.AsyncButton>
|
||||
</ContentHeader.Group>
|
||||
<ContentHeader.Group pos="end">
|
||||
<ContentHeader.Button
|
||||
disabled={item.episodeFileCount === 0 || !available}
|
||||
icon={faBriefcase}
|
||||
onClick={() => showModal("tools", episodes.data)}
|
||||
>
|
||||
Tools
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.Button
|
||||
disabled={
|
||||
item.episodeFileCount === 0 ||
|
||||
item.profileId === null ||
|
||||
!available
|
||||
}
|
||||
icon={faCloudUploadAlt}
|
||||
onClick={() => showModal("upload", item)}
|
||||
>
|
||||
Upload
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.Button
|
||||
icon={faWrench}
|
||||
onClick={() => showModal("edit", item)}
|
||||
>
|
||||
Edit Series
|
||||
</ContentHeader.Button>
|
||||
</ContentHeader.Group>
|
||||
</ContentHeader>
|
||||
<Row>
|
||||
<ItemOverview item={item} details={details}></ItemOverview>
|
||||
</Row>
|
||||
<Row>
|
||||
<Table episodes={episodes} update={update}></Table>
|
||||
</Row>
|
||||
<ItemEditorModal
|
||||
modalKey="edit"
|
||||
submit={(form) => SeriesApi.modify(form)}
|
||||
onSuccess={update}
|
||||
></ItemEditorModal>
|
||||
<SeriesUploadModal modalKey="upload"></SeriesUploadModal>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default withRouter(SeriesEpisodesView);
|
@ -0,0 +1,226 @@
|
||||
import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faBookmark,
|
||||
faBriefcase,
|
||||
faHistory,
|
||||
faUser,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent, useCallback, useMemo } from "react";
|
||||
import { Badge, ButtonGroup } from "react-bootstrap";
|
||||
import { Column, TableOptions, TableUpdater } from "react-table";
|
||||
import { ProvidersApi } from "../../apis";
|
||||
import {
|
||||
ActionButton,
|
||||
AsyncStateOverlay,
|
||||
EpisodeHistoryModal,
|
||||
GroupTable,
|
||||
SubtitleToolModal,
|
||||
useShowModal,
|
||||
} from "../../components";
|
||||
import { ManualSearchModal } from "../../components/modals/ManualSearchModal";
|
||||
import { BuildKey } from "../../utilites";
|
||||
import { SubtitleAction } from "./components";
|
||||
|
||||
interface Props {
|
||||
episodes: AsyncState<Item.Episode[]>;
|
||||
update: () => void;
|
||||
}
|
||||
|
||||
const download = (item: any, result: SearchResultType) => {
|
||||
item = item as Item.Episode;
|
||||
const { language, hearing_impaired, forced, provider, subtitle } = result;
|
||||
return ProvidersApi.downloadEpisodeSubtitle(
|
||||
item.sonarrSeriesId,
|
||||
item.sonarrEpisodeId,
|
||||
{
|
||||
language,
|
||||
hi: hearing_impaired,
|
||||
forced,
|
||||
provider,
|
||||
subtitle,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const Table: FunctionComponent<Props> = ({ episodes, update }) => {
|
||||
const showModal = useShowModal();
|
||||
|
||||
const columns: Column<Item.Episode>[] = useMemo<Column<Item.Episode>[]>(
|
||||
() => [
|
||||
{
|
||||
accessor: "monitored",
|
||||
Cell: (row) => {
|
||||
return (
|
||||
<FontAwesomeIcon
|
||||
title={row.value ? "monitored" : "unmonitored"}
|
||||
icon={row.value ? faBookmark : farBookmark}
|
||||
></FontAwesomeIcon>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "season",
|
||||
Cell: (row) => {
|
||||
return `Season ${row.value}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Episode",
|
||||
accessor: "episode",
|
||||
},
|
||||
{
|
||||
Header: "Title",
|
||||
accessor: "title",
|
||||
className: "text-nowrap",
|
||||
},
|
||||
{
|
||||
Header: "Audio",
|
||||
accessor: "audio_language",
|
||||
Cell: (row) => {
|
||||
return row.value.map((v) => (
|
||||
<Badge variant="secondary" key={v.code2}>
|
||||
{v.name}
|
||||
</Badge>
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Subtitles",
|
||||
accessor: "missing_subtitles",
|
||||
Cell: ({ row }) => {
|
||||
const episode = row.original;
|
||||
|
||||
const seriesid = episode.sonarrSeriesId;
|
||||
|
||||
const elements = useMemo(() => {
|
||||
const episodeid = episode.sonarrEpisodeId;
|
||||
|
||||
const missing = episode.missing_subtitles.map((val, idx) => (
|
||||
<SubtitleAction
|
||||
missing
|
||||
key={BuildKey(idx, val.code2, "missing")}
|
||||
seriesid={seriesid}
|
||||
episodeid={episodeid}
|
||||
subtitle={val}
|
||||
></SubtitleAction>
|
||||
));
|
||||
|
||||
const existing = episode.subtitles.filter(
|
||||
(val) =>
|
||||
episode.missing_subtitles.findIndex(
|
||||
(v) => v.code2 === val.code2
|
||||
) === -1
|
||||
);
|
||||
|
||||
const subtitles = existing.map((val, idx) => (
|
||||
<SubtitleAction
|
||||
key={BuildKey(idx, val.code2, "valid")}
|
||||
seriesid={seriesid}
|
||||
episodeid={episodeid}
|
||||
subtitle={val}
|
||||
></SubtitleAction>
|
||||
));
|
||||
|
||||
return [...missing, ...subtitles];
|
||||
}, [episode, seriesid]);
|
||||
|
||||
return elements;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Actions",
|
||||
accessor: "sonarrEpisodeId",
|
||||
Cell: ({ row, externalUpdate }) => {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<ActionButton
|
||||
icon={faUser}
|
||||
onClick={() => {
|
||||
externalUpdate && externalUpdate(row, "manual-search");
|
||||
}}
|
||||
></ActionButton>
|
||||
<ActionButton
|
||||
icon={faHistory}
|
||||
onClick={() => {
|
||||
externalUpdate && externalUpdate(row, "history");
|
||||
}}
|
||||
></ActionButton>
|
||||
<ActionButton
|
||||
icon={faBriefcase}
|
||||
onClick={() => {
|
||||
externalUpdate && externalUpdate(row, "tools");
|
||||
}}
|
||||
></ActionButton>
|
||||
</ButtonGroup>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const updateRow = useCallback<TableUpdater<Item.Episode>>(
|
||||
(row, modalKey: string) => {
|
||||
if (modalKey === "tools") {
|
||||
showModal(modalKey, [row.original]);
|
||||
} else {
|
||||
showModal(modalKey, row.original);
|
||||
}
|
||||
},
|
||||
[showModal]
|
||||
);
|
||||
|
||||
const maxSeason = useMemo(
|
||||
() =>
|
||||
episodes.data.reduce<number>(
|
||||
(prev, curr) => Math.max(prev, curr.season),
|
||||
0
|
||||
),
|
||||
[episodes]
|
||||
);
|
||||
|
||||
const options: TableOptions<Item.Episode> = useMemo(() => {
|
||||
return {
|
||||
columns,
|
||||
data: episodes.data,
|
||||
externalUpdate: updateRow,
|
||||
initialState: {
|
||||
sortBy: [
|
||||
{ id: "season", desc: true },
|
||||
{ id: "episode", desc: true },
|
||||
],
|
||||
groupBy: ["season"],
|
||||
expanded: {
|
||||
[`season:${maxSeason}`]: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [episodes, columns, maxSeason, updateRow]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<AsyncStateOverlay state={episodes}>
|
||||
{() => (
|
||||
<GroupTable
|
||||
emptyText="No Episode Found For This Series"
|
||||
{...options}
|
||||
></GroupTable>
|
||||
)}
|
||||
</AsyncStateOverlay>
|
||||
<SubtitleToolModal
|
||||
modalKey="tools"
|
||||
size="lg"
|
||||
update={update}
|
||||
></SubtitleToolModal>
|
||||
<EpisodeHistoryModal modalKey="history" size="lg"></EpisodeHistoryModal>
|
||||
<ManualSearchModal
|
||||
modalKey="manual-search"
|
||||
onDownload={update}
|
||||
onSelect={download}
|
||||
></ManualSearchModal>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
@ -0,0 +1,21 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import Series from ".";
|
||||
import Episodes from "./Episodes";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const Router: FunctionComponent<Props> = () => {
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/series">
|
||||
<Series></Series>
|
||||
</Route>
|
||||
<Route path="/series/:id">
|
||||
<Episodes></Episodes>
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
export default Router;
|