Add React-Query to improve network and cache performance

pull/1695/head v1.0.3-beta.15
Liang Yi 2 years ago committed by GitHub
parent 6b82a734e2
commit d8d2300980
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -22,3 +22,6 @@ REACT_APP_CAN_UPDATE=true
# Display update notification in notification center
REACT_APP_HAS_UPDATE=false
# Display React-Query devtools
REACT_APP_QUERY_DEV=false

@ -25,6 +25,7 @@
"react-bootstrap": "^1",
"react-dom": "^17",
"react-helmet": "^6.1",
"react-query": "^3.34",
"react-redux": "^7.2",
"react-router-dom": "^5.3",
"react-scripts": "^4",
@ -45,7 +46,6 @@
"@types/react-dom": "^17",
"@types/react-helmet": "^6.1",
"@types/react-router-dom": "^5",
"@types/react-select": "^5.0.1",
"@types/react-table": "^7",
"http-proxy-middleware": "^2",
"husky": "^7",
@ -3668,16 +3668,6 @@
"@types/react-router": "*"
}
},
"node_modules/@types/react-select": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-5.0.1.tgz",
"integrity": "sha512-h5Im0AP0dr4AVeHtrcvQrLV+gmPa7SA0AGdxl2jOhtwiE6KgXBFSogWw8az32/nusE6AQHlCOHQWjP1S/+oMWA==",
"deprecated": "This is a stub types definition. react-select provides its own type definitions, so you do not need this installed.",
"dev": true,
"dependencies": {
"react-select": "*"
}
},
"node_modules/@types/react-table": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.9.tgz",
@ -5389,6 +5379,14 @@
"node": ">= 8.0.0"
}
},
"node_modules/big-integer": {
"version": "1.6.51",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
"integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==",
"engines": {
"node": ">=0.6"
}
},
"node_modules/big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@ -5531,6 +5529,21 @@
"node": ">=0.10.0"
}
},
"node_modules/broadcast-channel": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz",
"integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==",
"dependencies": {
"@babel/runtime": "^7.7.2",
"detect-node": "^2.1.0",
"js-sha3": "0.8.0",
"microseconds": "0.2.0",
"nano-time": "1.0.0",
"oblivious-set": "1.0.0",
"rimraf": "3.0.2",
"unload": "2.2.0"
}
},
"node_modules/brorand": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
@ -5863,9 +5876,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001249",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001249.tgz",
"integrity": "sha512-vcX4U8lwVXPdqzPWi6cAJ3FnQaqXbBqy/GZseKNQzRj37J7qZdGcBtxq/QLFNLLlfsoXLUdHw8Iwenri86Tagw==",
"version": "1.0.30001300",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001300.tgz",
"integrity": "sha512-cVjiJHWGcNlJi8TZVKNMnvMid3Z3TTdDHmLDzlOdIiZq138Exvo0G+G0wTdVYolxKb4AYwC+38pxodiInVtJSA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
@ -13013,6 +13026,11 @@
"integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==",
"peer": true
},
"node_modules/js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -13457,6 +13475,15 @@
"node": ">=0.10.0"
}
},
"node_modules/match-sorter": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz",
"integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"remove-accents": "0.4.2"
}
},
"node_modules/md5.js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@ -13548,6 +13575,11 @@
"node": ">=0.10.0"
}
},
"node_modules/microseconds": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
"integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA=="
},
"node_modules/miller-rabin": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
@ -13884,6 +13916,14 @@
"integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==",
"optional": true
},
"node_modules/nano-time": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
"integrity": "sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=",
"dependencies": {
"big-integer": "^1.6.16"
}
},
"node_modules/nanoid": {
"version": "3.1.23",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
@ -14335,6 +14375,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oblivious-set": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz",
"integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw=="
},
"node_modules/obuf": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
@ -17063,6 +17108,31 @@
"react-dom": ">=16.3.0"
}
},
"node_modules/react-query": {
"version": "3.34.8",
"resolved": "https://registry.npmjs.org/react-query/-/react-query-3.34.8.tgz",
"integrity": "sha512-pl9e2VmVbgKf29Qn/WpmFVtB2g17JPqLLyOQg3GfSs/S2WABvip5xlT464vfXtilLPcJVg9bEHHlqmC38/nvDw==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"broadcast-channel": "^3.4.1",
"match-sorter": "^6.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-redux": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.4.tgz",
@ -17718,6 +17788,11 @@
"node": ">= 0.10"
}
},
"node_modules/remove-accents": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
"integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U="
},
"node_modules/remove-trailing-separator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
@ -20520,6 +20595,15 @@
"node": ">= 10.0.0"
}
},
"node_modules/unload": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
"integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
"dependencies": {
"@babel/runtime": "^7.6.2",
"detect-node": "^2.0.4"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -24979,15 +25063,6 @@
"@types/react-router": "*"
}
},
"@types/react-select": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-5.0.1.tgz",
"integrity": "sha512-h5Im0AP0dr4AVeHtrcvQrLV+gmPa7SA0AGdxl2jOhtwiE6KgXBFSogWw8az32/nusE6AQHlCOHQWjP1S/+oMWA==",
"dev": true,
"requires": {
"react-select": "*"
}
},
"@types/react-table": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.9.tgz",
@ -26329,6 +26404,11 @@
"tryer": "^1.0.1"
}
},
"big-integer": {
"version": "1.6.51",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
"integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg=="
},
"big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@ -26447,6 +26527,21 @@
}
}
},
"broadcast-channel": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz",
"integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==",
"requires": {
"@babel/runtime": "^7.7.2",
"detect-node": "^2.1.0",
"js-sha3": "0.8.0",
"microseconds": "0.2.0",
"nano-time": "1.0.0",
"oblivious-set": "1.0.0",
"rimraf": "3.0.2",
"unload": "2.2.0"
}
},
"brorand": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
@ -26718,9 +26813,9 @@
}
},
"caniuse-lite": {
"version": "1.0.30001249",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001249.tgz",
"integrity": "sha512-vcX4U8lwVXPdqzPWi6cAJ3FnQaqXbBqy/GZseKNQzRj37J7qZdGcBtxq/QLFNLLlfsoXLUdHw8Iwenri86Tagw=="
"version": "1.0.30001300",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001300.tgz",
"integrity": "sha512-cVjiJHWGcNlJi8TZVKNMnvMid3Z3TTdDHmLDzlOdIiZq138Exvo0G+G0wTdVYolxKb4AYwC+38pxodiInVtJSA=="
},
"capture-exit": {
"version": "2.0.0",
@ -32115,6 +32210,11 @@
"integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==",
"peer": true
},
"js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="
},
"js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -32470,6 +32570,15 @@
"object-visit": "^1.0.0"
}
},
"match-sorter": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz",
"integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==",
"requires": {
"@babel/runtime": "^7.12.5",
"remove-accents": "0.4.2"
}
},
"md5.js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@ -32549,6 +32658,11 @@
"to-regex": "^3.0.2"
}
},
"microseconds": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
"integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA=="
},
"miller-rabin": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
@ -32808,6 +32922,14 @@
"integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==",
"optional": true
},
"nano-time": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
"integrity": "sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=",
"requires": {
"big-integer": "^1.6.16"
}
},
"nanoid": {
"version": "3.1.23",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
@ -33162,6 +33284,11 @@
"es-abstract": "^1.18.2"
}
},
"oblivious-set": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz",
"integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw=="
},
"obuf": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
@ -35316,6 +35443,16 @@
"warning": "^4.0.3"
}
},
"react-query": {
"version": "3.34.8",
"resolved": "https://registry.npmjs.org/react-query/-/react-query-3.34.8.tgz",
"integrity": "sha512-pl9e2VmVbgKf29Qn/WpmFVtB2g17JPqLLyOQg3GfSs/S2WABvip5xlT464vfXtilLPcJVg9bEHHlqmC38/nvDw==",
"requires": {
"@babel/runtime": "^7.5.5",
"broadcast-channel": "^3.4.1",
"match-sorter": "^6.0.2"
}
},
"react-redux": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.4.tgz",
@ -35835,6 +35972,11 @@
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
"integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk="
},
"remove-accents": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
"integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U="
},
"remove-trailing-separator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
@ -38030,6 +38172,15 @@
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
"integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ=="
},
"unload": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
"integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
"requires": {
"@babel/runtime": "^7.6.2",
"detect-node": "^2.0.4"
}
},
"unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

@ -30,6 +30,7 @@
"react-bootstrap": "^1",
"react-dom": "^17",
"react-helmet": "^6.1",
"react-query": "^3.34",
"react-redux": "^7.2",
"react-router-dom": "^5.3",
"react-scripts": "^4",
@ -50,7 +51,6 @@
"@types/react-dom": "^17",
"@types/react-helmet": "^6.1",
"@types/react-router-dom": "^5",
"@types/react-select": "^5.0.1",
"@types/react-table": "^7",
"http-proxy-middleware": "^2",
"husky": "^7",

@ -1,33 +1,14 @@
import { ActionCreator } from "@reduxjs/toolkit";
import { QueryKeys } from "apis/queries/keys";
import {
episodesMarkBlacklistDirty,
episodesMarkDirtyById,
episodesRemoveById,
episodesResetHistory,
movieMarkBlacklistDirty,
movieMarkDirtyById,
movieMarkWantedDirtyById,
movieRemoveById,
movieRemoveWantedById,
movieResetHistory,
movieResetWanted,
seriesMarkDirtyById,
seriesMarkWantedDirtyById,
seriesRemoveById,
seriesRemoveWantedById,
seriesResetWanted,
siteAddNotifications,
addNotifications,
setOfflineStatus,
setSiteStatus,
siteAddProgress,
siteBootstrap,
siteRemoveProgress,
siteUpdateBadges,
siteUpdateInitialization,
siteUpdateOffline,
systemMarkTasksDirty,
systemUpdateAllSettings,
systemUpdateLanguages,
} from "../../@redux/actions";
import reduxStore from "../../@redux/store";
import queryClient from "../../apis/queries";
function bindReduxAction<T extends ActionCreator<any>>(action: T) {
return (...args: Parameters<T>) => {
@ -48,26 +29,24 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
return [
{
key: "connect",
any: bindReduxActionWithParam(siteUpdateOffline, false),
any: bindReduxActionWithParam(setOfflineStatus, false),
},
{
key: "connect",
any: bindReduxAction(siteBootstrap),
any: () => {
// init
reduxStore.dispatch(setSiteStatus("initialized"));
},
},
{
key: "connect_error",
any: () => {
const initialized = reduxStore.getState().site.initialized;
if (initialized === true) {
reduxStore.dispatch(siteUpdateOffline(true));
} else {
reduxStore.dispatch(siteUpdateInitialization("Socket.IO Error"));
}
reduxStore.dispatch(setSiteStatus("error"));
},
},
{
key: "disconnect",
any: bindReduxActionWithParam(siteUpdateOffline, true),
any: bindReduxActionWithParam(setOfflineStatus, true),
},
{
key: "message",
@ -80,7 +59,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
timeout: 5 * 1000,
}));
reduxStore.dispatch(siteAddNotifications(notifications));
reduxStore.dispatch(addNotifications(notifications));
}
},
},
@ -91,68 +70,125 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
},
{
key: "series",
update: bindReduxAction(seriesMarkDirtyById),
delete: bindReduxAction(seriesRemoveById),
update: (ids) => {
ids.forEach((id) => {
queryClient.invalidateQueries([QueryKeys.Series, id]);
});
},
delete: (ids) => {
ids.forEach((id) => {
queryClient.invalidateQueries([QueryKeys.Series, id]);
});
},
},
{
key: "movie",
update: bindReduxAction(movieMarkDirtyById),
delete: bindReduxAction(movieRemoveById),
update: (ids) => {
ids.forEach((id) => {
queryClient.invalidateQueries([QueryKeys.Movies, id]);
});
},
delete: (ids) => {
ids.forEach((id) => {
queryClient.invalidateQueries([QueryKeys.Movies, id]);
});
},
},
{
key: "episode",
update: bindReduxAction(episodesMarkDirtyById),
delete: bindReduxAction(episodesRemoveById),
update: (ids) => {
ids.forEach((id) => {
queryClient.invalidateQueries([QueryKeys.Episodes, id]);
});
},
delete: (ids) => {
ids.forEach((id) => {
queryClient.invalidateQueries([QueryKeys.Episodes, id]);
});
},
},
{
key: "episode-wanted",
update: bindReduxAction(seriesMarkWantedDirtyById),
delete: bindReduxAction(seriesRemoveWantedById),
update: (ids) => {
// Find a better way to update wanted
queryClient.invalidateQueries([QueryKeys.Episodes, QueryKeys.Wanted]);
},
delete: () => {
queryClient.invalidateQueries([QueryKeys.Episodes, QueryKeys.Wanted]);
},
},
{
key: "movie-wanted",
update: bindReduxAction(movieMarkWantedDirtyById),
delete: bindReduxAction(movieRemoveWantedById),
update: (ids) => {
// Find a better way to update wanted
queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.Wanted]);
},
delete: () => {
queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.Wanted]);
},
},
{
key: "settings",
any: bindReduxAction(systemUpdateAllSettings),
any: () => {
queryClient.invalidateQueries([QueryKeys.System]);
},
},
{
key: "languages",
any: bindReduxAction(systemUpdateLanguages),
any: () => {
queryClient.invalidateQueries([QueryKeys.System, QueryKeys.Languages]);
},
},
{
key: "badges",
any: bindReduxAction(siteUpdateBadges),
any: () => {
queryClient.invalidateQueries([QueryKeys.System, QueryKeys.Badges]);
},
},
{
key: "movie-history",
any: bindReduxAction(movieResetHistory),
any: () => {
queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.History]);
},
},
{
key: "movie-blacklist",
any: bindReduxAction(movieMarkBlacklistDirty),
any: () => {
queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.Blacklist]);
},
},
{
key: "episode-history",
any: bindReduxAction(episodesResetHistory),
any: () => {
queryClient.invalidateQueries([QueryKeys.Episodes, QueryKeys.History]);
},
},
{
key: "episode-blacklist",
any: bindReduxAction(episodesMarkBlacklistDirty),
any: () => {
queryClient.invalidateQueries([
QueryKeys.Episodes,
QueryKeys.Blacklist,
]);
},
},
{
key: "reset-episode-wanted",
any: bindReduxAction(seriesResetWanted),
any: () => {
queryClient.invalidateQueries([QueryKeys.Episodes, QueryKeys.Wanted]);
},
},
{
key: "reset-movie-wanted",
any: bindReduxAction(movieResetWanted),
any: () => {
queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.Wanted]);
},
},
{
key: "task",
any: bindReduxAction(systemMarkTasksDirty),
any: () => {
queryClient.invalidateQueries([QueryKeys.System, QueryKeys.Tasks]);
},
},
];
}

@ -1,17 +0,0 @@
import BGT from "./";
export function useIsAnyTaskRunning() {
return BGT.isRunning();
}
export function useIsAnyTaskRunningWithId(ids: number[]) {
return BGT.hasId(ids);
}
export function useIsGroupTaskRunning(groupName: string) {
return BGT.has(groupName);
}
export function useIsGroupTaskRunningWithId(groupName: string, id: number) {
return BGT.find(groupName, id);
}

@ -1,406 +0,0 @@
import {
configureStore,
createAction,
createAsyncThunk,
createReducer,
} from "@reduxjs/toolkit";
import {} from "jest";
import { differenceWith, intersectionWith, isString, uniq } from "lodash";
import { defaultList, defaultState, TestType } from "../tests/helper";
import { createAsyncEntityReducer } from "../utils/factory";
const newItem: TestType = {
id: 123,
name: "extended",
};
const longerList: TestType[] = [...defaultList, newItem];
const shorterList: TestType[] = defaultList.slice(0, defaultList.length - 1);
const allResolved = createAsyncThunk("all/resolved", () => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({ total: defaultList.length, data: defaultList });
});
});
const allResolvedLonger = createAsyncThunk("all/longer/resolved", () => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({ total: longerList.length, data: longerList });
});
});
const allResolvedShorter = createAsyncThunk("all/shorter/resolved", () => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({ total: shorterList.length, data: shorterList });
});
});
const idsResolved = createAsyncThunk("ids/resolved", (param: number[]) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: defaultList.length,
data: intersectionWith(defaultList, param, (l, r) => l.id === r),
});
});
});
const idsResolvedLonger = createAsyncThunk(
"ids/longer/resolved",
(param: number[]) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: longerList.length,
data: intersectionWith(longerList, param, (l, r) => l.id === r),
});
});
}
);
const idsResolvedShorter = createAsyncThunk(
"ids/shorter/resolved",
(param: number[]) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: shorterList.length,
data: intersectionWith(shorterList, param, (l, r) => l.id === r),
});
});
}
);
const rangeResolved = createAsyncThunk(
"range/resolved",
(param: Parameter.Range) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: defaultList.length,
data: defaultList.slice(param.start, param.start + param.length),
});
});
}
);
const rangeResolvedLonger = createAsyncThunk(
"range/longer/resolved",
(param: Parameter.Range) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: longerList.length,
data: longerList.slice(param.start, param.start + param.length),
});
});
}
);
const rangeResolvedShorter = createAsyncThunk(
"range/shorter/resolved",
(param: Parameter.Range) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: shorterList.length,
data: shorterList.slice(param.start, param.start + param.length),
});
});
}
);
const allRejected = createAsyncThunk("all/rejected", () => {
return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
rejected("Error");
});
});
const idsRejected = createAsyncThunk("ids/rejected", (param: number[]) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
rejected("Error");
});
});
const rangeRejected = createAsyncThunk(
"range/rejected",
(param: Parameter.Range) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
rejected("Error");
});
}
);
const removeIds = createAction<number[]>("remove/id");
const dirty = createAction<number[]>("dirty/id");
const reset = createAction("reset");
const reducer = createReducer(defaultState, (builder) => {
createAsyncEntityReducer(builder, (s) => s.entities, {
all: allResolved,
range: rangeResolved,
ids: idsResolved,
dirty,
removeIds,
reset,
});
createAsyncEntityReducer(builder, (s) => s.entities, {
all: allRejected,
range: rangeRejected,
ids: idsRejected,
});
createAsyncEntityReducer(builder, (s) => s.entities, {
all: allResolvedLonger,
range: rangeResolvedLonger,
ids: idsResolvedLonger,
});
createAsyncEntityReducer(builder, (s) => s.entities, {
all: allResolvedShorter,
range: rangeResolvedShorter,
ids: idsResolvedShorter,
});
});
function createStore() {
const store = configureStore({
reducer,
});
expect(store.getState()).toEqual(defaultState);
return store;
}
let store = createStore();
function use(callback: (entities: Async.Entity<TestType>) => void) {
const entities = store.getState().entities;
callback(entities);
}
beforeEach(() => {
store = createStore();
});
it("entity update all resolved", async () => {
await store.dispatch(allResolved());
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.error).toBeNull();
expect(entities.state).toBe("succeeded");
defaultList.forEach((v, index) => {
const id = v.id.toString();
expect(entities.content.ids[index]).toEqual(id);
expect(entities.content.entities[id]).toEqual(v);
expect(entities.didLoaded).toContain(id);
});
});
});
it("entity update all rejected", async () => {
await store.dispatch(allRejected());
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.error).not.toBeNull();
expect(entities.state).toBe("failed");
expect(entities.content.ids).toHaveLength(0);
expect(entities.content.entities).toEqual({});
});
});
it("entity reset", async () => {
await store.dispatch(allResolved());
store.dispatch(reset());
use((entities) => {
expect(entities).toEqual(defaultState.entities);
});
});
it("entity mark dirty", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty([1, 2, 3]));
use((entities) => {
expect(entities.error).toBeNull();
expect(entities.state).toBe("dirty");
defaultList.forEach((v, index) => {
const id = v.id.toString();
expect(entities.content.ids[index]).toEqual(id);
expect(entities.content.entities[id]).toEqual(v);
});
});
});
it("delete entity item", async () => {
await store.dispatch(allResolved());
const idsToRemove = [0, 1, 3, 5];
const expectResults = differenceWith(
defaultList,
idsToRemove,
(l, r) => l.id === r
);
store.dispatch(removeIds(idsToRemove));
use((entities) => {
expect(entities.state).toBe("succeeded");
idsToRemove.map(String).forEach((v) => {
expect(entities.didLoaded).not.toContain(v);
});
expectResults.forEach((v, index) => {
const id = v.id.toString();
expect(entities.content.ids[index]).toEqual(id);
expect(entities.content.entities[id]).toEqual(v);
});
});
});
it("entity update by range", async () => {
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
await store.dispatch(rangeResolved({ start: 4, length: 2 }));
use((entities) => {
expect(entities.content.ids).toHaveLength(defaultList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(4);
[0, 1, 4, 5].forEach((v) => {
const id = v.toString();
expect(entities.content.ids).toContain(id);
expect(entities.content.entities[id].id).toEqual(v);
expect(entities.didLoaded).toContain(id);
});
expect(entities.error).toBeNull();
expect(entities.state).toBe("succeeded");
});
});
it("entity update by duplicative range", async () => {
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
await store.dispatch(rangeResolved({ start: 1, length: 2 }));
use((entities) => {
expect(entities.content.ids).toHaveLength(defaultList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(3);
defaultList.slice(0, 3).forEach((v) => {
const id = v.id.toString();
expect(entities.content.ids).toContain(id);
expect(entities.content.entities[id]).toEqual(v);
expect(entities.didLoaded.filter((v) => v === id)).toHaveLength(1);
});
expect(entities.error).toBeNull();
expect(entities.state).toBe("succeeded");
});
});
it("entity update by range and ids", async () => {
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
await store.dispatch(idsResolved([3]));
await store.dispatch(rangeResolved({ start: 2, length: 2 }));
use((entries) => {
const ids = entries.content.ids.filter(isString);
const dedupIds = uniq(ids);
expect(ids.length).toBe(dedupIds.length);
});
});
it("entity resolved by dirty", async () => {
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
store.dispatch(dirty([1, 2, 3]));
await store.dispatch(rangeResolved({ start: 1, length: 2 }));
use((entities) => {
expect(entities.dirtyEntities).not.toContain("1");
expect(entities.dirtyEntities).not.toContain("2");
expect(entities.dirtyEntities).toContain("3");
expect(entities.state).toBe("dirty");
});
await store.dispatch(rangeResolved({ start: 1, length: 3 }));
use((entities) => {
expect(entities.dirtyEntities).not.toContain("1");
expect(entities.dirtyEntities).not.toContain("2");
expect(entities.dirtyEntities).not.toContain("3");
expect(entities.state).toBe("succeeded");
});
});
it("entity update by ids", async () => {
await store.dispatch(idsResolved([999]));
use((entities) => {
expect(entities.content.ids).toHaveLength(defaultList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(0);
expect(entities.content.entities).not.toHaveProperty("999");
expect(entities.error).toBeNull();
expect(entities.state).toBe("succeeded");
});
});
it("entity resolved dirty by ids", async () => {
await store.dispatch(idsResolved([0, 1, 2, 3, 4]));
store.dispatch(dirty([0, 1, 2, 3]));
await store.dispatch(idsResolved([0, 1]));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(2);
expect(entities.content.ids.filter(isString)).toHaveLength(5);
expect(entities.error).toBeNull();
expect(entities.state).toBe("dirty");
});
});
it("entity resolved non-exist by ids", async () => {
await store.dispatch(idsResolved([0, 1]));
store.dispatch(dirty([999]));
await store.dispatch(idsResolved([999]));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
});
});
it("entity update by variant range", async () => {
await store.dispatch(allResolved());
await store.dispatch(rangeResolvedLonger({ start: 0, length: 2 }));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
expect(entities.content.ids).toHaveLength(longerList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(2);
longerList.slice(0, 2).forEach((v) => {
const id = v.id.toString();
expect(entities.content.ids).toContain(id);
expect(entities.content.entities[id]).toEqual(v);
});
});
await store.dispatch(allResolved());
await store.dispatch(rangeResolvedShorter({ start: 0, length: 2 }));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
expect(entities.content.ids).toHaveLength(shorterList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(2);
shorterList.slice(0, 2).forEach((v) => {
const id = v.id.toString();
expect(entities.content.ids).toContain(id);
expect(entities.content.entities[id]).toEqual(v);
});
});
});
it("entity update by variant ids", async () => {
await store.dispatch(allResolved());
await store.dispatch(idsResolvedLonger([2, 3, 4]));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
expect(entities.content.ids).toHaveLength(longerList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(3);
Array(3)
.fill(undefined)
.forEach((v) => {
expect(entities.content.ids[v]).not.toBeNull();
});
});
await store.dispatch(allResolved());
await store.dispatch(idsResolvedShorter([2, 3, 4]));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
expect(entities.content.ids).toHaveLength(shorterList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(3);
Array(3)
.fill(undefined)
.forEach((v) => {
expect(entities.content.ids[v]).not.toBeNull();
});
});
});

@ -1,155 +0,0 @@
import {
configureStore,
createAction,
createAsyncThunk,
createReducer,
} from "@reduxjs/toolkit";
import {} from "jest";
import { defaultState, TestType } from "../tests/helper";
import { createAsyncItemReducer } from "../utils/factory";
// Item
const defaultItem: TestType = { id: 0, name: "test" };
const allResolved = createAsyncThunk("all/resolved", () => {
return new Promise<TestType>((resolve) => {
resolve(defaultItem);
});
});
const allRejected = createAsyncThunk("all/rejected", () => {
return new Promise<TestType>((resolve, rejected) => {
rejected("Error");
});
});
const dirty = createAction("dirty/ids");
const reducer = createReducer(defaultState, (builder) => {
createAsyncItemReducer(builder, (s) => s.item, { all: allResolved, dirty });
createAsyncItemReducer(builder, (s) => s.item, { all: allRejected });
});
function createStore() {
const store = configureStore({
reducer,
});
expect(store.getState()).toEqual(defaultState);
return store;
}
let store = createStore();
function use(callback: (entities: Async.Item<TestType>) => void) {
const item = store.getState().item;
callback(item);
}
// Begin Test Section
beforeEach(() => {
store = createStore();
});
it("item loading", async () => {
return new Promise<void>((done) => {
store.dispatch(allResolved()).finally(() => {
use((item) => {
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
done();
});
use((item) => {
expect(item.state).toBe("loading");
expect(item.error).toBeNull();
expect(item.content).toBeNull();
});
});
});
it("item uninitialized -> succeeded", async () => {
await store.dispatch(allResolved());
use((item) => {
expect(item.state).toBe("succeeded");
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item uninitialized -> failed", async () => {
await store.dispatch(allRejected());
use((item) => {
expect(item.state).toBe("failed");
expect(item.error).not.toBeNull();
expect(item.content).toBeNull();
});
});
it("item uninitialized -> dirty", () => {
store.dispatch(dirty());
use((item) => {
expect(item.state).toBe("uninitialized");
expect(item.error).toBeNull();
expect(item.content).toBeNull();
});
});
it("item succeeded -> failed", async () => {
await store.dispatch(allResolved());
await store.dispatch(allRejected());
use((item) => {
expect(item.state).toBe("failed");
expect(item.error).not.toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item failed -> succeeded", async () => {
await store.dispatch(allRejected());
await store.dispatch(allResolved());
use((item) => {
expect(item.state).toBe("succeeded");
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item succeeded -> dirty", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty());
use((item) => {
expect(item.state).toBe("dirty");
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item failed -> dirty", async () => {
await store.dispatch(allRejected());
store.dispatch(dirty());
use((item) => {
expect(item.state).toBe("dirty");
expect(item.error).not.toBeNull();
expect(item.content).toBeNull();
});
});
it("item dirty -> failed", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty());
await store.dispatch(allRejected());
use((item) => {
expect(item.state).toBe("failed");
expect(item.error).not.toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item dirty -> succeeded", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty());
await store.dispatch(allResolved());
use((item) => {
expect(item.state).toBe("succeeded");
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
});

@ -1,252 +0,0 @@
import {
configureStore,
createAction,
createAsyncThunk,
createReducer,
} from "@reduxjs/toolkit";
import {} from "jest";
import { intersectionWith } from "lodash";
import { defaultList, defaultState, TestType } from "../tests/helper";
import { createAsyncListReducer } from "../utils/factory";
const allResolved = createAsyncThunk("all/resolved", () => {
return new Promise<TestType[]>((resolve) => {
resolve(defaultList);
});
});
const allRejected = createAsyncThunk("all/rejected", () => {
return new Promise<TestType[]>((resolve, rejected) => {
rejected("Error");
});
});
const idsResolved = createAsyncThunk("ids/resolved", (param: number[]) => {
return new Promise<TestType[]>((resolve) => {
resolve(intersectionWith(defaultList, param, (l, r) => l.id === r));
});
});
const idsRejected = createAsyncThunk("ids/rejected", (param: number[]) => {
return new Promise<TestType[]>((resolve, rejected) => {
rejected("Error");
});
});
const removeIds = createAction<number[]>("remove/id");
const dirty = createAction<number[]>("dirty/id");
const reducer = createReducer(defaultState, (builder) => {
createAsyncListReducer(builder, (s) => s.list, {
all: allResolved,
ids: idsResolved,
removeIds,
dirty,
});
createAsyncListReducer(builder, (s) => s.list, {
all: allRejected,
ids: idsRejected,
});
});
function createStore() {
const store = configureStore({
reducer,
});
expect(store.getState()).toEqual(defaultState);
return store;
}
let store = createStore();
function use(callback: (list: Async.List<TestType>) => void) {
const list = store.getState().list;
callback(list);
}
beforeEach(() => {
store = createStore();
});
it("list all uninitialized -> succeeded", async () => {
await store.dispatch(allResolved());
use((list) => {
expect(list.content).toEqual(defaultList);
expect(list.dirtyEntities).toHaveLength(0);
expect(list.didLoaded).toHaveLength(defaultList.length);
expect(list.error).toBeNull();
expect(list.state).toEqual("succeeded");
});
});
it("list all uninitialized -> failed", async () => {
await store.dispatch(allRejected());
use((list) => {
expect(list.content).toHaveLength(0);
expect(list.dirtyEntities).toHaveLength(0);
expect(list.error).not.toBeNull();
expect(list.state).toEqual("failed");
});
});
it("list uninitialized -> dirty", () => {
store.dispatch(dirty([0, 1]));
use((list) => {
expect(list.content).toHaveLength(0);
expect(list.dirtyEntities).toHaveLength(0);
expect(list.error).toBeNull();
expect(list.state).toEqual("uninitialized");
});
});
it("list succeeded -> dirty", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty([1, 2, 3]));
use((list) => {
expect(list.content).toEqual(defaultList);
expect(list.dirtyEntities).toHaveLength(3);
expect(list.error).toBeNull();
expect(list.state).toEqual("dirty");
});
});
it("list ids uninitialized -> succeeded", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
use((list) => {
expect(list.content).toHaveLength(3);
expect(list.didLoaded).toHaveLength(3);
expect(list.dirtyEntities).toHaveLength(0);
expect(list.error).toBeNull();
expect(list.state).toEqual("succeeded");
});
});
it("list ids succeeded -> dirty", async () => {
await store.dispatch(idsResolved([0, 1]));
store.dispatch(dirty([2, 3]));
use((list) => {
expect(list.dirtyEntities).toHaveLength(2);
expect(list.state).toEqual("dirty");
});
});
it("list ids succeeded -> dirty", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
store.dispatch(dirty([2, 3]));
use((list) => {
expect(list.dirtyEntities).toHaveLength(2);
expect(list.state).toEqual("dirty");
});
});
it("list ids update data", async () => {
await store.dispatch(idsResolved([0, 1]));
await store.dispatch(idsResolved([3, 4]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.state).toEqual("succeeded");
});
});
it("list ids update duplicative data", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
await store.dispatch(idsResolved([2, 3]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.didLoaded).toHaveLength(4);
expect(list.state).toEqual("succeeded");
});
});
it("list ids update new data", async () => {
await store.dispatch(idsResolved([0, 1]));
await store.dispatch(idsResolved([2, 3]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.didLoaded).toHaveLength(4);
expect(list.content[1].id).toBe(2);
expect(list.content[0].id).toBe(3);
expect(list.state).toEqual("succeeded");
});
});
it("list ids empty data", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
await store.dispatch(idsResolved([999]));
use((list) => {
expect(list.content).toHaveLength(3);
expect(list.state).toEqual("succeeded");
});
});
it("list ids duplicative dirty", async () => {
await store.dispatch(idsResolved([0]));
store.dispatch(dirty([2, 2]));
use((list) => {
expect(list.dirtyEntities).toHaveLength(1);
expect(list.dirtyEntities).toContain("2");
expect(list.state).toEqual("dirty");
});
});
it("list ids resolved dirty", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
store.dispatch(dirty([2, 3]));
use((list) => {
expect(list.content).toHaveLength(3);
expect(list.dirtyEntities).toContain("2");
expect(list.dirtyEntities).toContain("3");
expect(list.state).toBe("dirty");
});
});
it("list ids resolved dirty", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
store.dispatch(dirty([1, 2, 3, 999]));
await store.dispatch(idsResolved([1, 2]));
use((list) => {
expect(list.content).toHaveLength(3);
expect(list.dirtyEntities).not.toContain("1");
expect(list.dirtyEntities).not.toContain("2");
expect(list.state).toBe("dirty");
});
await store.dispatch(idsResolved([3]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.dirtyEntities).not.toContain("3");
expect(list.state).toBe("dirty");
});
await store.dispatch(idsResolved([999]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.dirtyEntities).not.toContain("999");
expect(list.state).toBe("succeeded");
});
});
it("list remove ids", async () => {
await store.dispatch(allResolved());
const totalSize = store.getState().list.content.length;
store.dispatch(removeIds([1, 2]));
use((list) => {
expect(list.content).toHaveLength(totalSize - 2);
expect(list.content.map((v) => v.id)).not.toContain(1);
expect(list.content.map((v) => v.id)).not.toContain(2);
expect(list.state).toEqual("succeeded");
});
});
it("list remove dirty ids", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty([1, 2, 3]));
store.dispatch(removeIds([1, 2]));
use((list) => {
expect(list.dirtyEntities).not.toContain("1");
expect(list.dirtyEntities).not.toContain("2");
expect(list.state).toEqual("dirty");
});
store.dispatch(removeIds([3]));
use((list) => {
expect(list.dirtyEntities).toHaveLength(0);
expect(list.state).toEqual("succeeded");
});
});

@ -1,4 +1,38 @@
export * from "./movie";
export * from "./series";
export * from "./site";
export * from "./system";
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { waitFor } from "../../utilities";
export const setSiteStatus = createAction<Site.Status>("site/status/update");
export const setUnauthenticated = createAction("site/unauthenticated");
export const setOfflineStatus = createAction<boolean>("site/offline/update");
export const addNotifications = createAction<Server.Notification[]>(
"site/notifications/add"
);
export const removeNotification = createAction<string>(
"site/notifications/remove"
);
export const siteAddProgress =
createAction<Site.Progress[]>("site/progress/add");
export const siteUpdateProgressCount = createAction<{
id: string;
count: number;
}>("site/progress/update_count");
export const siteRemoveProgress = createAsyncThunk(
"site/progress/remove",
async (ids: string[]) => {
await waitFor(3 * 1000);
return ids;
}
);
export const siteUpdateNotifier = createAction<string>(
"site/progress/update_notifier"
);
export const setSidebar = createAction<boolean>("site/sidebar/update");

@ -1,84 +0,0 @@
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { MoviesApi } from "../../apis";
export const movieUpdateByRange = createAsyncThunk(
"movies/update/range",
async (params: Parameter.Range) => {
const response = await MoviesApi.moviesBy(params);
return response;
}
);
export const movieUpdateById = createAsyncThunk(
"movies/update/id",
async (ids: number[]) => {
const response = await MoviesApi.movies(ids);
return response;
}
);
export const movieUpdateAll = createAsyncThunk(
"movies/update/all",
async () => {
const response = await MoviesApi.movies();
return response;
}
);
export const movieRemoveById = createAction<number[]>("movies/remove");
export const movieMarkDirtyById = createAction<number[]>(
"movies/mark_dirty/id"
);
export const movieUpdateWantedById = createAsyncThunk(
"movies/wanted/update/id",
async (ids: number[]) => {
const response = await MoviesApi.wantedBy(ids);
return response;
}
);
export const movieRemoveWantedById = createAction<number[]>(
"movies/wanted/remove/id"
);
export const movieResetWanted = createAction("movies/wanted/reset");
export const movieMarkWantedDirtyById = createAction<number[]>(
"movies/wanted/mark_dirty/id"
);
export const movieUpdateWantedByRange = createAsyncThunk(
"movies/wanted/update/range",
async (params: Parameter.Range) => {
const response = await MoviesApi.wanted(params);
return response;
}
);
export const movieUpdateHistoryByRange = createAsyncThunk(
"movies/history/update/range",
async (params: Parameter.Range) => {
const response = await MoviesApi.history(params);
return response;
}
);
export const movieMarkHistoryDirty = createAction<number[]>(
"movies/history/mark_dirty"
);
export const movieResetHistory = createAction("movie/history/reset");
export const movieUpdateBlacklist = createAsyncThunk(
"movies/blacklist/update",
async () => {
const response = await MoviesApi.blacklist();
return response;
}
);
export const movieMarkBlacklistDirty = createAction(
"movies/blacklist/mark_dirty"
);

@ -1,106 +0,0 @@
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { EpisodesApi, SeriesApi } from "../../apis";
export const seriesUpdateWantedById = createAsyncThunk(
"series/wanted/update/id",
async (episodeid: number[]) => {
const response = await EpisodesApi.wantedBy(episodeid);
return response;
}
);
export const seriesUpdateWantedByRange = createAsyncThunk(
"series/wanted/update/range",
async (params: Parameter.Range) => {
const response = await EpisodesApi.wanted(params);
return response;
}
);
export const seriesRemoveWantedById = createAction<number[]>(
"series/wanted/remove/id"
);
export const seriesResetWanted = createAction("series/wanted/reset");
export const seriesMarkWantedDirtyById = createAction<number[]>(
"series/wanted/mark_dirty/episode_id"
);
export const seriesRemoveById = createAction<number[]>("series/remove");
export const seriesMarkDirtyById = createAction<number[]>(
"series/mark_dirty/id"
);
export const seriesUpdateById = createAsyncThunk(
"series/update/id",
async (ids: number[]) => {
const response = await SeriesApi.series(ids);
return response;
}
);
export const seriesUpdateAll = createAsyncThunk(
"series/update/all",
async () => {
const response = await SeriesApi.series();
return response;
}
);
export const seriesUpdateByRange = createAsyncThunk(
"series/update/range",
async (params: Parameter.Range) => {
const response = await SeriesApi.seriesBy(params);
return response;
}
);
export const episodesRemoveById = createAction<number[]>("episodes/remove");
export const episodesMarkDirtyById = createAction<number[]>(
"episodes/mark_dirty/id"
);
export const episodeUpdateBySeriesId = createAsyncThunk(
"episodes/update/series_id",
async (seriesid: number[]) => {
const response = await EpisodesApi.bySeriesId(seriesid);
return response;
}
);
export const episodeUpdateById = createAsyncThunk(
"episodes/update/episodes_id",
async (episodeid: number[]) => {
const response = await EpisodesApi.byEpisodeId(episodeid);
return response;
}
);
export const episodesUpdateHistoryByRange = createAsyncThunk(
"episodes/history/update/range",
async (param: Parameter.Range) => {
const response = await EpisodesApi.history(param);
return response;
}
);
export const episodesMarkHistoryDirty = createAction<number[]>(
"episodes/history/update"
);
export const episodesResetHistory = createAction("episodes/history/reset");
export const episodesUpdateBlacklist = createAsyncThunk(
"episodes/blacklist/update",
async () => {
const response = await EpisodesApi.blacklist();
return response;
}
);
export const episodesMarkBlacklistDirty = createAction(
"episodes/blacklist/update"
);

@ -1,62 +0,0 @@
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { BadgesApi } from "../../apis";
import { waitFor } from "../../utilities";
import { systemUpdateAllSettings } from "./system";
export const siteBootstrap = createAsyncThunk(
"site/bootstrap",
(_: undefined, { dispatch }) => {
return Promise.all([
dispatch(systemUpdateAllSettings()),
dispatch(siteUpdateBadges()),
]);
}
);
export const siteUpdateInitialization = createAction<string | true>(
"site/initialization/update"
);
export const siteRedirectToAuth = createAction("site/redirect_auth");
export const siteAddNotifications = createAction<Server.Notification[]>(
"site/notifications/add"
);
export const siteRemoveNotifications = createAction<string>(
"site/notifications/remove"
);
export const siteAddProgress =
createAction<Site.Progress[]>("site/progress/add");
export const siteUpdateProgressCount = createAction<{
id: string;
count: number;
}>("site/progress/update_count");
export const siteRemoveProgress = createAsyncThunk(
"site/progress/remove",
async (ids: string[]) => {
await waitFor(3 * 1000);
return ids;
}
);
export const siteUpdateNotifier = createAction<string>(
"site/progress/update_notifier"
);
export const siteChangeSidebarVisibility = createAction<boolean>(
"site/sidebar/visibility"
);
export const siteUpdateOffline = createAction<boolean>("site/offline/update");
export const siteUpdateBadges = createAsyncThunk(
"site/badges/update",
async () => {
const response = await BadgesApi.all();
return response;
}
);

@ -1,87 +0,0 @@
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { ProvidersApi, SystemApi } from "../../apis";
export const systemUpdateAllSettings = createAsyncThunk(
"system/update",
async (_: undefined, { dispatch }) => {
await Promise.all([
dispatch(systemUpdateSettings()),
dispatch(systemUpdateLanguages()),
dispatch(systemUpdateLanguagesProfiles()),
]);
}
);
export const systemUpdateLanguages = createAsyncThunk(
"system/languages/update",
async () => {
const response = await SystemApi.languages();
return response;
}
);
export const systemUpdateLanguagesProfiles = createAsyncThunk(
"system/languages/profile/update",
async () => {
const response = await SystemApi.languagesProfileList();
return response;
}
);
export const systemUpdateStatus = createAsyncThunk(
"system/status/update",
async () => {
const response = await SystemApi.status();
return response;
}
);
export const systemUpdateHealth = createAsyncThunk(
"system/health/update",
async () => {
const response = await SystemApi.health();
return response;
}
);
export const systemMarkTasksDirty = createAction("system/tasks/mark_dirty");
export const systemUpdateTasks = createAsyncThunk(
"system/tasks/update",
async () => {
const response = await SystemApi.tasks();
return response;
}
);
export const systemUpdateLogs = createAsyncThunk(
"system/logs/update",
async () => {
const response = await SystemApi.logs();
return response;
}
);
export const systemUpdateReleases = createAsyncThunk(
"system/releases/update",
async () => {
const response = await SystemApi.releases();
return response;
}
);
export const systemUpdateSettings = createAsyncThunk(
"system/settings/update",
async () => {
const response = await SystemApi.settings();
return response;
}
);
export const providerUpdateList = createAsyncThunk(
"providers/update",
async () => {
const response = await ProvidersApi.providers();
return response;
}
);

@ -1,29 +0,0 @@
import { AsyncThunk } from "@reduxjs/toolkit";
import { useEffect } from "react";
import { log } from "../../utilities/logger";
import { useReduxAction } from "./base";
export function useAutoUpdate(item: Async.Item<any>, update: () => void) {
useEffect(() => {
if (item.state === "uninitialized" || item.state === "dirty") {
update();
}
}, [item.state, update]);
}
export function useAutoDirtyUpdate(
item: Async.List<any> | Async.Entity<any>,
updateAction: AsyncThunk<any, number[], {}>
) {
const { state, dirtyEntities } = item;
const hasDirty = dirtyEntities.length > 0 && state === "dirty";
const update = useReduxAction(updateAction);
useEffect(() => {
if (hasDirty) {
log("info", "updating dirty entities...");
update(dirtyEntities.map(Number));
}
}, [hasDirty, dirtyEntities, update]);
}

@ -1,4 +1,39 @@
export * from "./movies";
export * from "./series";
export * from "./site";
export * from "./system";
import { useSystemSettings } from "apis/hooks";
import { useCallback } from "react";
import { addNotifications } from "../actions";
import { useReduxAction, useReduxStore } from "./base";
export function useNotification(id: string, timeout: number = 5000) {
const add = useReduxAction(addNotifications);
return useCallback(
(msg: Omit<Server.Notification, "id" | "timeout">) => {
const notification: Server.Notification = {
...msg,
id,
timeout,
};
add([notification]);
},
[add, timeout, id]
);
}
export function useIsOffline() {
return useReduxStore((s) => s.offline);
}
export function useIsSonarrEnabled() {
const { data } = useSystemSettings();
return data?.general.use_sonarr ?? true;
}
export function useIsRadarrEnabled() {
const { data } = useSystemSettings();
return data?.general.use_radarr ?? true;
}
export function useShowOnlyDesired() {
const { data } = useSystemSettings();
return data?.general.embedded_subs_show_desired ?? false;
}

@ -1,67 +0,0 @@
import { useCallback, useMemo } from "react";
import { useEntityItemById, useEntityToList } from "../../utilities";
import {
movieUpdateBlacklist,
movieUpdateById,
movieUpdateWantedById,
} from "../actions";
import { useAutoDirtyUpdate, useAutoUpdate } from "./async";
import { useReduxAction, useReduxStore } from "./base";
export function useMovieEntities() {
const entities = useReduxStore((d) => d.movies.movieList);
useAutoDirtyUpdate(entities, movieUpdateById);
return entities;
}
export function useMovies() {
const rawMovies = useMovieEntities();
const content = useEntityToList(rawMovies.content);
const movies = useMemo<Async.List<Item.Movie>>(() => {
return {
...rawMovies,
keyName: rawMovies.content.keyName,
content,
};
}, [rawMovies, content]);
return movies;
}
export function useMovieBy(id: number) {
const movies = useMovieEntities();
const action = useReduxAction(movieUpdateById);
const update = useCallback(() => {
if (!isNaN(id)) {
action([id]);
}
}, [id, action]);
const movie = useEntityItemById(movies, id.toString());
useAutoUpdate(movie, update);
return movie;
}
export function useWantedMovies() {
const items = useReduxStore((d) => d.movies.wantedMovieList);
useAutoDirtyUpdate(items, movieUpdateWantedById);
return items;
}
export function useBlacklistMovies() {
const update = useReduxAction(movieUpdateBlacklist);
const items = useReduxStore((d) => d.movies.blacklist);
useAutoUpdate(items, update);
return items;
}
export function useMoviesHistory() {
const items = useReduxStore((s) => s.movies.historyList);
return items;
}

@ -1,99 +0,0 @@
import { useCallback, useEffect, useMemo } from "react";
import { useEntityItemById, useEntityToList } from "../../utilities";
import {
episodesUpdateBlacklist,
episodeUpdateById,
episodeUpdateBySeriesId,
seriesUpdateById,
seriesUpdateWantedById,
} from "../actions";
import { useAutoDirtyUpdate, useAutoUpdate } from "./async";
import { useReduxAction, useReduxStore } from "./base";
export function useSerieEntities() {
const items = useReduxStore((d) => d.series.seriesList);
useAutoDirtyUpdate(items, seriesUpdateById);
return items;
}
export function useSeries() {
const rawSeries = useSerieEntities();
const content = useEntityToList(rawSeries.content);
const series = useMemo<Async.List<Item.Series>>(() => {
return {
...rawSeries,
keyName: rawSeries.content.keyName,
content,
};
}, [rawSeries, content]);
return series;
}
export function useSerieBy(id: number) {
const series = useSerieEntities();
const action = useReduxAction(seriesUpdateById);
const serie = useEntityItemById(series, String(id));
const update = useCallback(() => {
if (!isNaN(id)) {
action([id]);
}
}, [id, action]);
useAutoUpdate(serie, update);
return serie;
}
export function useEpisodesBy(seriesId: number) {
const action = useReduxAction(episodeUpdateBySeriesId);
const update = useCallback(() => {
if (!isNaN(seriesId)) {
action([seriesId]);
}
}, [action, seriesId]);
const episodes = useReduxStore((d) => d.series.episodeList);
const newContent = useMemo(() => {
return episodes.content.filter((v) => v.sonarrSeriesId === seriesId);
}, [seriesId, episodes.content]);
const newList: Async.List<Item.Episode> = useMemo(
() => ({
...episodes,
content: newContent,
}),
[episodes, newContent]
);
// FIXME
useEffect(() => {
update();
}, [update]);
useAutoDirtyUpdate(episodes, episodeUpdateById);
return newList;
}
export function useWantedSeries() {
const items = useReduxStore((d) => d.series.wantedEpisodesList);
useAutoDirtyUpdate(items, seriesUpdateWantedById);
return items;
}
export function useBlacklistSeries() {
const update = useReduxAction(episodesUpdateBlacklist);
const items = useReduxStore((d) => d.series.blacklist);
useAutoUpdate(items, update);
return items;
}
export function useSeriesHistory() {
const items = useReduxStore((s) => s.series.historyList);
return items;
}

@ -1,39 +0,0 @@
import { useCallback } from "react";
import { useSystemSettings } from ".";
import { siteAddNotifications } from "../actions";
import { useReduxAction, useReduxStore } from "./base";
export function useNotification(id: string, timeout: number = 5000) {
const add = useReduxAction(siteAddNotifications);
return useCallback(
(msg: Omit<Server.Notification, "id" | "timeout">) => {
const notification: Server.Notification = {
...msg,
id,
timeout,
};
add([notification]);
},
[add, timeout, id]
);
}
export function useIsOffline() {
return useReduxStore((s) => s.site.offline);
}
export function useIsSonarrEnabled() {
const settings = useSystemSettings();
return settings.content?.general.use_sonarr ?? true;
}
export function useIsRadarrEnabled() {
const settings = useSystemSettings();
return settings.content?.general.use_radarr ?? true;
}
export function useShowOnlyDesired() {
const settings = useSystemSettings();
return settings.content?.general.embedded_subs_show_desired ?? false;
}

@ -1,131 +0,0 @@
import { useMemo } from "react";
import {
providerUpdateList,
systemUpdateHealth,
systemUpdateLogs,
systemUpdateReleases,
systemUpdateStatus,
systemUpdateTasks,
} from "../actions";
import { useAutoUpdate } from "./async";
import { useReduxAction, useReduxStore } from "./base";
export function useSystemSettings() {
const items = useReduxStore((s) => s.system.settings);
return items;
}
export function useSystemLogs() {
const items = useReduxStore(({ system }) => system.logs);
const update = useReduxAction(systemUpdateLogs);
useAutoUpdate(items, update);
return items;
}
export function useSystemTasks() {
const items = useReduxStore((s) => s.system.tasks);
const update = useReduxAction(systemUpdateTasks);
useAutoUpdate(items, update);
return items;
}
export function useSystemStatus() {
const items = useReduxStore((s) => s.system.status);
const update = useReduxAction(systemUpdateStatus);
useAutoUpdate(items, update);
return items.content;
}
export function useSystemHealth() {
const items = useReduxStore((s) => s.system.health);
const update = useReduxAction(systemUpdateHealth);
useAutoUpdate(items, update);
return items;
}
export function useSystemProviders() {
const update = useReduxAction(providerUpdateList);
const items = useReduxStore((d) => d.system.providers);
useAutoUpdate(items, update);
return items;
}
export function useSystemReleases() {
const items = useReduxStore(({ system }) => system.releases);
const update = useReduxAction(systemUpdateReleases);
useAutoUpdate(items, update);
return items;
}
export function useLanguageProfiles() {
const items = useReduxStore((s) => s.system.languagesProfiles);
return items.content;
}
export function useProfileBy(id: number | null | undefined) {
const profiles = useLanguageProfiles();
return useMemo(
() => profiles?.find((v) => v.profileId === id),
[id, profiles]
);
}
export function useLanguages() {
const data = useReduxStore((s) => s.system.languages);
const languages = useMemo<Language.Info[]>(
() => data.content?.map((v) => ({ code2: v.code2, name: v.name })) ?? [],
[data.content]
);
return languages;
}
export function useEnabledLanguages() {
const data = useReduxStore((s) => s.system.languages);
const enabled = useMemo<Language.Info[]>(
() =>
data.content
?.filter((v) => v.enabled)
.map((v) => ({ code2: v.code2, name: v.name })) ?? [],
[data.content]
);
return enabled;
}
export function useLanguageBy(code?: string) {
const languages = useLanguages();
return useMemo(
() => languages.find((v) => v.code2 === code),
[languages, code]
);
}
// Convert languageprofile items to language
export function useProfileItemsToLanguages(profile?: Language.Profile) {
const languages = useLanguages();
return useMemo(
() =>
profile?.items.map<Language.Info>(({ language: code, hi, forced }) => {
const name = languages.find((v) => v.code2 === code)?.name ?? "";
return {
hi: hi === "True",
forced: forced === "True",
code2: code,
name,
};
}) ?? [],
[languages, profile?.items]
);
}

@ -1,13 +1,110 @@
import movies from "./movie";
import series from "./series";
import site from "./site";
import system from "./system";
const AllReducers = {
movies,
series,
site,
system,
import { createReducer } from "@reduxjs/toolkit";
import { intersectionWith, pullAllWith, remove, sortBy, uniqBy } from "lodash";
import apis from "../../apis/queries/client";
import { isProdEnv } from "../../utilities";
import {
addNotifications,
removeNotification,
setOfflineStatus,
setSidebar,
setSiteStatus,
setUnauthenticated,
siteAddProgress,
siteRemoveProgress,
siteUpdateNotifier,
siteUpdateProgressCount,
} from "../actions";
interface Site {
// Initialization state or error message
status: Site.Status;
offline: boolean;
progress: Site.Progress[];
notifier: {
content: string | null;
timestamp: string;
};
notifications: Server.Notification[];
showSidebar: boolean;
}
const defaultSite: Site = {
status: "uninitialized",
progress: [],
notifier: {
content: null,
timestamp: String(Date.now()),
},
notifications: [],
showSidebar: false,
offline: false,
};
export default AllReducers;
const reducer = createReducer(defaultSite, (builder) => {
builder
.addCase(setUnauthenticated, (state) => {
if (!isProdEnv) {
apis._resetApi("NEED_AUTH");
}
state.status = "unauthenticated";
})
.addCase(setSiteStatus, (state, action) => {
state.status = action.payload;
});
builder
.addCase(addNotifications, (state, action) => {
state.notifications = uniqBy(
[...action.payload, ...state.notifications],
(v) => v.id
);
state.notifications = sortBy(state.notifications, (v) => v.id);
})
.addCase(removeNotification, (state, action) => {
remove(state.notifications, (n) => n.id === action.payload);
});
builder
.addCase(siteAddProgress, (state, action) => {
state.progress = uniqBy(
[...action.payload, ...state.progress],
(n) => n.id
);
state.progress = sortBy(state.progress, (v) => v.id);
})
.addCase(siteRemoveProgress.pending, (state, action) => {
// Mark completed
intersectionWith(
state.progress,
action.meta.arg,
(l, r) => l.id === r
).forEach((v) => {
v.value = v.count + 1;
});
})
.addCase(siteRemoveProgress.fulfilled, (state, action) => {
pullAllWith(state.progress, action.payload, (l, r) => l.id === r);
})
.addCase(siteUpdateProgressCount, (state, action) => {
const { id, count } = action.payload;
const progress = state.progress.find((v) => v.id === id);
if (progress) {
progress.count = count;
}
});
builder.addCase(siteUpdateNotifier, (state, action) => {
state.notifier.content = action.payload;
state.notifier.timestamp = String(Date.now());
});
builder
.addCase(setSidebar, (state, action) => {
state.showSidebar = action.payload;
})
.addCase(setOfflineStatus, (state, action) => {
state.offline = action.payload;
});
});
export default reducer;

@ -1,68 +0,0 @@
import { createReducer } from "@reduxjs/toolkit";
import {
movieMarkBlacklistDirty,
movieMarkDirtyById,
movieMarkHistoryDirty,
movieMarkWantedDirtyById,
movieRemoveById,
movieRemoveWantedById,
movieResetHistory,
movieResetWanted,
movieUpdateAll,
movieUpdateBlacklist,
movieUpdateById,
movieUpdateByRange,
movieUpdateHistoryByRange,
movieUpdateWantedById,
movieUpdateWantedByRange,
} from "../actions";
import { AsyncUtility } from "../utils";
import {
createAsyncEntityReducer,
createAsyncItemReducer,
} from "../utils/factory";
interface Movie {
movieList: Async.Entity<Item.Movie>;
wantedMovieList: Async.Entity<Wanted.Movie>;
historyList: Async.Entity<History.Movie>;
blacklist: Async.Item<Blacklist.Movie[]>;
}
const defaultMovie: Movie = {
movieList: AsyncUtility.getDefaultEntity("radarrId"),
wantedMovieList: AsyncUtility.getDefaultEntity("radarrId"),
historyList: AsyncUtility.getDefaultEntity("id"),
blacklist: AsyncUtility.getDefaultItem(),
};
const reducer = createReducer(defaultMovie, (builder) => {
createAsyncEntityReducer(builder, (s) => s.movieList, {
range: movieUpdateByRange,
ids: movieUpdateById,
removeIds: movieRemoveById,
all: movieUpdateAll,
dirty: movieMarkDirtyById,
});
createAsyncEntityReducer(builder, (s) => s.wantedMovieList, {
range: movieUpdateWantedByRange,
ids: movieUpdateWantedById,
removeIds: movieRemoveWantedById,
dirty: movieMarkWantedDirtyById,
reset: movieResetWanted,
});
createAsyncEntityReducer(builder, (s) => s.historyList, {
range: movieUpdateHistoryByRange,
dirty: movieMarkHistoryDirty,
reset: movieResetHistory,
});
createAsyncItemReducer(builder, (s) => s.blacklist, {
all: movieUpdateBlacklist,
dirty: movieMarkBlacklistDirty,
});
});
export default reducer;

@ -1,100 +0,0 @@
import { createReducer } from "@reduxjs/toolkit";
import {
episodesMarkBlacklistDirty,
episodesMarkDirtyById,
episodesMarkHistoryDirty,
episodesRemoveById,
episodesResetHistory,
episodesUpdateBlacklist,
episodesUpdateHistoryByRange,
episodeUpdateById,
episodeUpdateBySeriesId,
seriesMarkDirtyById,
seriesMarkWantedDirtyById,
seriesRemoveById,
seriesRemoveWantedById,
seriesResetWanted,
seriesUpdateAll,
seriesUpdateById,
seriesUpdateByRange,
seriesUpdateWantedById,
seriesUpdateWantedByRange,
} from "../actions";
import { AsyncUtility, ReducerUtility } from "../utils";
import {
createAsyncEntityReducer,
createAsyncItemReducer,
createAsyncListReducer,
} from "../utils/factory";
interface Series {
seriesList: Async.Entity<Item.Series>;
wantedEpisodesList: Async.Entity<Wanted.Episode>;
episodeList: Async.List<Item.Episode>;
historyList: Async.Entity<History.Episode>;
blacklist: Async.Item<Blacklist.Episode[]>;
}
const defaultSeries: Series = {
seriesList: AsyncUtility.getDefaultEntity("sonarrSeriesId"),
wantedEpisodesList: AsyncUtility.getDefaultEntity("sonarrEpisodeId"),
episodeList: AsyncUtility.getDefaultList("sonarrEpisodeId"),
historyList: AsyncUtility.getDefaultEntity("id"),
blacklist: AsyncUtility.getDefaultItem(),
};
const reducer = createReducer(defaultSeries, (builder) => {
createAsyncEntityReducer(builder, (s) => s.seriesList, {
range: seriesUpdateByRange,
ids: seriesUpdateById,
removeIds: seriesRemoveById,
all: seriesUpdateAll,
});
builder.addCase(seriesMarkDirtyById, (state, action) => {
const series = state.seriesList;
const dirtyIds = action.payload.map(String);
ReducerUtility.markDirty(series, dirtyIds);
// Update episode list
const episodes = state.episodeList;
const dirtyIdsSet = new Set(dirtyIds);
const dirtyEpisodeIds = episodes.content
.filter((v) => dirtyIdsSet.has(v.sonarrSeriesId.toString()))
.map((v) => String(v.sonarrEpisodeId));
ReducerUtility.markDirty(episodes, dirtyEpisodeIds);
});
createAsyncEntityReducer(builder, (s) => s.wantedEpisodesList, {
range: seriesUpdateWantedByRange,
ids: seriesUpdateWantedById,
removeIds: seriesRemoveWantedById,
dirty: seriesMarkWantedDirtyById,
reset: seriesResetWanted,
});
createAsyncEntityReducer(builder, (s) => s.historyList, {
range: episodesUpdateHistoryByRange,
dirty: episodesMarkHistoryDirty,
reset: episodesResetHistory,
});
createAsyncItemReducer(builder, (s) => s.blacklist, {
all: episodesUpdateBlacklist,
dirty: episodesMarkBlacklistDirty,
});
createAsyncListReducer(builder, (s) => s.episodeList, {
ids: episodeUpdateBySeriesId,
});
createAsyncListReducer(builder, (s) => s.episodeList, {
ids: episodeUpdateById,
removeIds: episodesRemoveById,
dirty: episodesMarkDirtyById,
});
});
export default reducer;

@ -1,130 +0,0 @@
import { createReducer } from "@reduxjs/toolkit";
import { intersectionWith, pullAllWith, remove, sortBy, uniqBy } from "lodash";
import apis from "../../apis";
import { isProdEnv } from "../../utilities";
import {
siteAddNotifications,
siteAddProgress,
siteBootstrap,
siteChangeSidebarVisibility,
siteRedirectToAuth,
siteRemoveNotifications,
siteRemoveProgress,
siteUpdateBadges,
siteUpdateInitialization,
siteUpdateNotifier,
siteUpdateOffline,
siteUpdateProgressCount,
} from "../actions/site";
interface Site {
// Initialization state or error message
initialized: boolean | string;
offline: boolean;
auth: boolean;
progress: Site.Progress[];
notifier: {
content: string | null;
timestamp: string;
};
notifications: Server.Notification[];
showSidebar: boolean;
badges: Badge;
}
const defaultSite: Site = {
initialized: false,
auth: true,
progress: [],
notifier: {
content: null,
timestamp: String(Date.now()),
},
notifications: [],
showSidebar: false,
badges: {
movies: 0,
episodes: 0,
providers: 0,
status: 0,
},
offline: false,
};
const reducer = createReducer(defaultSite, (builder) => {
builder
.addCase(siteBootstrap.fulfilled, (state) => {
state.initialized = true;
})
.addCase(siteBootstrap.rejected, (state) => {
state.initialized = "An Error Occurred When Initializing Bazarr UI";
})
.addCase(siteRedirectToAuth, (state) => {
if (!isProdEnv) {
apis._resetApi("NEED_AUTH");
}
state.auth = false;
})
.addCase(siteUpdateInitialization, (state, action) => {
state.initialized = action.payload;
});
builder
.addCase(siteAddNotifications, (state, action) => {
state.notifications = uniqBy(
[...action.payload, ...state.notifications],
(v) => v.id
);
state.notifications = sortBy(state.notifications, (v) => v.id);
})
.addCase(siteRemoveNotifications, (state, action) => {
remove(state.notifications, (n) => n.id === action.payload);
});
builder
.addCase(siteAddProgress, (state, action) => {
state.progress = uniqBy(
[...action.payload, ...state.progress],
(n) => n.id
);
state.progress = sortBy(state.progress, (v) => v.id);
})
.addCase(siteRemoveProgress.pending, (state, action) => {
// Mark completed
intersectionWith(
state.progress,
action.meta.arg,
(l, r) => l.id === r
).forEach((v) => {
v.value = v.count + 1;
});
})
.addCase(siteRemoveProgress.fulfilled, (state, action) => {
pullAllWith(state.progress, action.payload, (l, r) => l.id === r);
})
.addCase(siteUpdateProgressCount, (state, action) => {
const { id, count } = action.payload;
const progress = state.progress.find((v) => v.id === id);
if (progress) {
progress.count = count;
}
});
builder.addCase(siteUpdateNotifier, (state, action) => {
state.notifier.content = action.payload;
state.notifier.timestamp = String(Date.now());
});
builder
.addCase(siteChangeSidebarVisibility, (state, action) => {
state.showSidebar = action.payload;
})
.addCase(siteUpdateOffline, (state, action) => {
state.offline = action.payload;
})
.addCase(siteUpdateBadges.fulfilled, (state, action) => {
state.badges = action.payload;
});
});
export default reducer;

@ -1,74 +0,0 @@
import { createReducer } from "@reduxjs/toolkit";
import {
providerUpdateList,
systemMarkTasksDirty,
systemUpdateHealth,
systemUpdateLanguages,
systemUpdateLanguagesProfiles,
systemUpdateLogs,
systemUpdateReleases,
systemUpdateSettings,
systemUpdateStatus,
systemUpdateTasks,
} from "../actions";
import { AsyncUtility } from "../utils";
import { createAsyncItemReducer } from "../utils/factory";
interface System {
languages: Async.Item<Language.Server[]>;
languagesProfiles: Async.Item<Language.Profile[]>;
status: Async.Item<System.Status>;
health: Async.Item<System.Health[]>;
tasks: Async.Item<System.Task[]>;
providers: Async.Item<System.Provider[]>;
logs: Async.Item<System.Log[]>;
releases: Async.Item<ReleaseInfo[]>;
settings: Async.Item<Settings>;
}
const defaultSystem: System = {
languages: AsyncUtility.getDefaultItem(),
languagesProfiles: AsyncUtility.getDefaultItem(),
status: AsyncUtility.getDefaultItem(),
health: AsyncUtility.getDefaultItem(),
tasks: AsyncUtility.getDefaultItem(),
providers: AsyncUtility.getDefaultItem(),
logs: AsyncUtility.getDefaultItem(),
releases: AsyncUtility.getDefaultItem(),
settings: AsyncUtility.getDefaultItem(),
};
const reducer = createReducer(defaultSystem, (builder) => {
createAsyncItemReducer(builder, (s) => s.languages, {
all: systemUpdateLanguages,
});
createAsyncItemReducer(builder, (s) => s.languagesProfiles, {
all: systemUpdateLanguagesProfiles,
});
createAsyncItemReducer(builder, (s) => s.status, { all: systemUpdateStatus });
createAsyncItemReducer(builder, (s) => s.settings, {
all: systemUpdateSettings,
});
createAsyncItemReducer(builder, (s) => s.releases, {
all: systemUpdateReleases,
});
createAsyncItemReducer(builder, (s) => s.logs, {
all: systemUpdateLogs,
});
createAsyncItemReducer(builder, (s) => s.health, {
all: systemUpdateHealth,
});
createAsyncItemReducer(builder, (s) => s.tasks, {
all: systemUpdateTasks,
dirty: systemMarkTasksDirty,
});
createAsyncItemReducer(builder, (s) => s.providers, {
all: providerUpdateList,
});
});
export default reducer;

@ -1,5 +1,5 @@
import { configureStore } from "@reduxjs/toolkit";
import apis from "../../apis";
import apis from "../../apis/queries/client";
import reducer from "../reducers";
const store = configureStore({

@ -1,31 +0,0 @@
import { AsyncUtility } from "../utils";
export interface TestType {
id: number;
name: string;
}
export interface Reducer {
item: Async.Item<TestType>;
list: Async.List<TestType>;
entities: Async.Entity<TestType>;
}
export const defaultState: Reducer = {
item: AsyncUtility.getDefaultItem(),
list: AsyncUtility.getDefaultList("id"),
entities: AsyncUtility.getDefaultEntity("id"),
};
export const defaultItem: TestType = { id: 0, name: "test" };
export const defaultList: TestType[] = [
{ id: 0, name: "test" },
{ id: 1, name: "test_1" },
{ id: 2, name: "test_2" },
{ id: 3, name: "test_3" },
{ id: 4, name: "test_4" },
{ id: 5, name: "test_5" },
{ id: 6, name: "test_6" },
{ id: 7, name: "test_6" },
];

@ -1,32 +0,0 @@
import {} from "jest";
import { AsyncUtility } from "..";
interface AsyncTest {
id: string;
name: string;
}
it("Item Init", () => {
const item = AsyncUtility.getDefaultItem<AsyncTest>();
expect(item.state).toEqual("uninitialized");
expect(item.error).toBeNull();
expect(item.content).toBeNull();
});
it("List Init", () => {
const list = AsyncUtility.getDefaultList<AsyncTest>("id");
expect(list.state).toEqual("uninitialized");
expect(list.dirtyEntities).toHaveLength(0);
expect(list.error).toBeNull();
expect(list.content).toHaveLength(0);
});
it("Entity Init", () => {
const entity = AsyncUtility.getDefaultEntity<AsyncTest>("id");
expect(entity.state).toEqual("uninitialized");
expect(entity.dirtyEntities).toHaveLength(0);
expect(entity.error).toBeNull();
expect(entity.content.ids).toHaveLength(0);
expect(entity.content.keyName).toBe("id");
expect(entity.content.entities).toMatchObject({});
});

@ -1,339 +0,0 @@
import {
ActionCreatorWithoutPayload,
ActionCreatorWithPayload,
ActionReducerMapBuilder,
AsyncThunk,
Draft,
} from "@reduxjs/toolkit";
import {
difference,
findIndex,
isNull,
isString,
omit,
pullAll,
pullAllWith,
} from "lodash";
import { ReducerUtility } from ".";
import { conditionalLog } from "../../utilities/logger";
interface ActionParam<T, ID = null> {
range?: AsyncThunk<T, Parameter.Range, {}>;
all?: AsyncThunk<T, void, {}>;
ids?: AsyncThunk<T, ID[], {}>;
removeIds?: ActionCreatorWithPayload<ID[]>;
reset?: ActionCreatorWithoutPayload;
dirty?: ID extends null
? ActionCreatorWithoutPayload
: ActionCreatorWithPayload<ID[]>;
}
export function createAsyncItemReducer<S, T>(
builder: ActionReducerMapBuilder<S>,
getItem: (state: Draft<S>) => Draft<Async.Item<T>>,
actions: Pick<ActionParam<T>, "all" | "dirty">
) {
const { all, dirty } = actions;
all &&
builder
.addCase(all.pending, (state) => {
const item = getItem(state);
item.state = "loading";
item.error = null;
})
.addCase(all.fulfilled, (state, action) => {
const item = getItem(state);
item.state = "succeeded";
item.content = action.payload as Draft<T>;
})
.addCase(all.rejected, (state, action) => {
const item = getItem(state);
item.state = "failed";
item.error = action.error.message ?? null;
});
dirty &&
builder.addCase(dirty, (state) => {
const item = getItem(state);
if (item.state !== "uninitialized") {
item.state = "dirty";
}
});
}
export function createAsyncListReducer<S, T, ID extends Async.IdType>(
builder: ActionReducerMapBuilder<S>,
getList: (state: Draft<S>) => Draft<Async.List<T>>,
actions: ActionParam<T[], ID>
) {
const { ids, removeIds, all, dirty } = actions;
ids &&
builder
.addCase(ids.pending, (state) => {
const list = getList(state);
list.state = "loading";
list.error = null;
})
.addCase(ids.fulfilled, (state, action) => {
const list = getList(state);
const {
meta: { arg },
} = action;
const strIds = arg.map(String);
const keyName = list.keyName as keyof T;
action.payload.forEach((v) => {
const idx = findIndex(list.content, [keyName, v[keyName]]);
if (idx !== -1) {
list.content.splice(idx, 1, v as Draft<T>);
} else {
list.content.unshift(v as Draft<T>);
}
});
ReducerUtility.updateDirty(list, strIds);
ReducerUtility.updateDidLoaded(list, strIds);
})
.addCase(ids.rejected, (state, action) => {
const list = getList(state);
list.state = "failed";
list.error = action.error.message ?? null;
});
removeIds &&
builder.addCase(removeIds, (state, action) => {
const list = getList(state);
const keyName = list.keyName as keyof T;
const removeIds = action.payload.map(String);
pullAllWith(list.content, removeIds, (lhs, rhs) => {
return String((lhs as T)[keyName]) === rhs;
});
ReducerUtility.removeDirty(list, removeIds);
ReducerUtility.removeDidLoaded(list, removeIds);
});
all &&
builder
.addCase(all.pending, (state) => {
const list = getList(state);
list.state = "loading";
list.error = null;
})
.addCase(all.fulfilled, (state, action) => {
const list = getList(state);
list.state = "succeeded";
list.content = action.payload as Draft<T[]>;
list.dirtyEntities = [];
const ids = action.payload.map((v) =>
String(v[list.keyName as keyof T])
);
ReducerUtility.updateDidLoaded(list, ids);
})
.addCase(all.rejected, (state, action) => {
const list = getList(state);
list.state = "failed";
list.error = action.error.message ?? null;
});
dirty &&
builder.addCase(dirty, (state, action) => {
const list = getList(state);
ReducerUtility.markDirty(list, action.payload.map(String));
});
}
export function createAsyncEntityReducer<S, T, ID extends Async.IdType>(
builder: ActionReducerMapBuilder<S>,
getEntity: (state: Draft<S>) => Draft<Async.Entity<T>>,
actions: ActionParam<AsyncDataWrapper<T>, ID>
) {
const { all, removeIds, ids, range, dirty, reset } = actions;
const checkSizeUpdate = (entity: Draft<Async.Entity<T>>, newSize: number) => {
if (entity.content.ids.length !== newSize) {
// Reset Entity State
entity.dirtyEntities = [];
entity.content.ids = Array(newSize).fill(null);
entity.content.entities = {};
}
};
range &&
builder
.addCase(range.pending, (state) => {
const entity = getEntity(state);
entity.state = "loading";
entity.error = null;
})
.addCase(range.fulfilled, (state, action) => {
const entity = getEntity(state);
const {
meta: {
arg: { start, length },
},
payload: { data, total },
} = action;
const keyName = entity.content.keyName as keyof T;
checkSizeUpdate(entity, total);
data.forEach((v) => {
const key = String(v[keyName]);
entity.content.entities[key] = v as Draft<T>;
});
const idsToUpdate = data.map((v) => String(v[keyName]));
// Remove duplicated ids
const pulledSize =
total - pullAll(entity.content.ids, idsToUpdate).length;
entity.content.ids.push(...Array(pulledSize).fill(null));
entity.content.ids.splice(start, length, ...idsToUpdate);
ReducerUtility.updateDirty(entity, idsToUpdate);
ReducerUtility.updateDidLoaded(entity, idsToUpdate);
})
.addCase(range.rejected, (state, action) => {
const entity = getEntity(state);
entity.state = "failed";
entity.error = action.error.message ?? null;
});
ids &&
builder
.addCase(ids.pending, (state) => {
const entity = getEntity(state);
entity.state = "loading";
entity.error = null;
})
.addCase(ids.fulfilled, (state, action) => {
const entity = getEntity(state);
const {
meta: { arg },
payload: { data, total },
} = action;
const keyName = entity.content.keyName as keyof T;
checkSizeUpdate(entity, total);
const idsToAdd = data.map((v) => String(v[keyName]));
// For new ids, remove null from list and add them
const newIds = difference(
idsToAdd,
entity.content.ids.filter(isString)
);
const newSize = entity.content.ids.unshift(...newIds);
Array(newSize - total)
.fill(undefined)
.forEach(() => {
const idx = entity.content.ids.findIndex(isNull);
conditionalLog(idx === -1, "Error when deleting ids from entity");
entity.content.ids.splice(idx, 1);
});
data.forEach((v) => {
const key = String(v[keyName]);
entity.content.entities[key] = v as Draft<T>;
});
const allIds = arg.map(String);
ReducerUtility.updateDirty(entity, allIds);
ReducerUtility.updateDidLoaded(entity, allIds);
})
.addCase(ids.rejected, (state, action) => {
const entity = getEntity(state);
entity.state = "failed";
entity.error = action.error.message ?? null;
});
removeIds &&
builder.addCase(removeIds, (state, action) => {
const entity = getEntity(state);
conditionalLog(
entity.state === "loading",
"Try to delete async entity when it's now loading"
);
const idsToDelete = action.payload.map(String);
pullAll(entity.content.ids, idsToDelete);
ReducerUtility.removeDirty(entity, idsToDelete);
ReducerUtility.removeDidLoaded(entity, idsToDelete);
omit(entity.content.entities, idsToDelete);
});
all &&
builder
.addCase(all.pending, (state) => {
const entity = getEntity(state);
entity.state = "loading";
entity.error = null;
})
.addCase(all.fulfilled, (state, action) => {
const entity = getEntity(state);
const {
payload: { data, total },
} = action;
conditionalLog(
data.length !== total,
"Length of data is mismatch with total length"
);
const keyName = entity.content.keyName as keyof T;
entity.state = "succeeded";
entity.dirtyEntities = [];
entity.content.ids = data.map((v) => String(v[keyName]));
entity.content.entities = data.reduce<
Draft<{
[id: string]: T;
}>
>((prev, curr) => {
const id = String(curr[keyName]);
prev[id] = curr as Draft<T>;
return prev;
}, {});
const allIds = entity.content.ids.filter(isString);
ReducerUtility.updateDidLoaded(entity, allIds);
})
.addCase(all.rejected, (state, action) => {
const entity = getEntity(state);
entity.state = "failed";
entity.error = action.error.message ?? null;
});
dirty &&
builder.addCase(dirty, (state, action) => {
const entity = getEntity(state);
ReducerUtility.markDirty(entity, action.payload.map(String));
});
reset &&
builder.addCase(reset, (state) => {
const entity = getEntity(state);
entity.content.entities = {};
entity.content.ids = [];
entity.didLoaded = [];
entity.dirtyEntities = [];
entity.error = null;
entity.state = "uninitialized";
});
}

@ -1,88 +0,0 @@
import { Draft } from "@reduxjs/toolkit";
import { difference, pullAll, uniq } from "lodash";
export namespace AsyncUtility {
export function getDefaultItem<T>(): Async.Item<T> {
return {
state: "uninitialized",
content: null,
error: null,
};
}
export function getDefaultList<T>(key: keyof T): Async.List<T> {
return {
state: "uninitialized",
keyName: key,
dirtyEntities: [],
didLoaded: [],
content: [],
error: null,
};
}
export function getDefaultEntity<T>(key: keyof T): Async.Entity<T> {
return {
state: "uninitialized",
dirtyEntities: [],
didLoaded: [],
content: {
keyName: key,
ids: [],
entities: {},
},
error: null,
};
}
}
export namespace ReducerUtility {
type DirtyType = Draft<Async.Entity<any>> | Draft<Async.List<any>>;
export function markDirty<T extends DirtyType>(
entity: T,
dirtyIds: string[]
) {
if (entity.state !== "uninitialized" && entity.state !== "loading") {
entity.state = "dirty";
entity.dirtyEntities.push(...dirtyIds);
entity.dirtyEntities = uniq(entity.dirtyEntities);
}
}
export function updateDirty<T extends DirtyType>(
entity: T,
updatedIds: string[]
) {
entity.dirtyEntities = difference(entity.dirtyEntities, updatedIds);
if (entity.dirtyEntities.length > 0) {
entity.state = "dirty";
} else {
entity.state = "succeeded";
}
}
export function removeDirty<T extends DirtyType>(
entity: T,
removedIds: string[]
) {
pullAll(entity.dirtyEntities, removedIds);
if (entity.dirtyEntities.length === 0 && entity.state === "dirty") {
entity.state = "succeeded";
}
}
export function updateDidLoaded<T extends DirtyType>(
entity: T,
loadedIds: string[]
) {
entity.didLoaded.push(...loadedIds);
entity.didLoaded = uniq(entity.didLoaded);
}
export function removeDidLoaded<T extends DirtyType>(
entity: T,
removedIds: string[]
) {
pullAll(entity.didLoaded, removedIds);
}
}

@ -227,7 +227,7 @@ declare namespace History {
series: StatItem[];
};
type TimeframeOptions = "week" | "month" | "trimester" | "year";
type TimeFrameOptions = "week" | "month" | "trimester" | "year";
type ActionOptions = 1 | 2 | 3;
}

@ -1,24 +0,0 @@
declare namespace Async {
type State = "loading" | "succeeded" | "failed" | "dirty" | "uninitialized";
type IdType = number | string;
type Base<T> = {
state: State;
content: T;
error: string | null;
};
type List<T> = Base<T[]> & {
keyName: keyof T;
dirtyEntities: string[];
didLoaded: string[];
};
type Item<T> = Base<T | null>;
type Entity<T> = Base<EntityStruct<T>> & {
dirtyEntities: string[];
didLoaded: string[];
};
}

@ -0,0 +1,3 @@
type RangeQuery<T> = (
param: Parameter.Range
) => Promise<DataWrapperWithTotal<T>>;

@ -8,6 +8,7 @@ declare namespace Server {
}
declare namespace Site {
type Status = "uninitialized" | "unauthenticated" | "initialized" | "error";
interface Progress {
id: string;
header: string;

@ -29,7 +29,7 @@ interface DataWrapper<T> {
data: T;
}
interface AsyncDataWrapper<T> {
interface DataWrapperWithTotal<T> {
data: T[];
total: number;
}

@ -5,7 +5,11 @@ import {
faUser,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { uniqueId } from "lodash";
import { setSidebar } from "@redux/actions";
import { useIsOffline } from "@redux/hooks";
import { useReduxAction } from "@redux/hooks/base";
import logo from "@static/logo64.png";
import { ActionButton, SearchBar } from "components";
import React, { FunctionComponent, useMemo } from "react";
import {
Button,
@ -17,60 +21,26 @@ import {
Row,
} from "react-bootstrap";
import { Helmet } from "react-helmet";
import {
siteChangeSidebarVisibility,
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 { useGotoHomepage, useIsMobile } from "../utilities";
import { useGotoHomepage, useIsMobile } from "utilities";
import { useSystem, useSystemSettings } from "../apis/hooks";
import "./header.scss";
import NotificationCenter from "./Notification";
async function SearchItem(text: string) {
const results = await SystemApi.search(text);
return results.map<SearchResult>((v) => {
let link: string;
let id: string;
if (v.sonarrSeriesId) {
link = `/series/${v.sonarrSeriesId}`;
id = `series-${v.sonarrSeriesId}`;
} else if (v.radarrId) {
link = `/movies/${v.radarrId}`;
id = `movie-${v.radarrId}`;
} else {
link = "";
id = uniqueId("unknown");
}
return {
name: `${v.title} (${v.year})`,
link,
id,
};
});
}
interface Props {}
const Header: FunctionComponent<Props> = () => {
const setNeedAuth = useReduxAction(siteRedirectToAuth);
const settings = useSystemSettings();
const { data: settings } = useSystemSettings();
const canLogout = (settings.content?.auth.type ?? "none") === "form";
const hasLogout = (settings?.auth.type ?? "none") === "form";
const changeSidebar = useReduxAction(siteChangeSidebarVisibility);
const changeSidebar = useReduxAction(setSidebar);
const offline = useIsOffline();
const isMobile = useIsMobile();
const { shutdown, restart, logout } = useSystem();
const serverActions = useMemo(
() => (
<Dropdown alignRight>
@ -80,23 +50,23 @@ const Header: FunctionComponent<Props> = () => {
<Dropdown.Menu>
<Dropdown.Item
onClick={() => {
SystemApi.restart();
restart();
}}
>
Restart
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
SystemApi.shutdown();
shutdown();
}}
>
Shutdown
</Dropdown.Item>
<Dropdown.Divider hidden={!canLogout}></Dropdown.Divider>
<Dropdown.Divider hidden={!hasLogout}></Dropdown.Divider>
<Dropdown.Item
hidden={!canLogout}
hidden={!hasLogout}
onClick={() => {
SystemApi.logout().then(() => setNeedAuth());
logout();
}}
>
Logout
@ -104,7 +74,7 @@ const Header: FunctionComponent<Props> = () => {
</Dropdown.Menu>
</Dropdown>
),
[canLogout, setNeedAuth]
[hasLogout, logout, restart, shutdown]
);
const goHome = useGotoHomepage();
@ -133,7 +103,7 @@ const Header: FunctionComponent<Props> = () => {
<Container fluid>
<Row noGutters className="flex-grow-1">
<Col xs={4} sm={6} className="d-flex align-items-center">
<SearchBar onSearch={SearchItem}></SearchBar>
<SearchBar></SearchBar>
</Col>
<Col className="d-flex flex-row align-items-center justify-content-end pr-2">
<NotificationCenter></NotificationCenter>

@ -10,6 +10,7 @@ import {
FontAwesomeIcon,
FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome";
import { useReduxStore } from "@redux/hooks/base";
import React, {
FunctionComponent,
useCallback,
@ -26,8 +27,7 @@ import {
Tooltip,
} from "react-bootstrap";
import { useDidUpdate, useTimeoutWhen } from "rooks";
import { useReduxStore } from "../@redux/hooks/base";
import { BuildKey, useIsArrayExtended } from "../utilities";
import { BuildKey, useIsArrayExtended } from "utilities";
import "./notification.scss";
enum State {
@ -63,7 +63,7 @@ function useHasErrorNotification(notifications: Server.Notification[]) {
}
const NotificationCenter: FunctionComponent = () => {
const { progress, notifications, notifier } = useReduxStore((s) => s.site);
const { progress, notifications, notifier } = useReduxStore((s) => s);
const dropdownRef = useRef<HTMLDivElement>(null);
const [hasNew, setHasNew] = useState(false);

@ -1,20 +1,18 @@
import Socketio from "@modules/socketio";
import { useNotification } from "@redux/hooks";
import { useReduxStore } from "@redux/hooks/base";
import { LoadingIndicator, ModalProvider } from "components";
import Authentication from "pages/Authentication";
import LaunchError from "pages/LaunchError";
import React, { FunctionComponent, useEffect } from "react";
import { Row } from "react-bootstrap";
import { Provider } from "react-redux";
import { Route, Switch } from "react-router";
import { BrowserRouter, Redirect } from "react-router-dom";
import { useEffectOnceWhen } from "rooks";
import Socketio from "../@modules/socketio";
import { useReduxStore } from "../@redux/hooks/base";
import { useNotification } from "../@redux/hooks/site";
import store from "../@redux/store";
import { LoadingIndicator, ModalProvider } from "../components";
import { Environment } from "utilities";
import ErrorBoundary from "../components/ErrorBoundary";
import Router from "../Router";
import Sidebar from "../Sidebar";
import Auth from "../special-pages/AuthPage";
import ErrorBoundary from "../special-pages/ErrorBoundary";
import LaunchError from "../special-pages/LaunchError";
import { Environment } from "../utilities";
import Header from "./Header";
// Sidebar Toggle
@ -22,7 +20,7 @@ import Header from "./Header";
interface Props {}
const App: FunctionComponent<Props> = () => {
const { initialized, auth } = useReduxStore((s) => s.site);
const { status } = useReduxStore((s) => s);
const notify = useNotification("has-update", 10 * 1000);
@ -35,21 +33,20 @@ const App: FunctionComponent<Props> = () => {
// TODO: Restart action
});
}
}, initialized === true);
}, status === "initialized");
if (!auth) {
if (status === "unauthenticated") {
return <Redirect to="/login"></Redirect>;
}
if (typeof initialized === "boolean" && initialized === false) {
} else if (status === "uninitialized") {
return (
<LoadingIndicator>
<span>Please wait</span>
</LoadingIndicator>
);
} else if (typeof initialized === "string") {
return <LaunchError>{initialized}</LaunchError>;
} else if (status === "error") {
return <LaunchError>Cannot Initialize Bazarr</LaunchError>;
}
return (
<ErrorBoundary>
<Row noGutters className="header-container">
@ -74,7 +71,7 @@ const MainRouter: FunctionComponent = () => {
<BrowserRouter basename={Environment.baseUrl}>
<Switch>
<Route exact path="/login">
<Auth></Auth>
<Authentication></Authentication>
</Route>
<Route path="/">
<App></App>
@ -84,15 +81,4 @@ const MainRouter: FunctionComponent = () => {
);
};
const Main: FunctionComponent = () => {
return (
<Provider store={store}>
{/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */}
{/* <React.StrictMode> */}
<MainRouter></MainRouter>
{/* </React.StrictMode> */}
</Provider>
);
};
export default Main;
export default MainRouter;

@ -1,39 +0,0 @@
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 { AsyncOverlay, ContentHeader } from "../../components";
import Table from "./table";
interface Props {}
const BlacklistMoviesView: FunctionComponent<Props> = () => {
const blacklist = useBlacklistMovies();
return (
<AsyncOverlay ctx={blacklist}>
{({ content }) => (
<Container fluid>
<Helmet>
<title>Movies Blacklist - Bazarr</title>
</Helmet>
<ContentHeader>
<ContentHeader.AsyncButton
icon={faTrash}
disabled={content?.length === 0}
promise={() => MoviesApi.deleteBlacklist(true)}
>
Remove All
</ContentHeader.AsyncButton>
</ContentHeader>
<Row>
<Table blacklist={content ?? []}></Table>
</Row>
</Container>
)}
</AsyncOverlay>
);
};
export default BlacklistMoviesView;

@ -1,39 +0,0 @@
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 { AsyncOverlay, ContentHeader } from "../../components";
import Table from "./table";
interface Props {}
const BlacklistSeriesView: FunctionComponent<Props> = () => {
const blacklist = useBlacklistSeries();
return (
<AsyncOverlay ctx={blacklist}>
{({ content }) => (
<Container fluid>
<Helmet>
<title>Series Blacklist - Bazarr</title>
</Helmet>
<ContentHeader>
<ContentHeader.AsyncButton
icon={faTrash}
disabled={content?.length === 0}
promise={() => EpisodesApi.deleteBlacklist(true)}
>
Remove All
</ContentHeader.AsyncButton>
</ContentHeader>
<Row>
<Table blacklist={content ?? []}></Table>
</Row>
</Container>
)}
</AsyncOverlay>
);
};
export default BlacklistSeriesView;

@ -1,177 +0,0 @@
import { faCheck, faList, faUndo } from "@fortawesome/free-solid-svg-icons";
import { AsyncThunk } from "@reduxjs/toolkit";
import { uniqBy } from "lodash";
import React, { useCallback, useMemo, useState } from "react";
import { Container, Dropdown, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Column } from "react-table";
import { useIsAnyTaskRunning } from "../../../@modules/task/hooks";
import { useLanguageProfiles } from "../../../@redux/hooks";
import { useAppDispatch } from "../../../@redux/hooks/base";
import { ContentHeader } from "../../../components";
import { GetItemId, isNonNullable } from "../../../utilities";
import Table from "./table";
export interface SharedProps<T extends Item.Base> {
name: string;
loader: (params: Parameter.Range) => void;
columns: Column<T>[];
modify: (form: FormType.ModifyItem) => Promise<void>;
state: Async.Entity<T>;
}
interface Props<T extends Item.Base = Item.Base> extends SharedProps<T> {
updateAction: AsyncThunk<AsyncDataWrapper<T>, void, {}>;
}
function BaseItemView<T extends Item.Base>({
updateAction,
...shared
}: Props<T>) {
const state = shared.state;
const [pendingEditMode, setPendingEdit] = useState(false);
const [editMode, setEdit] = useState(false);
const dispatch = useAppDispatch();
const update = useCallback(() => {
dispatch(updateAction()).then(() => {
setPendingEdit((edit) => {
// Hack to remove all dependencies
setEdit(edit);
return edit;
});
setDirty([]);
});
}, [dispatch, updateAction]);
const [selections, setSelections] = useState<T[]>([]);
const [dirtyItems, setDirty] = useState<T[]>([]);
const profiles = useLanguageProfiles();
const profileOptions = useMemo<JSX.Element[]>(() => {
const items: JSX.Element[] = [];
if (profiles) {
items.push(
<Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item>
);
items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
items.push(
...profiles.map((v) => (
<Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}>
{v.name}
</Dropdown.Item>
))
);
}
return items;
}, [profiles]);
const changeProfiles = useCallback(
(key: Nullable<string>) => {
const id = key ? parseInt(key) : null;
const newItems = selections.map((v) => {
const item = { ...v };
item.profileId = id;
return item;
});
setDirty((dirty) => {
return uniqBy([...newItems, ...dirty], GetItemId);
});
},
[selections]
);
const startEdit = useCallback(() => {
if (shared.state.content.ids.every(isNonNullable)) {
setEdit(true);
} else {
update();
}
setPendingEdit(true);
}, [shared.state.content.ids, update]);
const endEdit = useCallback(() => {
setEdit(false);
setDirty([]);
setPendingEdit(false);
setSelections([]);
}, []);
const save = useCallback(() => {
const form: FormType.ModifyItem = {
id: [],
profileid: [],
};
dirtyItems.forEach((v) => {
const id = GetItemId(v);
form.id.push(id);
form.profileid.push(v.profileId);
});
return shared.modify(form);
}, [dirtyItems, shared]);
const hasTask = useIsAnyTaskRunning();
return (
<Container fluid>
<Helmet>
<title>{shared.name} - Bazarr</title>
</Helmet>
<ContentHeader scroll={false}>
{editMode ? (
<React.Fragment>
<ContentHeader.Group pos="start">
<Dropdown onSelect={changeProfiles}>
<Dropdown.Toggle
disabled={selections.length === 0}
variant="light"
>
Change Profile
</Dropdown.Toggle>
<Dropdown.Menu>{profileOptions}</Dropdown.Menu>
</Dropdown>
</ContentHeader.Group>
<ContentHeader.Group pos="end">
<ContentHeader.Button icon={faUndo} onClick={endEdit}>
Cancel
</ContentHeader.Button>
<ContentHeader.AsyncButton
icon={faCheck}
disabled={dirtyItems.length === 0 || hasTask}
promise={save}
onSuccess={endEdit}
>
Save
</ContentHeader.AsyncButton>
</ContentHeader.Group>
</React.Fragment>
) : (
<ContentHeader.Button
updating={pendingEditMode !== editMode}
disabled={
(state.content.ids.length === 0 && state.state === "loading") ||
hasTask
}
icon={faList}
onClick={startEdit}
>
Mass Edit
</ContentHeader.Button>
)}
</ContentHeader>
<Row>
<Table
{...shared}
dirtyItems={dirtyItems}
editMode={editMode}
select={setSelections}
></Table>
</Row>
</Container>
);
}
export default BaseItemView;

@ -1,78 +0,0 @@
import { uniqBy } from "lodash";
import React, { useCallback, useMemo } from "react";
import { TableOptions, TableUpdater, useRowSelect } from "react-table";
import { SharedProps } from ".";
import {
AsyncPageTable,
ItemEditorModal,
SimpleTable,
useShowModal,
} from "../../../components";
import { TableStyleProps } from "../../../components/tables/BaseTable";
import { useCustomSelection } from "../../../components/tables/plugins";
import { GetItemId, useEntityToList } from "../../../utilities";
interface Props<T extends Item.Base> extends SharedProps<T> {
dirtyItems: readonly T[];
editMode: boolean;
select: React.Dispatch<T[]>;
}
function Table<T extends Item.Base>({
state,
dirtyItems,
modify,
editMode,
select,
columns,
loader,
name,
}: Props<T>) {
const showModal = useShowModal();
const updateRow = useCallback<TableUpdater<T>>(
(row, modalKey: string) => {
showModal(modalKey, row.original);
},
[showModal]
);
const orderList = useEntityToList(state.content);
const data = useMemo(
() => uniqBy([...dirtyItems, ...orderList], GetItemId),
[dirtyItems, orderList]
);
const options: Partial<TableOptions<T> & TableStyleProps<T>> = {
emptyText: `No ${name} Found`,
update: updateRow,
};
return (
<React.Fragment>
{editMode ? (
// TODO: Use PageTable
<SimpleTable
{...options}
columns={columns}
data={data}
onSelect={select}
isSelecting={true}
plugins={[useRowSelect, useCustomSelection]}
></SimpleTable>
) : (
<AsyncPageTable
{...options}
columns={columns}
entity={state}
loader={loader}
data={[]}
></AsyncPageTable>
)}
<ItemEditorModal modalKey="edit" submit={modify}></ItemEditorModal>
</React.Fragment>
);
}
export default Table;

@ -1,149 +0,0 @@
import { merge } from "lodash";
import React, {
FunctionComponent,
useCallback,
useEffect,
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 { useDidMount } from "rooks";
import {
HistoryApi,
ProvidersApi,
SystemApi,
useAsyncRequest,
} from "../../apis";
import {
AsyncOverlay,
AsyncSelector,
ContentHeader,
LanguageSelector,
Selector,
} from "../../components";
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, updateLanguages] = useAsyncRequest(
SystemApi.languages.bind(SystemApi)
);
const [providerList, updateProviderParam] = useAsyncRequest(
ProvidersApi.providers.bind(ProvidersApi)
);
const updateProvider = useCallback(
() => updateProviderParam(true),
[updateProviderParam]
);
useDidMount(() => {
updateLanguages(true);
});
const [timeframe, setTimeframe] = useState<History.TimeframeOptions>("month");
const [action, setAction] = useState<Nullable<History.ActionOptions>>(null);
const [lang, setLanguage] = useState<Nullable<Language.Info>>(null);
const [provider, setProvider] = useState<Nullable<System.Provider>>(null);
const [stats, update] = useAsyncRequest(HistoryApi.stats.bind(HistoryApi));
useEffect(() => {
update(timeframe, action ?? undefined, provider?.name, lang?.code2);
}, [timeframe, action, provider?.name, lang?.code2, update]);
return (
// TODO: Responsive
<Container fluid className="vh-75">
<Helmet>
<title>History Statistics - Bazarr</title>
</Helmet>
<AsyncOverlay ctx={stats}>
{({ content }) => (
<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}
update={updateProvider}
onChange={setProvider}
></AsyncSelector>
</SelectorContainer>
<SelectorContainer>
<LanguageSelector
clearable
options={languages.content ?? []}
value={lang}
onChange={setLanguage}
></LanguageSelector>
</SelectorContainer>
</ContentHeader>
<ResponsiveContainer height="100%">
<BarChart data={content ? converter(content) : []}>
<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>
)}
</AsyncOverlay>
</Container>
);
};
export default HistoryStats;

@ -1,40 +0,0 @@
import { capitalize } from "lodash";
import React from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Column } from "react-table";
import { AsyncPageTable } from "../../components";
interface Props<T extends History.Base> {
type: "movies" | "series";
state: Readonly<Async.Entity<T>>;
loader: (param: Parameter.Range) => void;
columns: Column<T>[];
}
function HistoryGenericView<T extends History.Base = History.Base>({
state,
loader,
columns,
type,
}: Props<T>) {
const typeName = capitalize(type);
return (
<Container fluid>
<Helmet>
<title>{typeName} History - Bazarr</title>
</Helmet>
<Row>
<AsyncPageTable
emptyText={`Nothing Found in ${typeName} History`}
entity={state}
loader={loader}
columns={columns}
data={[]}
></AsyncPageTable>
</Row>
</Container>
);
}
export default HistoryGenericView;

@ -1,6 +1,6 @@
import { useIsRadarrEnabled, useIsSonarrEnabled } from "@redux/hooks";
import { FunctionComponent } from "react";
import { Redirect } from "react-router-dom";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks";
const RootRedirect: FunctionComponent = () => {
const sonarr = useIsSonarrEnabled();

@ -7,42 +7,42 @@ import {
faLaptop,
faPlay,
} from "@fortawesome/free-solid-svg-icons";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "@redux/hooks";
import { useBadges } from "apis/hooks";
import EmptyPage, { RouterEmptyPath } from "pages/404";
import BlacklistMoviesView from "pages/Blacklist/Movies";
import BlacklistSeriesView from "pages/Blacklist/Series";
import Episodes from "pages/Episodes";
import MoviesHistoryView from "pages/History/Movies";
import SeriesHistoryView from "pages/History/Series";
import HistoryStats from "pages/History/Statistics";
import MovieView from "pages/Movies";
import MovieDetail from "pages/Movies/Details";
import SeriesView from "pages/Series";
import SettingsGeneralView from "pages/Settings/General";
import SettingsLanguagesView from "pages/Settings/Languages";
import SettingsNotificationsView from "pages/Settings/Notifications";
import SettingsProvidersView from "pages/Settings/Providers";
import SettingsRadarrView from "pages/Settings/Radarr";
import SettingsSchedulerView from "pages/Settings/Scheduler";
import SettingsSonarrView from "pages/Settings/Sonarr";
import SettingsSubtitlesView from "pages/Settings/Subtitles";
import SettingsUIView from "pages/Settings/UI";
import SystemLogsView from "pages/System/Logs";
import SystemProvidersView from "pages/System/Providers";
import SystemReleasesView from "pages/System/Releases";
import SystemStatusView from "pages/System/Status";
import SystemTasksView from "pages/System/Tasks";
import WantedMoviesView from "pages/Wanted/Movies";
import WantedSeriesView from "pages/Wanted/Series";
import { useMemo } from "react";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks";
import { useReduxStore } from "../@redux/hooks/base";
import BlacklistMoviesView from "../Blacklist/Movies";
import BlacklistSeriesView from "../Blacklist/Series";
import Episodes from "../DisplayItem/Episodes";
import MovieDetail from "../DisplayItem/MovieDetail";
import MovieView from "../DisplayItem/Movies";
import SeriesView from "../DisplayItem/Series";
import MoviesHistoryView from "../History/Movies";
import SeriesHistoryView from "../History/Series";
import HistoryStats from "../History/Statistics";
import SettingsGeneralView from "../Settings/General";
import SettingsLanguagesView from "../Settings/Languages";
import SettingsNotificationsView from "../Settings/Notifications";
import SettingsProvidersView from "../Settings/Providers";
import SettingsRadarrView from "../Settings/Radarr";
import SettingsSchedulerView from "../Settings/Scheduler";
import SettingsSonarrView from "../Settings/Sonarr";
import SettingsSubtitlesView from "../Settings/Subtitles";
import SettingsUIView from "../Settings/UI";
import EmptyPage, { RouterEmptyPath } from "../special-pages/404";
import SystemLogsView from "../System/Logs";
import SystemProvidersView from "../System/Providers";
import SystemReleasesView from "../System/Releases";
import SystemStatusView from "../System/Status";
import SystemTasksView from "../System/Tasks";
import WantedMoviesView from "../Wanted/Movies";
import WantedSeriesView from "../Wanted/Series";
import { Navigation } from "./nav";
import RootRedirect from "./RootRedirect";
export function useNavigationItems() {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
const { movies, episodes, providers } = useReduxStore((s) => s.site.badges);
const { data } = useBadges();
const items = useMemo<Navigation.RouteItem[]>(
() => [
@ -139,14 +139,14 @@ export function useNavigationItems() {
{
name: "Series",
path: "/series",
badge: episodes,
badge: data?.episodes,
enabled: sonarr,
component: WantedSeriesView,
},
{
name: "Movies",
path: "/movies",
badge: movies,
badge: data?.movies,
enabled: radarr,
component: WantedMoviesView,
},
@ -222,7 +222,7 @@ export function useNavigationItems() {
{
name: "Providers",
path: "/providers",
badge: providers,
badge: data?.providers,
component: SystemProvidersView,
},
{
@ -238,7 +238,7 @@ export function useNavigationItems() {
],
},
],
[episodes, movies, providers, radarr, sonarr]
[data, radarr, sonarr]
);
return items;

@ -1,10 +1,10 @@
import { FunctionComponent } from "react";
import { Redirect, Route, Switch, useHistory } from "react-router";
import { useDidMount } from "rooks";
import { BuildKey, ScrollToTop } from "utilities";
import { useNavigationItems } from "../Navigation";
import { Navigation } from "../Navigation/nav";
import { RouterEmptyPath } from "../special-pages/404";
import { BuildKey, ScrollToTop } from "../utilities";
import { RouterEmptyPath } from "../pages/404";
const Router: FunctionComponent = () => {
const navItems = useNavigationItems();

@ -1,5 +1,8 @@
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { setSidebar } from "@redux/actions";
import { useReduxAction, useReduxStore } from "@redux/hooks/base";
import logo from "@static/logo64.png";
import React, {
createContext,
FunctionComponent,
@ -16,13 +19,10 @@ import {
ListGroupItem,
} from "react-bootstrap";
import { NavLink, useHistory, useRouteMatch } from "react-router-dom";
import { siteChangeSidebarVisibility } from "../@redux/actions";
import { useReduxAction, useReduxStore } from "../@redux/hooks/base";
import logo from "../@static/logo64.png";
import { BuildKey } from "utilities";
import { useGotoHomepage } from "utilities/hooks";
import { useNavigationItems } from "../Navigation";
import { Navigation } from "../Navigation/nav";
import { BuildKey } from "../utilities";
import { useGotoHomepage } from "../utilities/hooks";
import "./style.scss";
const SelectionContext = createContext<{
@ -31,9 +31,9 @@ const SelectionContext = createContext<{
}>({ selection: null, select: () => {} });
const Sidebar: FunctionComponent = () => {
const open = useReduxStore((s) => s.site.showSidebar);
const open = useReduxStore((s) => s.showSidebar);
const changeSidebar = useReduxAction(siteChangeSidebarVisibility);
const changeSidebar = useReduxAction(setSidebar);
const cls = ["sidebar-container"];
const overlay = ["sidebar-overlay"];
@ -120,7 +120,7 @@ const SidebarParent: FunctionComponent<Navigation.RouteWithChild> = ({
[routes]
);
const changeSidebar = useReduxAction(siteChangeSidebarVisibility);
const changeSidebar = useReduxAction(setSidebar);
const { selection, select } = useContext(SelectionContext);
@ -201,7 +201,7 @@ interface SidebarChildProps {
const SidebarChild: FunctionComponent<
SidebarChildProps & Navigation.RouteWithoutChild
> = ({ icon, name, path, badge, enabled, routeOnly, parent }) => {
const changeSidebar = useReduxAction(siteChangeSidebarVisibility);
const changeSidebar = useReduxAction(setSidebar);
const { select } = useContext(SelectionContext);
if (enabled === false || routeOnly === true) {

@ -1,66 +0,0 @@
import { faDownload, faSync, faTrash } 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 { systemUpdateLogs } from "../../@redux/actions";
import { useSystemLogs } from "../../@redux/hooks";
import { useReduxAction } from "../../@redux/hooks/base";
import { SystemApi } from "../../apis";
import { AsyncOverlay, ContentHeader } from "../../components";
import { Environment } from "../../utilities";
import Table from "./table";
interface Props {}
const SystemLogsView: FunctionComponent<Props> = () => {
const logs = useSystemLogs();
const update = useReduxAction(systemUpdateLogs);
const [resetting, setReset] = useState(false);
const download = useCallback(() => {
window.open(`${Environment.baseUrl}/bazarr.log`);
}, []);
return (
<AsyncOverlay ctx={logs}>
{({ content, state }) => (
<Container fluid>
<Helmet>
<title>Logs - Bazarr (System)</title>
</Helmet>
<ContentHeader>
<ContentHeader.Button
updating={state === "loading"}
icon={faSync}
onClick={update}
>
Refresh
</ContentHeader.Button>
<ContentHeader.Button icon={faDownload} onClick={download}>
Download
</ContentHeader.Button>
<ContentHeader.Button
updating={resetting}
icon={faTrash}
onClick={() => {
setReset(true);
SystemApi.deleteLogs().finally(() => {
setReset(false);
update();
});
}}
>
Empty
</ContentHeader.Button>
</ContentHeader>
<Row>
<Table logs={content ?? []}></Table>
</Row>
</Container>
)}
</AsyncOverlay>
);
};
export default SystemLogsView;

@ -1,50 +0,0 @@
import { faSync, faTrash } from "@fortawesome/free-solid-svg-icons";
import React, { FunctionComponent } from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { providerUpdateList } from "../../@redux/actions";
import { useSystemProviders } from "../../@redux/hooks";
import { useReduxAction } from "../../@redux/hooks/base";
import { ProvidersApi } from "../../apis";
import { AsyncOverlay, ContentHeader } from "../../components";
import Table from "./table";
interface Props {}
const SystemProvidersView: FunctionComponent<Props> = () => {
const providers = useSystemProviders();
const update = useReduxAction(providerUpdateList);
return (
<AsyncOverlay ctx={providers}>
{({ content, state }) => (
<Container fluid>
<Helmet>
<title>Providers - Bazarr (System)</title>
</Helmet>
<ContentHeader>
<ContentHeader.Button
updating={state === "loading"}
icon={faSync}
onClick={update}
>
Refresh
</ContentHeader.Button>
<ContentHeader.AsyncButton
icon={faTrash}
promise={() => ProvidersApi.reset()}
onSuccess={update}
>
Reset
</ContentHeader.AsyncButton>
</ContentHeader>
<Row>
<Table providers={content ?? []}></Table>
</Row>
</Container>
)}
</AsyncOverlay>
);
};
export default SystemProvidersView;

@ -1,42 +0,0 @@
import { faSync } from "@fortawesome/free-solid-svg-icons";
import React, { FunctionComponent } from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { systemMarkTasksDirty } from "../../@redux/actions";
import { useSystemTasks } from "../../@redux/hooks";
import { useReduxAction } from "../../@redux/hooks/base";
import { AsyncOverlay, ContentHeader } from "../../components";
import Table from "./table";
interface Props {}
const SystemTasksView: FunctionComponent<Props> = () => {
const tasks = useSystemTasks();
const update = useReduxAction(systemMarkTasksDirty);
return (
<AsyncOverlay ctx={tasks}>
{({ content, state }) => (
<Container fluid>
<Helmet>
<title>Tasks - Bazarr (System)</title>
</Helmet>
<ContentHeader>
<ContentHeader.Button
updating={state === "loading"}
icon={faSync}
onClick={update}
>
Refresh
</ContentHeader.Button>
</ContentHeader>
<Row>
<Table tasks={content ?? []}></Table>
</Row>
</Container>
)}
</AsyncOverlay>
);
};
export default SystemTasksView;

@ -1,65 +0,0 @@
import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { capitalize } from "lodash";
import React from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Column } from "react-table";
import { dispatchTask } from "../../@modules/task";
import { useIsGroupTaskRunning } from "../../@modules/task/hooks";
import { createTask } from "../../@modules/task/utilities";
import { AsyncPageTable, ContentHeader } from "../../components";
interface Props<T extends Wanted.Base> {
type: "movies" | "series";
columns: Column<T>[];
state: Async.Entity<T>;
loader: (params: Parameter.Range) => void;
searchAll: () => Promise<void>;
}
const TaskGroupName = "Searching wanted subtitles...";
function GenericWantedView<T extends Wanted.Base>({
type,
columns,
state,
loader,
searchAll,
}: Props<T>) {
const typeName = capitalize(type);
const dataCount = Object.keys(state.content.entities).length;
const hasTask = useIsGroupTaskRunning(TaskGroupName);
return (
<Container fluid>
<Helmet>
<title>Wanted {typeName} - Bazarr</title>
</Helmet>
<ContentHeader>
<ContentHeader.Button
disabled={dataCount === 0 || hasTask}
onClick={() => {
const task = createTask(type, undefined, searchAll);
dispatchTask(TaskGroupName, [task], "Searching...");
}}
icon={faSearch}
>
Search All
</ContentHeader.Button>
</ContentHeader>
<Row>
<AsyncPageTable
entity={state}
loader={loader}
emptyText={`No Missing ${typeName} Subtitles`}
columns={columns}
data={[]}
></AsyncPageTable>
</Row>
</Container>
);
}
export default GenericWantedView;

@ -1,9 +1,9 @@
import { Entrance } from "index";
import {} from "jest";
import ReactDOM from "react-dom";
import App from "../App";
it("renders", () => {
const div = document.createElement("div");
ReactDOM.render(<App />, div);
ReactDOM.render(<Entrance />, div);
ReactDOM.unmountComponentAtNode(div);
});

@ -1,31 +0,0 @@
import { useCallback, useRef, useState } from "react";
type Request = (...args: any[]) => Promise<any>;
type Return<T extends Request> = PromiseType<ReturnType<T>>;
export function useAsyncRequest<F extends Request>(
request: F
): [Async.Item<Return<F>>, (...args: Parameters<F>) => void] {
const [state, setState] = useState<Async.Item<Return<F>>>({
state: "uninitialized",
content: null,
error: null,
});
const requestRef = useRef(request);
const update = useCallback(
(...args: Parameters<F>) => {
setState((s) => ({ ...s, state: "loading" }));
requestRef
.current(...args)
.then((res) =>
setState({ state: "succeeded", content: res, error: null })
)
.catch((error) => setState((s) => ({ ...s, state: "failed", error })));
},
[requestRef]
);
return [state, update];
}

@ -0,0 +1,115 @@
import {
QueryClient,
useMutation,
useQuery,
useQueryClient,
} from "react-query";
import { usePaginationQuery } from "../queries/hooks";
import { QueryKeys } from "../queries/keys";
import api from "../raw";
const cacheEpisodes = (client: QueryClient, episodes: Item.Episode[]) => {
episodes.forEach((item) => {
client.setQueryData(
[
QueryKeys.Series,
item.sonarrSeriesId,
QueryKeys.Episodes,
item.sonarrEpisodeId,
],
item
);
});
};
export function useEpisodesByIds(ids: number[]) {
const client = useQueryClient();
return useQuery(
[QueryKeys.Series, QueryKeys.Episodes, ids],
() => api.episodes.byEpisodeId(ids),
{
onSuccess: (data) => {
cacheEpisodes(client, data);
},
}
);
}
export function useEpisodesBySeriesId(id: number) {
const client = useQueryClient();
return useQuery(
[QueryKeys.Series, id, QueryKeys.Episodes, QueryKeys.All],
() => api.episodes.bySeriesId([id]),
{
onSuccess: (data) => {
cacheEpisodes(client, data);
},
}
);
}
export function useEpisodeWantedPagination() {
return usePaginationQuery([QueryKeys.Series, QueryKeys.Wanted], (param) =>
api.episodes.wanted(param)
);
}
export function useEpisodeBlacklist() {
return useQuery(
[QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
() => api.episodes.blacklist()
);
}
export function useEpisodeAddBlacklist() {
const client = useQueryClient();
return useMutation(
[QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
(param: {
seriesId: number;
episodeId: number;
form: FormType.AddBlacklist;
}) => {
const { seriesId, episodeId, form } = param;
return api.episodes.addBlacklist(seriesId, episodeId, form);
},
{
onSuccess: (_, { seriesId, episodeId }) => {
client.invalidateQueries([QueryKeys.Series, QueryKeys.Blacklist]);
client.invalidateQueries([QueryKeys.Series, seriesId]);
},
}
);
}
export function useEpisodeDeleteBlacklist() {
const client = useQueryClient();
return useMutation(
[QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
(param: { all?: boolean; form?: FormType.DeleteBlacklist }) =>
api.episodes.deleteBlacklist(param.all, param.form),
{
onSuccess: (_, param) => {
client.invalidateQueries([QueryKeys.Series, QueryKeys.Blacklist]);
},
}
);
}
export function useEpisodeHistoryPagination() {
return usePaginationQuery(
[QueryKeys.Series, QueryKeys.Episodes, QueryKeys.History],
(param) => api.episodes.history(param)
);
}
export function useEpisodeHistory(episodeId?: number) {
return useQuery(
[QueryKeys.Series, QueryKeys.Episodes, QueryKeys.History, episodeId],
() => {
if (episodeId) {
return api.episodes.historyBy(episodeId);
}
}
);
}

@ -0,0 +1,21 @@
import { useQuery } from "react-query";
import { QueryKeys } from "../queries/keys";
import api from "../raw";
export function useHistoryStats(
time: History.TimeFrameOptions,
action: History.ActionOptions | null,
provider: System.Provider | null,
language: Language.Info | null
) {
return useQuery(
[QueryKeys.System, QueryKeys.History, { time, action, provider, language }],
() =>
api.history.stats(
time,
action ?? undefined,
provider?.name,
language?.code2
)
);
}

@ -0,0 +1,9 @@
export * from "./episodes";
export * from "./histories";
export * from "./languages";
export * from "./movies";
export * from "./providers";
export * from "./series";
export * from "./status";
export * from "./subtitles";
export * from "./system";

@ -0,0 +1,23 @@
import { useQuery } from "react-query";
import { QueryKeys } from "../queries/keys";
import api from "../raw";
export function useLanguages(history?: boolean) {
return useQuery(
[QueryKeys.System, QueryKeys.Languages, history ?? false],
() => api.system.languages(history),
{
staleTime: Infinity,
}
);
}
export function useLanguageProfiles() {
return useQuery(
[QueryKeys.System, QueryKeys.LanguagesProfiles],
() => api.system.languagesProfileList(),
{
staleTime: Infinity,
}
);
}

@ -0,0 +1,138 @@
import {
QueryClient,
useMutation,
useQuery,
useQueryClient,
} from "react-query";
import { usePaginationQuery } from "../queries/hooks";
import { QueryKeys } from "../queries/keys";
import api from "../raw";
const cacheMovies = (client: QueryClient, movies: Item.Movie[]) => {
movies.forEach((item) => {
client.setQueryData([QueryKeys.Movies, item.radarrId], item);
});
};
export function useMoviesByIds(ids: number[]) {
const client = useQueryClient();
return useQuery([QueryKeys.Movies, ...ids], () => api.movies.movies(ids), {
onSuccess: (data) => {
cacheMovies(client, data);
},
});
}
export function useMovieById(id: number) {
return useQuery([QueryKeys.Movies, id], async () => {
const response = await api.movies.movies([id]);
return response.length > 0 ? response[0] : undefined;
});
}
export function useMovies() {
const client = useQueryClient();
return useQuery(
[QueryKeys.Movies, QueryKeys.All],
() => api.movies.movies(),
{
enabled: false,
onSuccess: (data) => {
cacheMovies(client, data);
},
}
);
}
export function useMoviesPagination() {
return usePaginationQuery([QueryKeys.Movies], (param) =>
api.movies.moviesBy(param)
);
}
export function useMovieModification() {
const client = useQueryClient();
return useMutation(
[QueryKeys.Movies],
(form: FormType.ModifyItem) => api.movies.modify(form),
{
onSuccess: (_, form) => {
form.id.forEach((v) => {
client.invalidateQueries([QueryKeys.Movies, v]);
});
// TODO: query less
client.invalidateQueries([QueryKeys.Movies]);
},
}
);
}
export function useMovieAction() {
const client = useQueryClient();
return useMutation(
[QueryKeys.Actions, QueryKeys.Movies],
(form: FormType.MoviesAction) => api.movies.action(form),
{
onSuccess: () => {
client.invalidateQueries([QueryKeys.Movies]);
},
}
);
}
export function useMovieWantedPagination() {
return usePaginationQuery([QueryKeys.Movies, QueryKeys.Wanted], (param) =>
api.movies.wanted(param)
);
}
export function useMovieBlacklist() {
return useQuery([QueryKeys.Movies, QueryKeys.Blacklist], () =>
api.movies.blacklist()
);
}
export function useMovieAddBlacklist() {
const client = useQueryClient();
return useMutation(
[QueryKeys.Movies, QueryKeys.Blacklist],
(param: { id: number; form: FormType.AddBlacklist }) => {
const { id, form } = param;
return api.movies.addBlacklist(id, form);
},
{
onSuccess: (_, { id }) => {
client.invalidateQueries([QueryKeys.Movies, QueryKeys.Blacklist]);
client.invalidateQueries([QueryKeys.Movies, id]);
},
}
);
}
export function useMovieDeleteBlacklist() {
const client = useQueryClient();
return useMutation(
[QueryKeys.Movies, QueryKeys.Blacklist],
(param: { all?: boolean; form?: FormType.DeleteBlacklist }) =>
api.movies.deleteBlacklist(param.all, param.form),
{
onSuccess: (_, param) => {
client.invalidateQueries([QueryKeys.Movies, QueryKeys.Blacklist]);
},
}
);
}
export function useMovieHistoryPagination() {
return usePaginationQuery([QueryKeys.Movies, QueryKeys.History], (param) =>
api.movies.history(param)
);
}
export function useMovieHistory(radarrId?: number) {
return useQuery([QueryKeys.Movies, QueryKeys.History, radarrId], () => {
if (radarrId) {
return api.movies.historyBy(radarrId);
}
});
}

@ -0,0 +1,99 @@
import { useMutation, useQuery, useQueryClient } from "react-query";
import { QueryKeys } from "../queries/keys";
import api from "../raw";
export function useSystemProviders(history?: boolean) {
return useQuery(
[QueryKeys.System, QueryKeys.Providers, history ?? false],
() => api.providers.providers(history)
);
}
export function useMoviesProvider(radarrId?: number) {
return useQuery(
[QueryKeys.System, QueryKeys.Providers, QueryKeys.Movies, radarrId],
() => {
if (radarrId) {
return api.providers.movies(radarrId);
}
},
{
staleTime: Infinity,
}
);
}
export function useEpisodesProvider(episodeId?: number) {
return useQuery(
[QueryKeys.System, QueryKeys.Providers, QueryKeys.Episodes, episodeId],
() => {
if (episodeId) {
return api.providers.episodes(episodeId);
}
},
{
staleTime: Infinity,
}
);
}
export function useResetProvider() {
const client = useQueryClient();
return useMutation(
[QueryKeys.System, QueryKeys.Providers],
() => api.providers.reset(),
{
onSuccess: () => {
client.invalidateQueries([QueryKeys.System, QueryKeys.Providers]);
},
}
);
}
export function useDownloadEpisodeSubtitles() {
const client = useQueryClient();
return useMutation(
[
QueryKeys.System,
QueryKeys.Providers,
QueryKeys.Subtitles,
QueryKeys.Episodes,
],
(param: {
seriesId: number;
episodeId: number;
form: FormType.ManualDownload;
}) =>
api.providers.downloadEpisodeSubtitle(
param.seriesId,
param.episodeId,
param.form
),
{
onSuccess: (_, param) => {
client.invalidateQueries([QueryKeys.Series, param.seriesId]);
},
}
);
}
export function useDownloadMovieSubtitles() {
const client = useQueryClient();
return useMutation(
[
QueryKeys.System,
QueryKeys.Providers,
QueryKeys.Subtitles,
QueryKeys.Movies,
],
(param: { radarrId: number; form: FormType.ManualDownload }) =>
api.providers.downloadMovieSubtitle(param.radarrId, param.form),
{
onSuccess: (_, param) => {
client.invalidateQueries([QueryKeys.Movies, param.radarrId]);
},
}
);
}

@ -0,0 +1,80 @@
import {
QueryClient,
useMutation,
useQuery,
useQueryClient,
} from "react-query";
import { usePaginationQuery } from "../queries/hooks";
import { QueryKeys } from "../queries/keys";
import api from "../raw";
function cacheSeries(client: QueryClient, series: Item.Series[]) {
series.forEach((item) => {
client.setQueryData([QueryKeys.Series, item.sonarrSeriesId], item);
});
}
export function useSeriesByIds(ids: number[]) {
const client = useQueryClient();
return useQuery([QueryKeys.Series, ...ids], () => api.series.series(ids), {
onSuccess: (data) => {
cacheSeries(client, data);
},
});
}
export function useSeriesById(id: number) {
return useQuery([QueryKeys.Series, id], async () => {
const response = await api.series.series([id]);
return response.length > 0 ? response[0] : undefined;
});
}
export function useSeries() {
const client = useQueryClient();
return useQuery(
[QueryKeys.Series, QueryKeys.All],
() => api.series.series(),
{
enabled: false,
onSuccess: (data) => {
cacheSeries(client, data);
},
}
);
}
export function useSeriesPagination() {
return usePaginationQuery([QueryKeys.Series], (param) =>
api.series.seriesBy(param)
);
}
export function useSeriesModification() {
const client = useQueryClient();
return useMutation(
[QueryKeys.Series],
(form: FormType.ModifyItem) => api.series.modify(form),
{
onSuccess: (_, form) => {
form.id.forEach((v) => {
client.invalidateQueries([QueryKeys.Series, v]);
});
client.invalidateQueries([QueryKeys.Series]);
},
}
);
}
export function useSeriesAction() {
const client = useQueryClient();
return useMutation(
[QueryKeys.Actions, QueryKeys.Series],
(form: FormType.SeriesAction) => api.series.action(form),
{
onSuccess: () => {
client.invalidateQueries([QueryKeys.Series]);
},
}
);
}

@ -0,0 +1,18 @@
import { useIsMutating } from "react-query";
import { QueryKeys } from "../queries/keys";
export function useIsAnyActionRunning() {
return useIsMutating([QueryKeys.Actions]) > 0;
}
export function useIsMovieActionRunning() {
return useIsMutating([QueryKeys.Actions, QueryKeys.Movies]) > 0;
}
export function useIsSeriesActionRunning() {
return useIsMutating([QueryKeys.Actions, QueryKeys.Series]) > 0;
}
export function useIsAnyMutationRunning() {
return useIsMutating() > 0;
}

@ -0,0 +1,119 @@
import { useMutation, useQuery, useQueryClient } from "react-query";
import { QueryKeys } from "../queries/keys";
import api from "../raw";
export function useSubtitleAction() {
const client = useQueryClient();
interface Param {
action: string;
form: FormType.ModifySubtitle;
}
return useMutation(
[QueryKeys.Subtitles],
(param: Param) => api.subtitles.modify(param.action, param.form),
{
onSuccess: () => {
client.invalidateQueries([QueryKeys.History]);
},
}
);
}
export function useEpisodeSubtitleModification() {
const client = useQueryClient();
interface Param<T> {
seriesId: number;
episodeId: number;
form: T;
}
const download = useMutation(
[QueryKeys.Subtitles, QueryKeys.Episodes],
(param: Param<FormType.Subtitle>) =>
api.episodes.downloadSubtitles(
param.seriesId,
param.episodeId,
param.form
),
{
onSuccess: (_, param) => {
client.invalidateQueries([QueryKeys.Series, param.seriesId]);
},
}
);
const remove = useMutation(
[QueryKeys.Subtitles, QueryKeys.Episodes],
(param: Param<FormType.DeleteSubtitle>) =>
api.episodes.deleteSubtitles(param.seriesId, param.episodeId, param.form),
{
onSuccess: (_, param) => {
client.invalidateQueries([QueryKeys.Series, param.seriesId]);
},
}
);
const upload = useMutation(
[QueryKeys.Subtitles, QueryKeys.Episodes],
(param: Param<FormType.UploadSubtitle>) =>
api.episodes.uploadSubtitles(param.seriesId, param.episodeId, param.form),
{
onSuccess: (_, { seriesId }) => {
client.invalidateQueries([QueryKeys.Series, seriesId]);
},
}
);
return { download, remove, upload };
}
export function useMovieSubtitleModification() {
const client = useQueryClient();
interface Param<T> {
radarrId: number;
form: T;
}
const download = useMutation(
[QueryKeys.Subtitles, QueryKeys.Movies],
(param: Param<FormType.Subtitle>) =>
api.movies.downloadSubtitles(param.radarrId, param.form),
{
onSuccess: (_, param) => {
client.invalidateQueries([QueryKeys.Movies, param.radarrId]);
},
}
);
const remove = useMutation(
[QueryKeys.Subtitles, QueryKeys.Movies],
(param: Param<FormType.DeleteSubtitle>) =>
api.movies.deleteSubtitles(param.radarrId, param.form),
{
onSuccess: (_, param) => {
client.invalidateQueries([QueryKeys.Movies, param.radarrId]);
},
}
);
const upload = useMutation(
[QueryKeys.Subtitles, QueryKeys.Movies],
(param: Param<FormType.UploadSubtitle>) =>
api.movies.uploadSubtitles(param.radarrId, param.form),
{
onSuccess: (_, { radarrId }) => {
client.invalidateQueries([QueryKeys.Movies, radarrId]);
},
}
);
return { download, remove, upload };
}
export function useSubtitleInfos(names: string[]) {
return useQuery([QueryKeys.Subtitles, QueryKeys.Infos, names], () =>
api.subtitles.info(names)
);
}

@ -0,0 +1,188 @@
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { setUnauthenticated } from "../../@redux/actions";
import store from "../../@redux/store";
import { QueryKeys } from "../queries/keys";
import api from "../raw";
export function useBadges() {
return useQuery([QueryKeys.System, QueryKeys.Badges], () => api.badges.all());
}
export function useFileSystem(
type: "bazarr" | "sonarr" | "radarr",
path: string,
enabled: boolean
) {
return useQuery(
[QueryKeys.FileSystem, type, path],
() => {
if (type === "bazarr") {
return api.files.bazarr(path);
} else if (type === "radarr") {
return api.files.radarr(path);
} else if (type === "sonarr") {
return api.files.sonarr(path);
}
},
{
enabled,
}
);
}
export function useSystemSettings() {
return useQuery(
[QueryKeys.System, QueryKeys.Settings],
() => api.system.settings(),
{
staleTime: Infinity,
}
);
}
export function useSettingsMutation() {
const client = useQueryClient();
return useMutation(
[QueryKeys.System, QueryKeys.Settings],
(data: LooseObject) => api.system.updateSettings(data),
{
onSuccess: () => {
client.invalidateQueries([QueryKeys.System]);
},
}
);
}
export function useServerSearch(query: string, enabled: boolean) {
return useQuery(
[QueryKeys.System, QueryKeys.Search, query],
() => api.system.search(query),
{
enabled,
}
);
}
export function useSystemLogs() {
return useQuery([QueryKeys.System, QueryKeys.Logs], () => api.system.logs(), {
refetchOnWindowFocus: "always",
refetchInterval: 1000 * 60,
staleTime: 1000,
});
}
export function useDeleteLogs() {
const client = useQueryClient();
return useMutation(
[QueryKeys.System, QueryKeys.Logs],
() => api.system.deleteLogs(),
{
onSuccess: () => {
client.invalidateQueries([QueryKeys.System, QueryKeys.Logs]);
},
}
);
}
export function useSystemTasks() {
return useQuery(
[QueryKeys.System, QueryKeys.Tasks],
() => api.system.tasks(),
{
refetchOnWindowFocus: "always",
refetchInterval: 1000 * 60,
staleTime: 1000 * 10,
}
);
}
export function useRunTask() {
const client = useQueryClient();
return useMutation(
[QueryKeys.System, QueryKeys.Tasks],
(id: string) => api.system.runTask(id),
{
onSuccess: () => {
client.invalidateQueries([QueryKeys.System, QueryKeys.Tasks]);
},
}
);
}
export function useSystemStatus() {
return useQuery([QueryKeys.System, "status"], () => api.system.status());
}
export function useSystemHealth() {
return useQuery([QueryKeys.System, "health"], () => api.system.health());
}
export function useSystemReleases() {
return useQuery([QueryKeys.System, "releases"], () => api.system.releases());
}
export function useSystem() {
const client = useQueryClient();
const { mutate: logout, isLoading: isLoggingOut } = useMutation(
[QueryKeys.System, QueryKeys.Actions],
() => api.system.logout(),
{
onSuccess: () => {
store.dispatch(setUnauthenticated());
client.clear();
},
}
);
const { mutate: login, isLoading: isLoggingIn } = useMutation(
[QueryKeys.System, QueryKeys.Actions],
(param: { username: string; password: string }) =>
api.system.login(param.username, param.password),
{
onSuccess: () => {
window.location.reload();
},
}
);
const { mutate: shutdown, isLoading: isShuttingDown } = useMutation(
[QueryKeys.System, QueryKeys.Actions],
() => api.system.shutdown(),
{
onSuccess: () => {
client.clear();
},
}
);
const { mutate: restart, isLoading: isRestarting } = useMutation(
[QueryKeys.System, QueryKeys.Actions],
() => api.system.restart(),
{
onSuccess: () => {
client.clear();
},
}
);
return useMemo(
() => ({
logout,
shutdown,
restart,
login,
isWorking: isLoggingOut || isShuttingDown || isRestarting || isLoggingIn,
}),
[
isLoggingIn,
isLoggingOut,
isRestarting,
isShuttingDown,
login,
logout,
restart,
shutdown,
]
);
}

@ -1,8 +1,8 @@
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
import { siteRedirectToAuth } from "../@redux/actions";
import { AppDispatch } from "../@redux/store";
import { Environment, isProdEnv } from "../utilities";
class Api {
import { setUnauthenticated } from "../../@redux/actions";
import { AppDispatch } from "../../@redux/store";
import { Environment, isProdEnv } from "../../utilities";
class BazarrClient {
axios!: AxiosInstance;
source!: CancelTokenSource;
dispatch!: AppDispatch;
@ -57,7 +57,7 @@ class Api {
handleError(code: number) {
switch (code) {
case 401:
this.dispatch(siteRedirectToAuth());
this.dispatch(setUnauthenticated());
break;
case 500:
break;
@ -67,15 +67,4 @@ class Api {
}
}
export default new Api();
export { default as BadgesApi } from "./badges";
export { default as EpisodesApi } from "./episodes";
export { default as FilesApi } from "./files";
export { default as HistoryApi } from "./history";
export * from "./hooks";
export { default as MoviesApi } from "./movies";
export { default as ProvidersApi } from "./providers";
export { default as SeriesApi } from "./series";
export { default as SubtitlesApi } from "./subtitles";
export { default as SystemApi } from "./system";
export { default as UtilsApi } from "./utils";
export default new BazarrClient();

@ -0,0 +1,116 @@
import { useCallback, useEffect, useState } from "react";
import {
QueryKey,
useQuery,
useQueryClient,
UseQueryResult,
} from "react-query";
import { GetItemId } from "utilities";
import { usePageSize } from "utilities/storage";
import { QueryKeys } from "./keys";
export type UsePaginationQueryResult<T extends object> = UseQueryResult<
DataWrapperWithTotal<T>
> & {
controls: {
previousPage: () => void;
nextPage: () => void;
gotoPage: (index: number) => void;
};
paginationStatus: {
totalCount: number;
pageSize: number;
pageCount: number;
page: number;
canPrevious: boolean;
canNext: boolean;
};
};
export function usePaginationQuery<
TObject extends object = object,
TQueryKey extends QueryKey = QueryKey
>(
queryKey: TQueryKey,
queryFn: RangeQuery<TObject>
): UsePaginationQueryResult<TObject> {
const client = useQueryClient();
const [page, setIndex] = useState(0);
const [pageSize] = usePageSize();
const start = page * pageSize;
const results = useQuery(
[...queryKey, QueryKeys.Range, { start, size: pageSize }],
() => {
const param: Parameter.Range = {
start,
length: pageSize,
};
return queryFn(param);
},
{
onSuccess: ({ data }) => {
data.forEach((item) => {
const id = GetItemId(item);
if (id) {
client.setQueryData([...queryKey, id], item);
}
});
},
}
);
const { data } = results;
const totalCount = data?.total ?? 0;
const pageCount = Math.ceil(totalCount / pageSize);
const previousPage = useCallback(() => {
setIndex((index) => Math.max(0, index - 1));
}, []);
const nextPage = useCallback(() => {
if (pageCount > 0) {
setIndex((index) => Math.min(pageCount - 1, index + 1));
}
}, [pageCount]);
const gotoPage = useCallback(
(idx: number) => {
if (idx >= 0 && idx < pageCount) {
setIndex(idx);
}
},
[pageCount]
);
// Reset page index if we out of bound
useEffect(() => {
if (pageCount === 0) return;
if (page >= pageCount) {
setIndex(pageCount - 1);
} else if (page < 0) {
setIndex(0);
}
}, [page, pageCount]);
return {
...results,
paginationStatus: {
totalCount,
pageCount,
pageSize,
page,
canPrevious: page > 0,
canNext: page < pageCount - 1,
},
controls: {
gotoPage,
previousPage,
nextPage,
},
};
}

@ -0,0 +1,14 @@
import { QueryClient } from "react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
staleTime: 1000 * 60,
keepPreviousData: true,
},
},
});
export default queryClient;

@ -0,0 +1,23 @@
export enum QueryKeys {
Movies = "movies",
Episodes = "episodes",
Series = "series",
Badges = "badges",
FileSystem = "file-system",
System = "system",
Settings = "settings",
Subtitles = "subtitles",
Providers = "providers",
Languages = "languages",
LanguagesProfiles = "languages-profiles",
Blacklist = "blacklist",
Search = "search",
Actions = "actions",
Tasks = "tasks",
Logs = "logs",
Infos = "infos",
History = "history",
Wanted = "wanted",
Range = "range",
All = "all",
}

@ -1,5 +1,5 @@
import { AxiosResponse } from "axios";
import apis from ".";
import client from "../queries/client";
class BaseApi {
prefix: string;
@ -31,7 +31,7 @@ class BaseApi {
}
protected async get<T = unknown>(path: string, params?: any) {
const response = await apis.axios.get<T>(this.prefix + path, { params });
const response = await client.axios.get<T>(this.prefix + path, { params });
return response.data;
}
@ -41,7 +41,7 @@ class BaseApi {
params?: any
): Promise<AxiosResponse<T>> {
const form = this.createFormdata(formdata);
return apis.axios.post(this.prefix + path, form, { params });
return client.axios.post(this.prefix + path, form, { params });
}
protected patch<T = void>(
@ -50,7 +50,7 @@ class BaseApi {
params?: any
): Promise<AxiosResponse<T>> {
const form = this.createFormdata(formdata);
return apis.axios.patch(this.prefix + path, form, { params });
return client.axios.patch(this.prefix + path, form, { params });
}
protected delete<T = void>(
@ -59,7 +59,7 @@ class BaseApi {
params?: any
): Promise<AxiosResponse<T>> {
const form = this.createFormdata(formdata);
return apis.axios.delete(this.prefix + path, { params, data: form });
return client.axios.delete(this.prefix + path, { params, data: form });
}
}

@ -20,7 +20,7 @@ class EpisodeApi extends BaseApi {
}
async wanted(params: Parameter.Range) {
const response = await this.get<AsyncDataWrapper<Wanted.Episode>>(
const response = await this.get<DataWrapperWithTotal<Wanted.Episode>>(
"/wanted",
params
);
@ -28,7 +28,7 @@ class EpisodeApi extends BaseApi {
}
async wantedBy(episodeid: number[]) {
const response = await this.get<AsyncDataWrapper<Wanted.Episode>>(
const response = await this.get<DataWrapperWithTotal<Wanted.Episode>>(
"/wanted",
{ episodeid }
);
@ -36,7 +36,7 @@ class EpisodeApi extends BaseApi {
}
async history(params: Parameter.Range) {
const response = await this.get<AsyncDataWrapper<History.Episode>>(
const response = await this.get<DataWrapperWithTotal<History.Episode>>(
"/history",
params
);
@ -44,11 +44,11 @@ class EpisodeApi extends BaseApi {
}
async historyBy(episodeid: number) {
const response = await this.get<AsyncDataWrapper<History.Episode>>(
const response = await this.get<DataWrapperWithTotal<History.Episode>>(
"/history",
{ episodeid }
);
return response;
return response.data;
}
async downloadSubtitles(

@ -6,13 +6,13 @@ class HistoryApi extends BaseApi {
}
async stats(
timeframe?: History.TimeframeOptions,
timeFrame?: History.TimeFrameOptions,
action?: History.ActionOptions,
provider?: string,
language?: Language.CodeType
) {
const response = await this.get<History.Stat>("/stats", {
timeframe,
timeFrame,
action,
provider,
language,

@ -0,0 +1,25 @@
import badges from "./badges";
import episodes from "./episodes";
import files from "./files";
import history from "./history";
import movies from "./movies";
import providers from "./providers";
import series from "./series";
import subtitles from "./subtitles";
import system from "./system";
import utils from "./utils";
const api = {
badges,
episodes,
files,
movies,
series,
providers,
history,
subtitles,
system,
utils,
};
export default api;

@ -21,14 +21,17 @@ class MovieApi extends BaseApi {
}
async movies(radarrid?: number[]) {
const response = await this.get<AsyncDataWrapper<Item.Movie>>("", {
const response = await this.get<DataWrapperWithTotal<Item.Movie>>("", {
radarrid,
});
return response;
return response.data;
}
async moviesBy(params: Parameter.Range) {
const response = await this.get<AsyncDataWrapper<Item.Movie>>("", params);
const response = await this.get<DataWrapperWithTotal<Item.Movie>>(
"",
params
);
return response;
}
@ -37,7 +40,7 @@ class MovieApi extends BaseApi {
}
async wanted(params: Parameter.Range) {
const response = await this.get<AsyncDataWrapper<Wanted.Movie>>(
const response = await this.get<DataWrapperWithTotal<Wanted.Movie>>(
"/wanted",
params
);
@ -45,14 +48,17 @@ class MovieApi extends BaseApi {
}
async wantedBy(radarrid: number[]) {
const response = await this.get<AsyncDataWrapper<Wanted.Movie>>("/wanted", {
radarrid,
});
const response = await this.get<DataWrapperWithTotal<Wanted.Movie>>(
"/wanted",
{
radarrid,
}
);
return response;
}
async history(params: Parameter.Range) {
const response = await this.get<AsyncDataWrapper<History.Movie>>(
const response = await this.get<DataWrapperWithTotal<History.Movie>>(
"/history",
params
);
@ -60,11 +66,11 @@ class MovieApi extends BaseApi {
}
async historyBy(radarrid: number) {
const response = await this.get<AsyncDataWrapper<History.Movie>>(
const response = await this.get<DataWrapperWithTotal<History.Movie>>(
"/history",
{ radarrid }
);
return response;
return response.data;
}
async action(action: FormType.MoviesAction) {

@ -6,14 +6,17 @@ class SeriesApi extends BaseApi {
}
async series(seriesid?: number[]) {
const response = await this.get<AsyncDataWrapper<Item.Series>>("", {
const response = await this.get<DataWrapperWithTotal<Item.Series>>("", {
seriesid,
});
return response;
return response.data;
}
async seriesBy(params: Parameter.Range) {
const response = await this.get<AsyncDataWrapper<Item.Series>>("", params);
const response = await this.get<DataWrapperWithTotal<Item.Series>>(
"",
params
);
return response;
}

@ -30,7 +30,7 @@ class SystemApi extends BaseApi {
return response;
}
async setSettings(data: object) {
async updateSettings(data: object) {
await this.post("/settings", data);
}

@ -1,4 +1,4 @@
import apis from ".";
import client from "../queries/client";
type UrlTestResponse =
| {
@ -13,7 +13,7 @@ type UrlTestResponse =
class RequestUtils {
async urlTest(protocol: string, url: string, params?: any) {
try {
const result = await apis.axios.get<UrlTestResponse>(
const result = await client.axios.get<UrlTestResponse>(
`../test/${protocol}/${url}api/system/status`,
{ params }
);
@ -24,7 +24,7 @@ class RequestUtils {
throw new Error("Cannot get response, fallback to v3 api");
}
} catch (e) {
const result = await apis.axios.get<UrlTestResponse>(
const result = await client.axios.get<UrlTestResponse>(
`../test/${protocol}/${url}api/v3/system/status`,
{ params }
);

@ -1,5 +1,5 @@
import UIError from "pages/UIError";
import React from "react";
import UIError from "./UIError";
interface State {
error: Error | null;

@ -22,9 +22,12 @@ import {
Popover,
Row,
} from "react-bootstrap";
import { useProfileBy, useProfileItemsToLanguages } from "../../@redux/hooks";
import { LanguageText } from "../../components";
import { BuildKey, isMovie } from "../../utilities";
import { BuildKey, isMovie } from "utilities";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
} from "utilities/languages";
import { LanguageText } from ".";
interface Props {
item: Item.Base;
@ -75,7 +78,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
[item.audio_language]
);
const profile = useProfileBy(item.profileId);
const profile = useLanguageProfileBy(item.profileId);
const profileItems = useProfileItemsToLanguages(profile);
const languageBadges = useMemo(() => {

@ -1,5 +1,5 @@
import { Selector, SelectorProps } from "components";
import React, { useMemo } from "react";
import { Selector, SelectorProps } from "../components";
interface Props {
options: readonly Language.Info[];

@ -1,3 +1,5 @@
import { useServerSearch } from "apis/hooks";
import { uniqueId } from "lodash";
import React, {
FunctionComponent,
useCallback,
@ -9,6 +11,34 @@ import { Dropdown, Form } from "react-bootstrap";
import { useHistory } from "react-router";
import { useThrottle } from "rooks";
function useSearch(query: string) {
const { data } = useServerSearch(query, query.length > 0);
return useMemo(
() =>
data?.map((v) => {
let link: string;
let id: string;
if (v.sonarrSeriesId) {
link = `/series/${v.sonarrSeriesId}`;
id = `series-${v.sonarrSeriesId}`;
} else if (v.radarrId) {
link = `/movies/${v.radarrId}`;
id = `movie-${v.radarrId}`;
} else {
link = "";
id = uniqueId("unknown");
}
return {
name: `${v.title} (${v.year})`,
link,
id,
};
}) ?? [],
[data]
);
}
export interface SearchResult {
id: string;
name: string;
@ -17,43 +47,30 @@ export interface SearchResult {
interface Props {
className?: string;
onSearch: (text: string) => Promise<SearchResult[]>;
onFocus?: () => void;
onBlur?: () => void;
}
export const SearchBar: FunctionComponent<Props> = ({
onSearch,
onFocus,
onBlur,
className,
}) => {
const [text, setText] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [display, setDisplay] = useState("");
const [query, setQuery] = useState("");
const history = useHistory();
const search = useCallback(
(value: string) => {
if (value === "") {
setResults([]);
} else {
onSearch(value).then((res) => setResults(res));
}
},
[onSearch]
);
const [debounce] = useThrottle(setQuery, 500);
useEffect(() => {
debounce(display);
}, [debounce, display]);
const [debounceSearch] = useThrottle(search, 500);
const results = useSearch(query);
useEffect(() => {
debounceSearch(text);
}, [text, debounceSearch]);
const history = useHistory();
const clear = useCallback(() => {
setText("");
setResults([]);
setDisplay("");
setQuery("");
}, []);
const items = useMemo(() => {
@ -76,7 +93,7 @@ export const SearchBar: FunctionComponent<Props> = ({
return (
<Dropdown
show={text.length !== 0}
show={query.length !== 0}
className={className}
onFocus={onFocus}
onBlur={onBlur}
@ -91,8 +108,8 @@ export const SearchBar: FunctionComponent<Props> = ({
type="text"
size="sm"
placeholder="Search..."
value={text}
onChange={(e) => setText(e.currentTarget.value)}
value={display}
onChange={(e) => setDisplay(e.currentTarget.value)}
></Form.Control>
<Dropdown.Menu style={{ maxHeight: 256, overflowY: "auto" }}>
{items}

@ -4,38 +4,35 @@ import {
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { isEmpty } from "lodash";
import React, {
FunctionComponent,
PropsWithChildren,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Button, ButtonProps } from "react-bootstrap";
import { UseQueryResult } from "react-query";
import { useTimeoutWhen } from "rooks";
import { LoadingIndicator } from ".";
import { Selector, SelectorProps } from "./inputs";
interface Props<T extends Async.Base<any>> {
ctx: T;
children: FunctionComponent<T>;
interface QueryOverlayProps {
result: UseQueryResult<unknown, unknown>;
children: React.ReactElement;
}
export function AsyncOverlay<T extends Async.Base<any>>(props: Props<T>) {
const { ctx, children } = props;
if (
ctx.state === "uninitialized" ||
(ctx.state === "loading" && isEmpty(ctx.content))
) {
export const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
children,
result: { isLoading, isError, error },
}) => {
if (isLoading) {
return <LoadingIndicator></LoadingIndicator>;
} else if (ctx.state === "failed") {
return <p>{ctx.error}</p>;
} else {
return children(ctx);
} else if (isError) {
return <p>{error as string}</p>;
}
}
return children;
};
interface PromiseProps<T> {
promise: () => Promise<T>;
@ -58,48 +55,6 @@ export function PromiseOverlay<T>({ promise, children }: PromiseProps<T>) {
}
}
type AsyncSelectorProps<V, T extends Async.Item<V[]>> = {
state: T;
update: () => void;
label: (item: V) => string;
};
type RemovedSelectorProps<T, M extends boolean> = Omit<
SelectorProps<T, M>,
"loading" | "options" | "onFocus"
>;
export function AsyncSelector<
V,
T extends Async.Item<V[]>,
M extends boolean = false
>(props: Override<AsyncSelectorProps<V, T>, RemovedSelectorProps<V, M>>) {
const { label, state, update, ...selector } = props;
const options = useMemo<SelectorOption<V>[]>(
() =>
state.content?.map((v) => ({
label: label(v),
value: v,
})) ?? [],
[state, label]
);
return (
<Selector
loading={state.state === "loading"}
options={options}
label={label}
onFocus={() => {
if (state.state === "uninitialized") {
update();
}
}}
{...selector}
></Selector>
);
}
interface AsyncButtonProps<T> {
as?: ButtonProps["as"];
variant?: ButtonProps["variant"];

@ -1,6 +1,7 @@
import { faFile, faFolder } from "@fortawesome/free-regular-svg-icons";
import { faReply } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useFileSystem } from "apis/hooks";
import React, {
FunctionComponent,
useEffect,
@ -31,21 +32,22 @@ function extractPath(raw: string) {
interface Props {
defaultValue?: string;
load: (path: string) => Promise<FileTree[]>;
type: "sonarr" | "radarr" | "bazarr";
onChange?: (path: string) => void;
drop?: DropdownProps["drop"];
}
export const FileBrowser: FunctionComponent<Props> = ({
defaultValue,
type,
onChange,
load,
drop,
}) => {
const [show, canShow] = useState(false);
const [text, setText] = useState(defaultValue ?? "");
const [path, setPath] = useState(() => extractPath(text));
const [loading, setLoading] = useState(true);
const { data: tree, isFetching } = useFileSystem(type, path, show);
const filter = useMemo(() => {
const idx = getLastSeparator(text);
@ -57,10 +59,8 @@ export const FileBrowser: FunctionComponent<Props> = ({
return path.slice(0, idx + 1);
}, [path]);
const [tree, setTree] = useState<FileTree[]>([]);
const requestItems = useMemo(() => {
if (loading) {
const requestItems = () => {
if (isFetching) {
return (
<Dropdown.Item>
<Spinner size="sm" animation="border"></Spinner>
@ -70,19 +70,21 @@ export const FileBrowser: FunctionComponent<Props> = ({
const elements = [];
elements.push(
...tree
.filter((v) => v.name.startsWith(filter))
.map((v) => (
<Dropdown.Item eventKey={v.path} key={v.name}>
<FontAwesomeIcon
icon={v.children ? faFolder : faFile}
className="mr-2"
></FontAwesomeIcon>
<span>{v.name}</span>
</Dropdown.Item>
))
);
if (tree) {
elements.push(
...tree
.filter((v) => v.name.startsWith(filter))
.map((v) => (
<Dropdown.Item eventKey={v.path} key={v.name}>
<FontAwesomeIcon
icon={v.children ? faFolder : faFile}
className="mr-2"
></FontAwesomeIcon>
<span>{v.name}</span>
</Dropdown.Item>
))
);
}
if (elements.length === 0) {
elements.push(<Dropdown.Header key="no-files">No Files</Dropdown.Header>);
@ -100,7 +102,7 @@ export const FileBrowser: FunctionComponent<Props> = ({
} else {
return elements;
}
}, [tree, filter, previous, loading]);
};
useEffect(() => {
if (text === path) {
@ -116,17 +118,6 @@ export const FileBrowser: FunctionComponent<Props> = ({
const input = useRef<HTMLInputElement>(null);
useEffect(() => {
if (show) {
setLoading(true);
load(path)
.then((res) => {
setTree(res);
})
.finally(() => setLoading(false));
}
}, [path, load, show]);
return (
<Dropdown
show={show}
@ -165,7 +156,7 @@ export const FileBrowser: FunctionComponent<Props> = ({
className="w-100"
style={{ maxHeight: 256, overflowY: "auto" }}
>
{requestItems}
{requestItems()}
</Dropdown.Menu>
</Dropdown>
);

@ -1,7 +1,7 @@
import { faFileExcel } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent } from "react";
import { AsyncButton } from "../../components";
import { AsyncButton } from "..";
interface Props {
history: History.Base;

@ -1,10 +1,19 @@
import React, { FunctionComponent, useCallback, useMemo } from "react";
import {
useEpisodeAddBlacklist,
useEpisodeHistory,
useMovieAddBlacklist,
useMovieHistory,
} from "apis/hooks";
import React, { FunctionComponent, useMemo } from "react";
import { Column } from "react-table";
import { useDidUpdate } from "rooks";
import { HistoryIcon, LanguageText, PageTable, TextPopover } from "..";
import { EpisodesApi, MoviesApi, useAsyncRequest } from "../../apis";
import { BlacklistButton } from "../../DisplayItem/generic/blacklist";
import { AsyncOverlay } from "../async";
import {
HistoryIcon,
LanguageText,
PageTable,
QueryOverlay,
TextPopover,
} from "..";
import { BlacklistButton } from "../inputs/blacklist";
import BaseModal, { BaseModalProps } from "./BaseModal";
import { useModalPayload } from "./hooks";
@ -13,19 +22,9 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
const movie = useModalPayload<Item.Movie>(modal.modalKey);
const [history, updateHistory] = useAsyncRequest(
MoviesApi.historyBy.bind(MoviesApi)
);
const update = useCallback(() => {
if (movie) {
updateHistory(movie.radarrId);
}
}, [movie, updateHistory]);
const history = useMovieHistory(movie?.radarrId);
useDidUpdate(() => {
update();
}, [movie?.radarrId]);
const { data } = history;
const columns = useMemo<Column<History.Movie>[]>(
() => [
@ -74,33 +73,30 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
// Actions
accessor: "blacklisted",
Cell: ({ row }) => {
const original = row.original;
const { radarrId } = row.original;
const { mutateAsync } = useMovieAddBlacklist();
return (
<BlacklistButton
update={update}
promise={(form) =>
MoviesApi.addBlacklist(original.radarrId, form)
}
history={original}
update={history.refetch}
promise={(form) => mutateAsync({ id: radarrId, form })}
history={row.original}
></BlacklistButton>
);
},
},
],
[update]
[history.refetch]
);
return (
<BaseModal title={`History - ${movie?.title ?? ""}`} {...modal}>
<AsyncOverlay ctx={history}>
{({ content }) => (
<PageTable
emptyText="No History Found"
columns={columns}
data={content?.data ?? []}
></PageTable>
)}
</AsyncOverlay>
<QueryOverlay result={history}>
<PageTable
emptyText="No History Found"
columns={columns}
data={data ?? []}
></PageTable>
</QueryOverlay>
</BaseModal>
);
};
@ -112,19 +108,9 @@ export const EpisodeHistoryModal: FunctionComponent<
> = (props) => {
const episode = useModalPayload<Item.Episode>(props.modalKey);
const [history, updateHistory] = useAsyncRequest(
EpisodesApi.historyBy.bind(EpisodesApi)
);
const update = useCallback(() => {
if (episode) {
updateHistory(episode.sonarrEpisodeId);
}
}, [episode, updateHistory]);
const history = useEpisodeHistory(episode?.sonarrEpisodeId);
useDidUpdate(() => {
update();
}, [episode?.sonarrEpisodeId]);
const { data } = history;
const columns = useMemo<Column<History.Episode>[]>(
() => [
@ -174,33 +160,36 @@ export const EpisodeHistoryModal: FunctionComponent<
accessor: "blacklisted",
Cell: ({ row }) => {
const original = row.original;
const { sonarrSeriesId, sonarrEpisodeId } = original;
const { sonarrEpisodeId, sonarrSeriesId } = original;
const { mutateAsync } = useEpisodeAddBlacklist();
return (
<BlacklistButton
history={original}
update={update}
promise={(form) =>
EpisodesApi.addBlacklist(sonarrSeriesId, sonarrEpisodeId, form)
mutateAsync({
seriesId: sonarrSeriesId,
episodeId: sonarrEpisodeId,
form,
})
}
></BlacklistButton>
);
},
},
],
[update]
[]
);
return (
<BaseModal title={`History - ${episode?.title ?? ""}`} {...props}>
<AsyncOverlay ctx={history}>
{({ content }) => (
<PageTable
emptyText="No History Found"
columns={columns}
data={content?.data ?? []}
></PageTable>
)}
</AsyncOverlay>
<QueryOverlay result={history}>
<PageTable
emptyText="No History Found"
columns={columns}
data={data ?? []}
></PageTable>
</QueryOverlay>
</BaseModal>
);
};

@ -1,9 +1,8 @@
import { useIsAnyActionRunning, useLanguageProfiles } from "apis/hooks";
import React, { FunctionComponent, useMemo, useState } from "react";
import { Container, Form } from "react-bootstrap";
import { GetItemId } from "utilities";
import { AsyncButton, Selector } from "../";
import { useIsAnyTaskRunningWithId } from "../../@modules/task/hooks";
import { useLanguageProfiles } from "../../@redux/hooks";
import { GetItemId } from "../../utilities";
import BaseModal, { BaseModalProps } from "./BaseModal";
import { useModalInformation } from "./hooks";
@ -15,14 +14,13 @@ interface Props {
const Editor: FunctionComponent<Props & BaseModalProps> = (props) => {
const { onSuccess, submit, ...modal } = props;
const profiles = useLanguageProfiles();
const { data: profiles } = useLanguageProfiles();
const { payload, closeModal } = useModalInformation<Item.Base>(
modal.modalKey
);
// TODO: Separate movies and series
const hasTask = useIsAnyTaskRunningWithId([GetItemId(payload ?? {})]);
const hasTask = useIsAnyActionRunning();
const profileOptions = useMemo<SelectorOption<number>[]>(
() =>
@ -43,6 +41,10 @@ const Editor: FunctionComponent<Props & BaseModalProps> = (props) => {
promise={() => {
if (payload) {
const itemId = GetItemId(payload);
if (!itemId) {
return null;
}
return submit({
id: [itemId],
profileid: [id],

@ -6,10 +6,12 @@ import {
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { dispatchTask } from "@modules/task";
import { createTask } from "@modules/task/utilities";
import { useEpisodesProvider, useMoviesProvider } from "apis/hooks";
import React, {
FunctionComponent,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
@ -24,6 +26,7 @@ import {
Row,
} from "react-bootstrap";
import { Column } from "react-table";
import { GetItemId, isMovie } from "utilities";
import {
BaseModal,
BaseModalProps,
@ -32,20 +35,10 @@ import {
PageTable,
useModalPayload,
} from "..";
import { dispatchTask } from "../../@modules/task";
import { createTask } from "../../@modules/task/utilities";
import { ProvidersApi } from "../../apis";
import { GetItemId, isMovie } from "../../utilities";
import "./msmStyle.scss";
type SupportType = Item.Movie | Item.Episode;
enum SearchState {
Ready,
Searching,
Finished,
}
interface Props<T extends SupportType> {
download: (item: T, result: SearchResultType) => Promise<void>;
}
@ -55,30 +48,35 @@ export function ManualSearchModal<T extends SupportType>(
) {
const { download, ...modal } = props;
const [result, setResult] = useState<SearchResultType[]>([]);
const [searchState, setSearchState] = useState(SearchState.Ready);
const item = useModalPayload<T>(modal.modalKey);
const search = useCallback(async () => {
const [episodeId, setEpisodeId] = useState<number | undefined>(undefined);
const [radarrId, setRadarrId] = useState<number | undefined>(undefined);
const episodes = useEpisodesProvider(episodeId);
const movies = useMoviesProvider(radarrId);
const isInitial = episodeId === undefined && radarrId === undefined;
const isFetching = episodes.isFetching || movies.isFetching;
const results = useMemo(
() => [...(episodes.data ?? []), ...(movies.data ?? [])],
[episodes.data, movies.data]
);
const search = useCallback(() => {
setEpisodeId(undefined);
setRadarrId(undefined);
if (item) {
setSearchState(SearchState.Searching);
let results: SearchResultType[] = [];
if (isMovie(item)) {
results = await ProvidersApi.movies(item.radarrId);
setRadarrId(item.radarrId);
movies.refetch();
} else {
results = await ProvidersApi.episodes(item.sonarrEpisodeId);
setEpisodeId(item.sonarrEpisodeId);
episodes.refetch();
}
setResult(results);
setSearchState(SearchState.Finished);
}
}, [item]);
useEffect(() => {
if (item !== null) {
setSearchState(SearchState.Ready);
}
}, [item]);
}, [episodes, item, movies]);
const columns = useMemo<Column<SearchResultType>[]>(
() => [
@ -214,8 +212,8 @@ export function ManualSearchModal<T extends SupportType>(
[download, item]
);
const content = useMemo<JSX.Element>(() => {
if (searchState === SearchState.Ready) {
const content = () => {
if (isInitial) {
return (
<div className="px-4 py-5">
<p className="mb-3 small">{item?.path ?? ""}</p>
@ -224,7 +222,7 @@ export function ManualSearchModal<T extends SupportType>(
</Button>
</div>
);
} else if (searchState === SearchState.Searching) {
} else if (isFetching) {
return <LoadingIndicator animation="grow"></LoadingIndicator>;
} else {
return (
@ -233,24 +231,21 @@ export function ManualSearchModal<T extends SupportType>(
<PageTable
emptyText="No Result"
columns={columns}
data={result}
data={results}
></PageTable>
</React.Fragment>
);
}
}, [searchState, columns, result, search, item?.path]);
};
const footer = useMemo(
() => (
<Button
variant="light"
hidden={searchState !== SearchState.Finished}
onClick={search}
>
Search Again
</Button>
),
[searchState, search]
const footer = (
<Button
variant="light"
hidden={isFetching === true || isInitial === true}
onClick={search}
>
Search Again
</Button>
);
const title = useMemo(() => {
@ -270,13 +265,13 @@ export function ManualSearchModal<T extends SupportType>(
return (
<BaseModal
closeable={searchState !== SearchState.Searching}
closeable={isFetching === false}
size="xl"
title={title}
footer={footer}
{...modal}
>
{content}
{content()}
</BaseModal>
);
}

@ -1,8 +1,11 @@
import { dispatchTask } from "@modules/task";
import { createTask } from "@modules/task/utilities";
import { useMovieSubtitleModification } from "apis/hooks";
import React, { FunctionComponent, useCallback } from "react";
import { dispatchTask } from "../../@modules/task";
import { createTask } from "../../@modules/task/utilities";
import { useProfileBy, useProfileItemsToLanguages } from "../../@redux/hooks";
import { MoviesApi } from "../../apis";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
} from "utilities/languages";
import { BaseModalProps } from "./BaseModal";
import { useModalInformation } from "./hooks";
import SubtitleUploadModal, {
@ -19,7 +22,7 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
const { payload } = useModalInformation<Item.Movie>(modal.modalKey);
const profile = useProfileBy(payload?.profileId);
const profile = useLanguageProfileBy(payload?.profileId);
const availableLanguages = useProfileItemsToLanguages(profile);
@ -27,6 +30,10 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
return list;
}, []);
const {
upload: { mutateAsync },
} = useMovieSubtitleModification();
const validate = useCallback<Validator<Payload>>(
(item) => {
if (item.language === null) {
@ -64,23 +71,20 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
.map((v) => {
const { file, language, forced, hi } = v;
return createTask(
file.name,
radarrId,
MoviesApi.uploadSubtitles.bind(MoviesApi),
return createTask(file.name, radarrId, mutateAsync, {
radarrId,
{
form: {
file,
forced,
hi,
language: language!.code2,
}
);
},
});
});
dispatchTask(TaskGroupName, tasks, "Uploading...");
},
[payload]
[mutateAsync, payload]
);
return (

@ -1,9 +1,13 @@
import { dispatchTask } from "@modules/task";
import { createTask } from "@modules/task/utilities";
import { useEpisodeSubtitleModification } from "apis/hooks";
import api from "apis/raw";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Column } from "react-table";
import { dispatchTask } from "../../@modules/task";
import { createTask } from "../../@modules/task/utilities";
import { useProfileBy, useProfileItemsToLanguages } from "../../@redux/hooks";
import { EpisodesApi, SubtitlesApi } from "../../apis";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
} from "utilities/languages";
import { Selector } from "../inputs";
import { BaseModalProps } from "./BaseModal";
import { useModalInformation } from "./hooks";
@ -28,17 +32,21 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
}) => {
const { payload } = useModalInformation<Item.Series>(modal.modalKey);
const profile = useProfileBy(payload?.profileId);
const profile = useLanguageProfileBy(payload?.profileId);
const availableLanguages = useProfileItemsToLanguages(profile);
const {
upload: { mutateAsync },
} = useEpisodeSubtitleModification();
const update = useCallback(
async (list: PendingSubtitle<Payload>[]) => {
const newList = [...list];
const names = list.map((v) => v.file.name);
if (names.length > 0) {
const results = await SubtitlesApi.info(names);
const results = await api.subtitles.info(names);
// TODO: Optimization
newList.forEach((v) => {
@ -85,14 +93,14 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
return;
}
const { sonarrSeriesId: seriesid } = payload;
const { sonarrSeriesId: seriesId } = payload;
const tasks = items
.filter((v) => v.payload.instance !== undefined)
.map((v) => {
const { hi, forced, payload, language } = v;
const { code2 } = language!;
const { sonarrEpisodeId: episodeid } = payload.instance!;
const { sonarrEpisodeId: episodeId } = payload.instance!;
const form: FormType.UploadSubtitle = {
file: v.file,
@ -101,19 +109,16 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
forced: forced,
};
return createTask(
v.file.name,
episodeid,
EpisodesApi.uploadSubtitles.bind(EpisodesApi),
seriesid,
episodeid,
form
);
return createTask(v.file.name, episodeId, mutateAsync, {
seriesId,
episodeId,
form,
});
});
dispatchTask(TaskGroupName, tasks, "Uploading subtitles...");
},
[payload]
[mutateAsync, payload]
);
const columns = useMemo<Column<PendingSubtitle<Payload>>[]>(

@ -14,6 +14,9 @@ import {
faTextHeight,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { dispatchTask } from "@modules/task";
import { createTask } from "@modules/task/utilities";
import { useSubtitleAction } from "apis/hooks";
import React, {
FunctionComponent,
useCallback,
@ -29,6 +32,9 @@ import {
InputGroup,
} from "react-bootstrap";
import { Column, useRowSelect } from "react-table";
import { isMovie, submodProcessColor } from "utilities";
import { useEnabledLanguages } from "utilities/languages";
import { log } from "utilities/logger";
import {
ActionButton,
ActionButtonItem,
@ -39,12 +45,6 @@ import {
useModalPayload,
useShowModal,
} from "..";
import { dispatchTask } from "../../@modules/task";
import { createTask } from "../../@modules/task/utilities";
import { useEnabledLanguages } from "../../@redux/hooks";
import { SubtitlesApi } from "../../apis";
import { isMovie, submodProcessColor } from "../../utilities";
import { log } from "../../utilities/logger";
import { useCustomSelection } from "../tables/plugins";
import BaseModal, { BaseModalProps } from "./BaseModal";
import { useCloseModal } from "./hooks";
@ -255,7 +255,7 @@ const TranslateModal: FunctionComponent<BaseModalProps & ToolModalProps> = ({
process,
...modal
}) => {
const languages = useEnabledLanguages();
const { data: languages } = useEnabledLanguages();
const available = useMemo(
() => languages.filter((v) => v.code2 in availableTranslation),
@ -305,6 +305,8 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
const closeModal = useCloseModal();
const { mutateAsync } = useSubtitleAction();
const process = useCallback(
(action: string, override?: Partial<FormType.ModifySubtitle>) => {
log("info", "executing action", action);
@ -318,18 +320,12 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
path: s.path,
...override,
};
return createTask(
s.path,
s.id,
SubtitlesApi.modify.bind(SubtitlesApi),
action,
form
);
return createTask(s.path, s.id, mutateAsync, { action, form });
});
dispatchTask(TaskGroupName, tasks, "Modifying subtitles...");
},
[closeModal, selections, props.modalKey]
[closeModal, props.modalKey, selections, mutateAsync]
);
const showModal = useShowModal();

@ -9,8 +9,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Container, Form } from "react-bootstrap";
import { Column, TableUpdater } from "react-table";
import { BuildKey } from "utilities";
import { LanguageSelector, MessageIcon } from "..";
import { BuildKey } from "../../utilities";
import { FileForm } from "../inputs";
import { SimpleTable } from "../tables";
import BaseModal, { BaseModalProps } from "./BaseModal";

@ -1,6 +1,6 @@
import { useCallback, useContext, useMemo } from "react";
import { useDidUpdate } from "rooks";
import { log } from "../../utilities/logger";
import { log } from "utilities/logger";
import { ModalContext } from "./provider";
interface ModalInformation<T> {

@ -1,128 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { PluginHook, TableOptions, useTable } from "react-table";
import { LoadingIndicator } from "..";
import { usePageSize } from "../../@storage/local";
import {
ScrollToTop,
useEntityByRange,
useIsEntityLoaded,
} from "../../utilities";
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
import PageControl from "./PageControl";
import { useDefaultSettings } from "./plugins";
function useEntityPagination<T>(
entity: Async.Entity<T>,
loader: (range: Parameter.Range) => void,
start: number,
end: number
): T[] {
const { state, content } = entity;
const needInit = state === "uninitialized";
const hasEmpty = useIsEntityLoaded(content, start, end) === false;
useEffect(() => {
if (needInit || hasEmpty) {
const length = end - start;
loader({ start, length });
}
});
return useEntityByRange(content, start, end);
}
type Props<T extends object> = TableOptions<T> &
TableStyleProps<T> & {
plugins?: PluginHook<T>[];
entity: Async.Entity<T>;
loader: (params: Parameter.Range) => void;
};
export default function AsyncPageTable<T extends object>(props: Props<T>) {
const { entity, plugins, loader, ...remain } = props;
const { style, options } = useStyleAndOptions(remain);
const {
state,
content: { ids },
} = entity;
// Impl a new pagination system instead of hacking into existing one
const [pageIndex, setIndex] = useState(0);
const [pageSize] = usePageSize();
const totalRows = ids.length;
const pageCount = Math.ceil(totalRows / pageSize);
const pageStart = pageIndex * pageSize;
const pageEnd = pageStart + pageSize;
const data = useEntityPagination(entity, loader, pageStart, pageEnd);
const instance = useTable(
{
...options,
data,
},
useDefaultSettings,
...(plugins ?? [])
);
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
instance;
const previous = useCallback(() => {
setIndex((idx) => idx - 1);
}, []);
const next = useCallback(() => {
setIndex((idx) => idx + 1);
}, []);
const goto = useCallback((idx: number) => {
setIndex(idx);
}, []);
useEffect(() => {
ScrollToTop();
}, [pageIndex]);
// Reset page index if we out of bound
useEffect(() => {
if (pageCount === 0) return;
if (pageIndex >= pageCount) {
setIndex(pageCount - 1);
} else if (pageIndex < 0) {
setIndex(0);
}
}, [pageIndex, pageCount]);
if ((state === "loading" && data.length === 0) || state === "uninitialized") {
return <LoadingIndicator></LoadingIndicator>;
}
return (
<React.Fragment>
<BaseTable
{...style}
headers={headerGroups}
rows={rows}
prepareRow={prepareRow}
tableProps={getTableProps()}
tableBodyProps={getTableBodyProps()}
></BaseTable>
<PageControl
count={pageCount}
index={pageIndex}
size={pageSize}
total={totalRows}
canPrevious={pageIndex > 0}
canNext={pageIndex < pageCount - 1}
previous={previous}
next={next}
goto={goto}
></PageControl>
</React.Fragment>
);
}

@ -6,7 +6,7 @@ import {
useRowSelect,
useTable,
} from "react-table";
import { ScrollToTop } from "../../utilities";
import { ScrollToTop } from "utilities";
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
import PageControl from "./PageControl";
import { useCustomSelection, useDefaultSettings } from "./plugins";

@ -0,0 +1,77 @@
import { UsePaginationQueryResult } from "apis/queries/hooks";
import React, { useEffect } from "react";
import { PluginHook, TableOptions, useTable } from "react-table";
import { ScrollToTop } from "utilities";
import { LoadingIndicator } from "..";
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
import PageControl from "./PageControl";
import { useDefaultSettings } from "./plugins";
type Props<T extends object> = TableOptions<T> &
TableStyleProps<T> & {
plugins?: PluginHook<T>[];
query: UsePaginationQueryResult<T>;
};
export default function QueryPageTable<T extends object>(props: Props<T>) {
const { plugins, query, ...remain } = props;
const { style, options } = useStyleAndOptions(remain);
const {
data,
isLoading,
paginationStatus: {
page,
pageCount,
totalCount,
canPrevious,
canNext,
pageSize,
},
controls: { previousPage, nextPage, gotoPage },
} = query;
const instance = useTable(
{
...options,
data: data?.data ?? [],
},
useDefaultSettings,
...(plugins ?? [])
);
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
instance;
useEffect(() => {
ScrollToTop();
}, [page]);
if (isLoading) {
return <LoadingIndicator></LoadingIndicator>;
}
return (
<React.Fragment>
<BaseTable
{...style}
headers={headerGroups}
rows={rows}
prepareRow={prepareRow}
tableProps={getTableProps()}
tableBodyProps={getTableBodyProps()}
></BaseTable>
<PageControl
count={pageCount}
index={page}
size={pageSize}
total={totalCount}
canPrevious={canPrevious}
canNext={canNext}
previous={previousPage}
next={nextPage}
goto={gotoPage}
></PageControl>
</React.Fragment>
);
}

@ -1,4 +1,4 @@
export { default as AsyncPageTable } from "./AsyncPageTable";
export { default as GroupTable } from "./GroupTable";
export { default as PageTable } from "./PageTable";
export { default as QueryPageTable } from "./QueryPageTable";
export { default as SimpleTable } from "./SimpleTable";

@ -1,5 +1,5 @@
import { Hooks, TableOptions } from "react-table";
import { usePageSize } from "../../../@storage/local";
import { usePageSize } from "utilities/storage";
const pluginName = "useLocalSettings";

@ -0,0 +1,36 @@
import { UsePaginationQueryResult } from "apis/queries/hooks";
import React from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Column } from "react-table";
import { QueryPageTable } from "..";
interface Props<T extends History.Base> {
name: string;
query: UsePaginationQueryResult<T>;
columns: Column<T>[];
}
function HistoryView<T extends History.Base = History.Base>({
columns,
name,
query,
}: Props<T>) {
return (
<Container fluid>
<Helmet>
<title>{name} History - Bazarr</title>
</Helmet>
<Row>
<QueryPageTable
emptyText={`Nothing Found in ${name} History`}
columns={columns}
query={query}
data={[]}
></QueryPageTable>
</Row>
</Container>
);
}
export default HistoryView;

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save