mirror of
https://github.com/idanoo/autobrr
synced 2025-07-22 16:29:12 +00:00
feat(lists): integrate Omegabrr (#1885)
* feat(lists): integrate Omegabrr * feat(lists): add missing lists index * feat(lists): add db repo * feat(lists): add db migrations * feat(lists): labels * feat(lists): url lists and more arrs * fix(lists): db migrations client_id wrong type * fix(lists): db fields * feat(lists): create list form wip * feat(lists): show in list and create * feat(lists): update and delete * feat(lists): trigger via webhook * feat(lists): add webhook handler * fix(arr): encode json to pointer * feat(lists): rename endpoint to lists * feat(lists): fetch tags from arr * feat(lists): process plaintext lists * feat(lists): add background refresh job * run every 6th hour with a random start delay between 1-35 seconds * feat(lists): refresh on save and improve logging * feat(lists): cast arr client to pointer * feat(lists): improve error handling * feat(lists): reset shows field with match release * feat(lists): filter opts all lists * feat(lists): trigger on update if enabled * feat(lists): update option for lists * feat(lists): show connected filters in list * feat(lists): missing listSvc dep * feat(lists): cleanup * feat(lists): typo arr list * feat(lists): radarr include original * feat(lists): rename ExcludeAlternateTitle to IncludeAlternateTitle * fix(lists): arr client type conversion to pointer * fix(actions): only log panic recover if err not nil * feat(lists): show spinner on save * feat(lists): show icon in filters list * feat(lists): change icon color in filters list * feat(lists): delete relations on filter delete
This commit is contained in:
parent
b68ae334ca
commit
221bc35371
77 changed files with 5025 additions and 254 deletions
|
@ -296,6 +296,7 @@ export const APIClient = {
|
|||
},
|
||||
download_clients: {
|
||||
getAll: () => appClient.Get<DownloadClient[]>("api/download_clients"),
|
||||
getArrTags: (clientID: number) => appClient.Get<ArrTag[]>(`api/download_clients/${clientID}/arr/tags`),
|
||||
create: (dc: DownloadClient) => appClient.Post("api/download_clients", {
|
||||
body: dc
|
||||
}),
|
||||
|
@ -409,6 +410,22 @@ export const APIClient = {
|
|||
body: notification
|
||||
})
|
||||
},
|
||||
lists: {
|
||||
list: () => appClient.Get<List[]>("api/lists"),
|
||||
getByID: (id: number) => appClient.Get<List>(`api/lists/${id}`),
|
||||
store: (list: List) => appClient.Post("api/lists", {
|
||||
body: list
|
||||
}),
|
||||
update: (list: List) => appClient.Put(`api/lists/${list.id}`, {
|
||||
body: list
|
||||
}),
|
||||
delete: (id: number) => appClient.Delete(`api/lists/${id}`),
|
||||
refreshList: (id: number) => appClient.Post(`api/lists/${id}/refresh`),
|
||||
refreshAll: () => appClient.Post(`api/lists/refresh`),
|
||||
test: (list: List) => appClient.Post("api/lists/test", {
|
||||
body: list
|
||||
})
|
||||
},
|
||||
proxy: {
|
||||
list: () => appClient.Get<Proxy[]>("api/proxy"),
|
||||
getByID: (id: number) => appClient.Get<Proxy>(`api/proxy/${id}`),
|
||||
|
|
|
@ -11,12 +11,19 @@ import {
|
|||
FeedKeys,
|
||||
FilterKeys,
|
||||
IndexerKeys,
|
||||
IrcKeys, NotificationKeys, ProxyKeys,
|
||||
IrcKeys, ListKeys, NotificationKeys, ProxyKeys,
|
||||
ReleaseKeys,
|
||||
SettingsKeys
|
||||
} from "@api/query_keys";
|
||||
import { ColumnFilter } from "@tanstack/react-table";
|
||||
|
||||
export const FiltersGetAllQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: FilterKeys.lists(),
|
||||
queryFn: () => APIClient.filters.getAll(),
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
|
||||
export const FiltersQueryOptions = (indexers: string[], sortOrder: string) =>
|
||||
queryOptions({
|
||||
queryKey: FilterKeys.list(indexers, sortOrder),
|
||||
|
@ -92,6 +99,14 @@ export const DownloadClientsQueryOptions = () =>
|
|||
queryFn: () => APIClient.download_clients.getAll(),
|
||||
});
|
||||
|
||||
export const DownloadClientsArrTagsQueryOptions = (clientID: number) =>
|
||||
queryOptions({
|
||||
queryKey: DownloadClientKeys.arrTags(clientID),
|
||||
queryFn: () => APIClient.download_clients.getArrTags(clientID),
|
||||
retry: false,
|
||||
enabled: clientID > 0,
|
||||
});
|
||||
|
||||
export const NotificationsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: NotificationKeys.lists(),
|
||||
|
@ -163,3 +178,10 @@ export const ProxyByIdQueryOptions = (proxyId: number) =>
|
|||
queryFn: async ({queryKey}) => await APIClient.proxy.getByID(queryKey[2]),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
export const ListsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ListKeys.lists(),
|
||||
queryFn: () => APIClient.lists.list(),
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
|
|
|
@ -47,7 +47,8 @@ export const DownloadClientKeys = {
|
|||
lists: () => [...DownloadClientKeys.all, "list"] as const,
|
||||
// list: (indexers: string[], sortOrder: string) => [...clientKeys.lists(), { indexers, sortOrder }] as const,
|
||||
details: () => [...DownloadClientKeys.all, "detail"] as const,
|
||||
detail: (id: number) => [...DownloadClientKeys.details(), id] as const
|
||||
detail: (id: number) => [...DownloadClientKeys.details(), id] as const,
|
||||
arrTags: (id: number) => [...DownloadClientKeys.details(), id, "arr-tags"] as const
|
||||
};
|
||||
|
||||
export const FeedKeys = {
|
||||
|
@ -89,3 +90,10 @@ export const ProxyKeys = {
|
|||
details: () => [...ProxyKeys.all, "detail"] as const,
|
||||
detail: (id: number) => [...ProxyKeys.details(), id] as const
|
||||
};
|
||||
|
||||
export const ListKeys = {
|
||||
all: ["list"] as const,
|
||||
lists: () => [...ListKeys.all, "list"] as const,
|
||||
details: () => [...ListKeys.all, "detail"] as const,
|
||||
detail: (id: number) => [...ListKeys.details(), id] as const
|
||||
};
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { JSX } from "react";
|
||||
import { Field as FormikField } from "formik";
|
||||
import Select from "react-select";
|
||||
import { Field, Label, Description } from "@headlessui/react";
|
||||
|
@ -33,6 +34,7 @@ interface TextFieldWideProps {
|
|||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
autoComplete?: string;
|
||||
hidden?: boolean;
|
||||
tooltip?: JSX.Element;
|
||||
|
@ -46,6 +48,7 @@ export const TextFieldWide = ({
|
|||
placeholder,
|
||||
defaultValue,
|
||||
required,
|
||||
disabled,
|
||||
autoComplete,
|
||||
tooltip,
|
||||
hidden,
|
||||
|
@ -68,6 +71,7 @@ export const TextFieldWide = ({
|
|||
value={defaultValue}
|
||||
required={required}
|
||||
validate={validate}
|
||||
disabled={disabled}
|
||||
>
|
||||
{({ field, meta }: FieldProps) => (
|
||||
<input
|
||||
|
@ -76,11 +80,13 @@ export const TextFieldWide = ({
|
|||
type="text"
|
||||
value={field.value ? field.value : defaultValue ?? ""}
|
||||
onChange={field.onChange}
|
||||
disabled={disabled}
|
||||
className={classNames(
|
||||
meta.touched && meta.error
|
||||
? "border-red-500 focus:ring-red-500 focus:border-red-500"
|
||||
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
|
||||
"block w-full shadow-sm sm:text-sm rounded-md border py-2.5 bg-gray-100 dark:bg-gray-850 dark:text-gray-100"
|
||||
"block w-full shadow-sm sm:text-sm rounded-md border py-2.5 dark:text-gray-100",
|
||||
disabled ? "bg-gray-200 dark:bg-gray-700" : "bg-gray-100 dark:bg-gray-850 "
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
hidden={hidden}
|
||||
|
@ -273,7 +279,7 @@ export const SwitchGroupWide = ({
|
|||
</div>
|
||||
</Label>
|
||||
{description && (
|
||||
<Description className="text-sm text-gray-500 dark:text-gray-700">
|
||||
<Description className="text-sm text-gray-500 dark:text-gray-500">
|
||||
{description}
|
||||
</Description>
|
||||
)}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { JSX } from "react";
|
||||
import { Field } from "formik";
|
||||
import Select from "react-select";
|
||||
import CreatableSelect from "react-select/creatable";
|
||||
|
@ -11,6 +12,8 @@ import type { FieldProps } from "formik";
|
|||
import { OptionBasicTyped } from "@domain/constants";
|
||||
import * as common from "@components/inputs/common";
|
||||
import { DocsTooltip } from "@components/tooltips/DocsTooltip";
|
||||
import { MultiSelect as RMSC } from "react-multi-select-component";
|
||||
import { MultiSelectOption } from "@components/inputs/select.tsx";
|
||||
|
||||
interface SelectFieldProps<T> {
|
||||
name: string;
|
||||
|
@ -228,3 +231,67 @@ export function SelectFieldBasic<T>({ name, label, help, placeholder, required,
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface MultiSelectFieldProps {
|
||||
name: string;
|
||||
label: string;
|
||||
help?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
tooltip?: JSX.Element;
|
||||
options: OptionBasicTyped<number>[];
|
||||
}
|
||||
|
||||
interface ListFilterMultiSelectOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function ListFilterMultiSelectField({ name, label, help, tooltip, options }: MultiSelectFieldProps) {
|
||||
return (
|
||||
<div className="flex items-center space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className="block ml-px text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
<div className="flex">
|
||||
{tooltip ? (
|
||||
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
|
||||
) : label}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Field name={name} type="select">
|
||||
{({
|
||||
field,
|
||||
form: { setFieldValue }
|
||||
}: FieldProps) => (
|
||||
<>
|
||||
<RMSC
|
||||
{...field}
|
||||
options={options}
|
||||
// disabled={disabled}
|
||||
labelledBy={name}
|
||||
// isCreatable={creatable}
|
||||
// onCreateOption={handleNewField}
|
||||
value={field.value && field.value.map((item: ListFilterMultiSelectOption) => ({
|
||||
value: item.id,
|
||||
label: item.name
|
||||
}))}
|
||||
onChange={(values: MultiSelectOption[]) => {
|
||||
const item = values && values.map((i) => ({ id: i.value, name: i.label }));
|
||||
setFieldValue(field.name, item);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
{help && (
|
||||
<p className="mt-2 text-sm text-gray-500" id={`${name}-description`}>{help}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -409,6 +409,49 @@ export const PushStatusOptions: OptionBasic[] = [
|
|||
}
|
||||
];
|
||||
|
||||
export const ListTypeOptions: OptionBasicTyped<ListType>[] = [
|
||||
{
|
||||
label: "Sonarr",
|
||||
value: "SONARR"
|
||||
},
|
||||
{
|
||||
label: "Radarr",
|
||||
value: "RADARR"
|
||||
},
|
||||
{
|
||||
label: "Lidarr",
|
||||
value: "LIDARR"
|
||||
},
|
||||
{
|
||||
label: "Readarr",
|
||||
value: "READARR"
|
||||
},
|
||||
{
|
||||
label: "Whisparr",
|
||||
value: "WHISPARR"
|
||||
},
|
||||
{
|
||||
label: "MDBList",
|
||||
value: "MDBLIST"
|
||||
},
|
||||
{
|
||||
label: "Trakt",
|
||||
value: "TRAKT"
|
||||
},
|
||||
{
|
||||
label: "Plaintext",
|
||||
value: "PLAINTEXT"
|
||||
},
|
||||
{
|
||||
label: "Steam",
|
||||
value: "STEAM"
|
||||
},
|
||||
{
|
||||
label: "Metacritic",
|
||||
value: "METACRITIC"
|
||||
},
|
||||
];
|
||||
|
||||
export const NotificationTypeOptions: OptionBasicTyped<NotificationType>[] = [
|
||||
{
|
||||
label: "Discord",
|
||||
|
@ -607,3 +650,48 @@ export const ProxyTypeOptions: OptionBasicTyped<ProxyType>[] = [
|
|||
value: "SOCKS5"
|
||||
},
|
||||
];
|
||||
|
||||
export const ListsTraktOptions: OptionBasic[] = [
|
||||
{
|
||||
label: "Anticipated TV",
|
||||
value: "https://api.autobrr.com/lists/trakt/anticipated-tv"
|
||||
},
|
||||
{
|
||||
label: "Popular TV",
|
||||
value: "https://api.autobrr.com/lists/trakt/popular-tv"
|
||||
},
|
||||
{
|
||||
label: "Upcoming Movies",
|
||||
value: "https://api.autobrr.com/lists/trakt/upcoming-movies"
|
||||
},
|
||||
{
|
||||
label: "Upcoming BluRay",
|
||||
value: "https://api.autobrr.com/lists/trakt/upcoming-bluray"
|
||||
},
|
||||
{
|
||||
label: "Popular TV",
|
||||
value: "https://api.autobrr.com/lists/trakt/popular-tv"
|
||||
},
|
||||
{
|
||||
label: "Steven Lu",
|
||||
value: "https://api.autobrr.com/lists/stevenlu"
|
||||
},
|
||||
];
|
||||
|
||||
export const ListsMetacriticOptions: OptionBasic[] = [
|
||||
{
|
||||
label: "Upcoming Albums",
|
||||
value: "https://api.autobrr.com/lists/metacritic/upcoming-albums"
|
||||
},
|
||||
{
|
||||
label: "New Albums",
|
||||
value: "https://api.autobrr.com/lists/metacritic/new-albums"
|
||||
}
|
||||
];
|
||||
|
||||
export const ListsMDBListOptions: OptionBasic[] = [
|
||||
{
|
||||
label: "Latest TV Shows",
|
||||
value: "https://mdblist.com/lists/garycrawfordgc/latest-tv-shows/json"
|
||||
},
|
||||
];
|
||||
|
|
|
@ -8,3 +8,5 @@ export { FilterAddForm } from "./filters/FilterAddForm";
|
|||
export { DownloadClientAddForm, DownloadClientUpdateForm } from "./settings/DownloadClientForms";
|
||||
export { IndexerAddForm, IndexerUpdateForm } from "./settings/IndexerForms";
|
||||
export { IrcNetworkAddForm, IrcNetworkUpdateForm } from "./settings/IrcForms";
|
||||
export { ListAddForm, ListUpdateForm } from "./settings/ListForms";
|
||||
|
||||
|
|
969
web/src/forms/settings/ListForms.tsx
Normal file
969
web/src/forms/settings/ListForms.tsx
Normal file
|
@ -0,0 +1,969 @@
|
|||
/*
|
||||
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { Fragment, JSX, useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Select from "react-select";
|
||||
import {
|
||||
Field,
|
||||
FieldProps,
|
||||
Form,
|
||||
Formik,
|
||||
FormikErrors,
|
||||
FormikValues,
|
||||
useFormikContext
|
||||
} from "formik";
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
Listbox,
|
||||
ListboxButton, ListboxOption, ListboxOptions,
|
||||
Transition,
|
||||
TransitionChild
|
||||
} from "@headlessui/react";
|
||||
import { CheckIcon, ChevronUpDownIcon, XMarkIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { ListKeys } from "@api/query_keys";
|
||||
import { toast } from "@components/hot-toast";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import * as common from "@components/inputs/common";
|
||||
import {
|
||||
MultiSelectOption,
|
||||
PasswordFieldWide,
|
||||
SwitchGroupWide,
|
||||
TextFieldWide
|
||||
} from "@components/inputs";
|
||||
import {
|
||||
ListsMDBListOptions,
|
||||
ListsMetacriticOptions,
|
||||
ListsTraktOptions,
|
||||
ListTypeOptions, OptionBasicTyped,
|
||||
SelectOption
|
||||
} from "@domain/constants";
|
||||
import { DEBUG } from "@components/debug";
|
||||
import {
|
||||
DownloadClientsArrTagsQueryOptions,
|
||||
DownloadClientsQueryOptions,
|
||||
FiltersGetAllQueryOptions
|
||||
} from "@api/queries";
|
||||
import { classNames, sleep } from "@utils";
|
||||
import {
|
||||
ListFilterMultiSelectField,
|
||||
SelectFieldCreatable
|
||||
} from "@components/inputs/select_wide";
|
||||
import { DocsTooltip } from "@components/tooltips/DocsTooltip";
|
||||
import { MultiSelect as RMSC } from "react-multi-select-component";
|
||||
import { useToggle } from "@hooks/hooks.ts";
|
||||
import { DeleteModal } from "@components/modals";
|
||||
|
||||
interface ListAddFormValues {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface AddFormProps {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export function ListAddForm({ isOpen, toggle }: AddFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: clients } = useQuery(DownloadClientsQueryOptions());
|
||||
|
||||
const filterQuery = useQuery(FiltersGetAllQueryOptions());
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (list: List) => APIClient.lists.store(list),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body="List added!" t={t}/>);
|
||||
toggle();
|
||||
},
|
||||
onError: () => {
|
||||
toast.custom((t) => <Toast type="error" body="List could not be added" t={t}/>);
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (formData: unknown) => createMutation.mutate(formData as List);
|
||||
|
||||
const validate = (values: ListAddFormValues) => {
|
||||
const errors = {} as FormikErrors<FormikValues>;
|
||||
if (!values.name)
|
||||
errors.name = "Required";
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
static
|
||||
className="fixed inset-0 overflow-hidden"
|
||||
open={isOpen}
|
||||
onClose={toggle}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<DialogPanel className="absolute inset-y-0 right-0 max-w-full flex">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<div className="w-screen max-w-2xl dark:border-gray-700 border-l">
|
||||
<Formik
|
||||
enableReinitialize={true}
|
||||
initialValues={{
|
||||
enabled: true,
|
||||
type: "",
|
||||
name: "",
|
||||
client_id: 0,
|
||||
url: "",
|
||||
headers: [],
|
||||
api_key: "",
|
||||
filters: [],
|
||||
match_release: false,
|
||||
tags_included: [],
|
||||
tags_excluded: [],
|
||||
include_unmonitored: false,
|
||||
include_alternate_titles: false,
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
validate={validate}
|
||||
>
|
||||
{({ values }) => (
|
||||
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto">
|
||||
<div className="flex-1">
|
||||
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
|
||||
<div className="flex items-start justify-between space-x-3">
|
||||
<div className="space-y-1">
|
||||
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Add List
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200">
|
||||
Auto update filters from lists and arrs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-7 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white dark:bg-gray-700 rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className="sr-only">Close panel</span>
|
||||
<XMarkIcon className="h-6 w-6" aria-hidden="true"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
|
||||
<TextFieldWide
|
||||
name="name"
|
||||
label="Name"
|
||||
required={true}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<div>
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Field name="type" type="select">
|
||||
{({
|
||||
field,
|
||||
form: { setFieldValue }
|
||||
}: FieldProps) => (
|
||||
<Select
|
||||
{...field}
|
||||
isClearable={true}
|
||||
isSearchable={true}
|
||||
components={{
|
||||
Input: common.SelectInput,
|
||||
Control: common.SelectControl,
|
||||
Menu: common.SelectMenu,
|
||||
Option: common.SelectOption,
|
||||
IndicatorSeparator: common.IndicatorSeparator,
|
||||
DropdownIndicator: common.DropdownIndicator
|
||||
}}
|
||||
placeholder="Choose a type"
|
||||
styles={{
|
||||
singleValue: (base) => ({
|
||||
...base,
|
||||
color: "unset"
|
||||
})
|
||||
}}
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
spacing: {
|
||||
...theme.spacing,
|
||||
controlHeight: 30,
|
||||
baseUnit: 2
|
||||
}
|
||||
})}
|
||||
value={field?.value && field.value.value}
|
||||
onChange={(option: unknown) => {
|
||||
// resetForm();
|
||||
|
||||
const opt = option as SelectOption;
|
||||
// setFieldValue("name", option?.label ?? "")
|
||||
setFieldValue(
|
||||
field.name,
|
||||
opt.value ?? ""
|
||||
);
|
||||
}}
|
||||
options={ListTypeOptions}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SwitchGroupWide name="enabled" label="Enabled"/>
|
||||
</div>
|
||||
|
||||
<ListTypeForm listType={values.type} clients={clients ?? []}/>
|
||||
|
||||
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
||||
<div className="px-4 space-y-1">
|
||||
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Filters
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select filters to update for this list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ListFilterMultiSelectField name="filters" label="Filters" options={filterQuery.data?.map(f => ({ value: f.id, label: f.name })) ?? []} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 px-4 border-t border-gray-200 dark:border-gray-700 py-4 sm:px-6">
|
||||
<div className="space-x-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||
onClick={toggle}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<SubmitButton isPending={createMutation.isPending} isError={createMutation.isError} isSuccess={createMutation.isSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DEBUG values={values}/>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
interface UpdateFormProps<T> {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export function ListUpdateForm({ isOpen, toggle, data }: UpdateFormProps<List>) {
|
||||
const cancelModalButtonRef = useRef<HTMLInputElement | null>(null);
|
||||
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const clientsQuery = useQuery(DownloadClientsQueryOptions());
|
||||
const filterQuery = useQuery(FiltersGetAllQueryOptions());
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (list: List) => APIClient.lists.update(list),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`${data.name} was updated successfully`} t={t}/>);
|
||||
|
||||
sleep(1500);
|
||||
toggle();
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (formData: unknown) => mutation.mutate(formData as List);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (listID: number) => APIClient.lists.delete(listID),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`${data.name} was deleted.`} t={t}/>);
|
||||
}
|
||||
});
|
||||
|
||||
const deleteAction = () => deleteMutation.mutate(data.id);
|
||||
|
||||
return (
|
||||
<Transition show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
static
|
||||
className="fixed inset-0 overflow-hidden"
|
||||
open={isOpen}
|
||||
onClose={toggle}
|
||||
>
|
||||
{deleteAction && (
|
||||
<DeleteModal
|
||||
isOpen={deleteModalIsOpen}
|
||||
isLoading={false}
|
||||
toggle={toggleDeleteModal}
|
||||
buttonRef={cancelModalButtonRef}
|
||||
deleteAction={deleteAction}
|
||||
title={`Remove ${data.name}`}
|
||||
text={`Are you sure you want to remove this ${data.name}? This action cannot be undone.`}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<DialogPanel className="absolute inset-y-0 right-0 max-w-full flex">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<div className="w-screen max-w-2xl dark:border-gray-700 border-l">
|
||||
<Formik
|
||||
enableReinitialize={true}
|
||||
initialValues={{
|
||||
id: data.id,
|
||||
enabled: data.enabled,
|
||||
type: data.type,
|
||||
name: data.name,
|
||||
client_id: data.client_id,
|
||||
url: data.url,
|
||||
headers: data.headers || [],
|
||||
api_key: data.api_key,
|
||||
filters: data.filters,
|
||||
match_release: data.match_release,
|
||||
tags_included: data.tags_included,
|
||||
tags_excluded: data.tags_excluded,
|
||||
include_unmonitored: data.include_unmonitored,
|
||||
include_alternate_titles: data.include_alternate_titles,
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
// validate={validate}
|
||||
>
|
||||
{({ values }) => (
|
||||
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto">
|
||||
<div className="flex-1">
|
||||
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
|
||||
<div className="flex items-start justify-between space-x-3">
|
||||
<div className="space-y-1">
|
||||
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Update List
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200">
|
||||
Auto update filters from lists and arrs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-7 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white dark:bg-gray-700 rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className="sr-only">Close panel</span>
|
||||
<XMarkIcon className="h-6 w-6" aria-hidden="true"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
|
||||
|
||||
<TextFieldWide name="name" label="Name" required={true}/>
|
||||
|
||||
<TextFieldWide name="type" label="Type" required={true} disabled={true} />
|
||||
|
||||
<SwitchGroupWide name="enabled" label="Enabled"/>
|
||||
|
||||
<div className="space-y-2 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<ListTypeForm listType={values.type} clients={clientsQuery.data ?? []}/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
||||
<div className="px-4 space-y-1">
|
||||
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Filters
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select filters to update for this list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ListFilterMultiSelectField name="filters" label="Filters" options={filterQuery.data?.map(f => ({
|
||||
value: f.id,
|
||||
label: f.name
|
||||
})) ?? []}/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 px-4 border-t border-gray-200 dark:border-gray-700 py-4">
|
||||
<div className="space-x-3 flex justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 dark:text-white bg-red-100 dark:bg-red-700 hover:bg-red-200 dark:hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
|
||||
onClick={toggleDeleteModal}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||
onClick={toggle}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<SubmitButton isPending={mutation.isPending} isError={mutation.isError} isSuccess={mutation.isSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DEBUG values={values}/>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isPending?: boolean;
|
||||
isError?: boolean;
|
||||
isSuccess?: boolean;
|
||||
}
|
||||
|
||||
const SubmitButton = (props: SubmitButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
className={classNames(
|
||||
// isTestSuccessful
|
||||
// ? "text-green-500 border-green-500 bg-green-50"
|
||||
// : isError
|
||||
// ? "text-red-500 border-red-500 bg-red-50"
|
||||
// : "border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:border-rose-700 active:bg-rose-700",
|
||||
props.isPending ? "cursor-not-allowed" : "",
|
||||
"mr-2 inline-flex items-center px-4 py-2 border font-medium rounded-md shadow-sm text-sm transition ease-in-out duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:border-blue-700 active:bg-blue-700"
|
||||
)}
|
||||
>
|
||||
{props.isPending ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 text-green-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
|
||||
<span className="pl-2">Saving..</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Save</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListTypeFormProps {
|
||||
listID?: number;
|
||||
listType: string;
|
||||
clients: DownloadClient[];
|
||||
}
|
||||
|
||||
const ListTypeForm = (props: ListTypeFormProps) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const [prevActionType, setPrevActionType] = useState<string | null>(null);
|
||||
|
||||
const { listType } = props;
|
||||
|
||||
useEffect(() => {
|
||||
// if (prevActionType !== null && prevActionType !== list.type && ListTypeOptions.map(l => l.value).includes(list.type)) {
|
||||
if (prevActionType !== null && prevActionType !== listType && ListTypeOptions.map(l => l.value).includes(listType as ListType)) {
|
||||
// Reset the client_id field value
|
||||
setFieldValue(`client_id`, 0);
|
||||
}
|
||||
|
||||
setPrevActionType(listType);
|
||||
}, [listType, prevActionType, setFieldValue]);
|
||||
|
||||
switch (props.listType) {
|
||||
case "RADARR":
|
||||
return <ListTypeArr {...props} />;
|
||||
case "SONARR":
|
||||
return <ListTypeArr {...props} />;
|
||||
case "LIDARR":
|
||||
return <ListTypeArr {...props} />;
|
||||
case "READARR":
|
||||
return <ListTypeArr {...props} />;
|
||||
case "WHISPARR":
|
||||
return <ListTypeArr {...props} />;
|
||||
case "TRAKT":
|
||||
return <ListTypeTrakt />;
|
||||
case "STEAM":
|
||||
return <ListTypeSteam />;
|
||||
case "METACRITIC":
|
||||
return <ListTypeMetacritic />;
|
||||
case "MDBLIST":
|
||||
return <ListTypeMDBList />;
|
||||
case "PLAINTEXT":
|
||||
return <ListTypePlainText />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const FilterOptionCheckBoxes = (props: ListTypeFormProps) => {
|
||||
switch (props.listType) {
|
||||
case "RADARR":
|
||||
case "SONARR":
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="sr-only">Settings</legend>
|
||||
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
|
||||
<SwitchGroupWide name="include_unmonitored" label="Include Unmonitored" description="By default only monitored titles are filtered." />
|
||||
<SwitchGroupWide name="include_alternate_titles" label="Include Alternate Titles" description="Include alternate titles in the filter." />
|
||||
</fieldset>
|
||||
);
|
||||
case "LIDARR":
|
||||
case "WHISPARR":
|
||||
case "READARR":
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="sr-only">Settings</legend>
|
||||
<SwitchGroupWide name="include_unmonitored" label="Include Unmonitored" description="By default only monitored titles are filtered." />
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ListTypeArr({ listType, clients }: ListTypeFormProps) {
|
||||
const { values } = useFormikContext<List>();
|
||||
|
||||
useEffect(() => {
|
||||
}, [values.client_id]);
|
||||
|
||||
const arrTagsQuery = useQuery(DownloadClientsArrTagsQueryOptions(values.client_id));
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
||||
<div className="px-4 space-y-1">
|
||||
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Source
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Update filters from titles in Radarr, Sonarr, Lidarr, Readarr, or Whisparr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DownloadClientSelectCustom
|
||||
name={`client_id`}
|
||||
clients={clients}
|
||||
clientType={listType}
|
||||
/>
|
||||
|
||||
{values.client_id > 0 && (values.type === "RADARR" || values.type == "SONARR") && (
|
||||
<>
|
||||
<ListArrTagsMultiSelectField name="tags_included" label="Tags Included" options={arrTagsQuery.data?.map(f => ({
|
||||
value: f.label,
|
||||
label: f.label
|
||||
})) ?? []}/>
|
||||
|
||||
<ListArrTagsMultiSelectField name="tags_excluded" label="Tags Excluded" options={arrTagsQuery.data?.map(f => ({
|
||||
value: f.label,
|
||||
label: f.label
|
||||
})) ?? []}/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<FilterOptionCheckBoxes listType={listType} clients={[]}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListTypeTrakt() {
|
||||
const { values } = useFormikContext<List>();
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
||||
<div className="px-4 space-y-1">
|
||||
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Source list
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Use a Trakt list or one of the default autobrr hosted lists.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SelectFieldCreatable
|
||||
name="url"
|
||||
label="List URL"
|
||||
help="Default Trakt lists. Override with your own."
|
||||
options={ListsTraktOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
|
||||
/>
|
||||
|
||||
{!values.url.startsWith("https://api.autobrr.com/") && (
|
||||
<PasswordFieldWide
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
help="Trakt API Key. Required for private lists."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<fieldset>
|
||||
<legend className="sr-only">Settings</legend>
|
||||
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListTypePlainText() {
|
||||
const { values } = useFormikContext<List>();
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
||||
<div className="px-4 space-y-1">
|
||||
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Source list
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Use a Trakt list or one of the default autobrr hosted lists.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SelectFieldCreatable
|
||||
name="url"
|
||||
label="List URL"
|
||||
help="Default Trakt lists. Override with your own."
|
||||
options={ListsTraktOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
|
||||
/>
|
||||
|
||||
{!values.url.startsWith("https://api.autobrr.com/") && (
|
||||
<PasswordFieldWide
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
help="Trakt API Key. Required for private lists."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<fieldset>
|
||||
<legend className="sr-only">Settings</legend>
|
||||
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListTypeSteam() {
|
||||
return (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
||||
<div className="px-4 space-y-1">
|
||||
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Source list
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Follow Steam wishlists.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TextFieldWide name="url" label="URL" help={"Steam Wishlist URL"} placeholder="https://store.steampowered.com/wishlist/id/USERNAME/wishlistdata"/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListTypeMetacritic() {
|
||||
return (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
||||
<div className="px-4 space-y-1">
|
||||
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Source list
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Use a Metacritic list or one of the default autobrr hosted lists.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SelectFieldCreatable
|
||||
name="url"
|
||||
label="List URL"
|
||||
help="Metacritic lists. Override with your own."
|
||||
options={ListsMetacriticOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
<fieldset>
|
||||
<legend className="sr-only">Settings</legend>
|
||||
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListTypeMDBList() {
|
||||
return (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
||||
<div className="px-4 space-y-1">
|
||||
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Source list
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Use a MDBList list or one of the default autobrr hosted lists.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SelectFieldCreatable
|
||||
name="url"
|
||||
label="List URL"
|
||||
help="MDBLists.com lists. Override with your own."
|
||||
options={ListsMDBListOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
<fieldset>
|
||||
<legend className="sr-only">Settings</legend>
|
||||
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DownloadClientSelectProps {
|
||||
name: string;
|
||||
clientType: string;
|
||||
clients: DownloadClient[];
|
||||
}
|
||||
|
||||
function DownloadClientSelectCustom({ name, clientType, clients }: DownloadClientSelectProps) {
|
||||
return (
|
||||
<div className="flex items-center space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className="block ml-px text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
<div className="flex">
|
||||
Select Client
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Field name={name} type="select">
|
||||
{({
|
||||
field,
|
||||
meta,
|
||||
form: { setFieldValue }
|
||||
}: FieldProps) => (
|
||||
<Listbox
|
||||
value={field.value}
|
||||
onChange={(value) => setFieldValue(field?.name, value)}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
{/*<Label className="block text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">*/}
|
||||
{/* Client*/}
|
||||
{/*</Label>*/}
|
||||
<div className="relative">
|
||||
<ListboxButton
|
||||
className="block w-full shadow-sm sm:text-sm rounded-md border py-2 pl-3 pr-10 text-left focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-815 dark:text-gray-100">
|
||||
<span className="block truncate">
|
||||
{field.value
|
||||
? clients.find((c) => c.id === field.value)?.name
|
||||
: "Choose a client"}
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<ChevronUpDownIcon
|
||||
className="h-5 w-5 text-gray-400 dark:text-gray-300"
|
||||
aria-hidden="true"/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
static
|
||||
className="absolute z-10 mt-1 w-full border border-gray-400 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg max-h-60 rounded-md py-1 text-base overflow-auto focus:outline-none sm:text-sm"
|
||||
>
|
||||
{clients
|
||||
.filter((c) => c.type === clientType)
|
||||
.map((client) => (
|
||||
<ListboxOption
|
||||
key={client.id}
|
||||
className={({ focus }) => classNames(
|
||||
focus
|
||||
? "text-white dark:text-gray-100 bg-blue-600 dark:bg-gray-950"
|
||||
: "text-gray-900 dark:text-gray-300",
|
||||
"cursor-default select-none relative py-2 pl-3 pr-9"
|
||||
)}
|
||||
value={client.id}
|
||||
>
|
||||
{({ selected, focus }) => (
|
||||
<>
|
||||
<span
|
||||
className={classNames(
|
||||
selected ? "font-semibold" : "font-normal",
|
||||
"block truncate"
|
||||
)}
|
||||
>
|
||||
{client.name}
|
||||
</span>
|
||||
|
||||
{selected ? (
|
||||
<span
|
||||
className={classNames(
|
||||
focus ? "text-white dark:text-gray-100" : "text-blue-600 dark:text-blue-500",
|
||||
"absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
)}
|
||||
>
|
||||
<CheckIcon
|
||||
className="h-5 w-5"
|
||||
aria-hidden="true"/>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
{meta.touched && meta.error && (
|
||||
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ListMultiSelectFieldProps {
|
||||
name: string;
|
||||
label: string;
|
||||
help?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
tooltip?: JSX.Element;
|
||||
options: OptionBasicTyped<number | string>[];
|
||||
}
|
||||
|
||||
export function ListArrTagsMultiSelectField({ name, label, help, tooltip, options }: ListMultiSelectFieldProps) {
|
||||
return (
|
||||
<div className="flex items-center space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className="block ml-px text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
<div className="flex">
|
||||
{tooltip ? (
|
||||
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
|
||||
) : label}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Field name={name} type="select">
|
||||
{({
|
||||
field,
|
||||
form: { setFieldValue }
|
||||
}: FieldProps) => (
|
||||
<>
|
||||
<RMSC
|
||||
{...field}
|
||||
options={options}
|
||||
// disabled={disabled}
|
||||
labelledBy={name}
|
||||
// isCreatable={creatable}
|
||||
// onCreateOption={handleNewField}
|
||||
value={field.value && field.value.map((item: MultiSelectOption) => ({
|
||||
value: item.value ? item.value : item,
|
||||
label: item.label ? item.label : item
|
||||
}))}
|
||||
onChange={(values: Array<MultiSelectOption>) => {
|
||||
const am = values && values.map((i) => i.value);
|
||||
|
||||
setFieldValue(field.name, am);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
{help && (
|
||||
<p className="mt-2 text-sm text-gray-500" id={`${name}-description`}>{help}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -29,7 +29,7 @@ import {
|
|||
FeedsQueryOptions,
|
||||
FilterByIdQueryOptions,
|
||||
IndexersQueryOptions,
|
||||
IrcQueryOptions,
|
||||
IrcQueryOptions, ListsQueryOptions,
|
||||
NotificationsQueryOptions,
|
||||
ProxiesQueryOptions
|
||||
} from "@api/queries";
|
||||
|
@ -52,6 +52,7 @@ import { TanStackRouterDevtools } from "@tanstack/router-devtools";
|
|||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { queryClient } from "@api/QueryClient";
|
||||
import ProxySettings from "@screens/settings/Proxy";
|
||||
import ListsSettings from "@screens/settings/Lists";
|
||||
|
||||
import { ErrorPage } from "@components/alerts";
|
||||
|
||||
|
@ -186,6 +187,13 @@ export const SettingsIrcRoute = createRoute({
|
|||
component: IrcSettings
|
||||
});
|
||||
|
||||
export const SettingsListsRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'lists',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(ListsQueryOptions()),
|
||||
component: ListsSettings
|
||||
});
|
||||
|
||||
export const SettingsFeedsRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'feeds',
|
||||
|
@ -364,7 +372,7 @@ export const RootRoute = createRootRouteWithContext<{
|
|||
});
|
||||
|
||||
const filterRouteTree = FiltersRoute.addChildren([FilterIndexRoute, FilterGetByIdRoute.addChildren([FilterGeneralRoute, FilterMoviesTvRoute, FilterMusicRoute, FilterAdvancedRoute, FilterExternalRoute, FilterActionsRoute])])
|
||||
const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsProxiesRoute, SettingsReleasesRoute, SettingsAccountRoute])
|
||||
const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsListsRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsProxiesRoute, SettingsReleasesRoute, SettingsAccountRoute])
|
||||
const authenticatedTree = AuthRoute.addChildren([AuthIndexRoute.addChildren([DashboardRoute, filterRouteTree, ReleasesRoute, settingsRouteTree, LogsRoute])])
|
||||
const routeTree = RootRoute.addChildren([
|
||||
authenticatedTree,
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
BarsArrowDownIcon,
|
||||
BellIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
CogIcon,
|
||||
|
@ -32,6 +33,7 @@ const subNavigation: NavTabType[] = [
|
|||
{ name: "Indexers", href: "/settings/indexers", icon: KeyIcon },
|
||||
{ name: "IRC", href: "/settings/irc", icon: ChatBubbleLeftRightIcon },
|
||||
{ name: "Feeds", href: "/settings/feeds", icon: RssIcon },
|
||||
{ name: "Lists", href: "/settings/lists", icon: BarsArrowDownIcon },
|
||||
{ name: "Clients", href: "/settings/clients", icon: FolderArrowDownIcon },
|
||||
{ name: "Notifications", href: "/settings/notifications", icon: BellIcon },
|
||||
{ name: "API keys", href: "/settings/api", icon: KeyIcon },
|
||||
|
|
|
@ -25,7 +25,7 @@ import {
|
|||
DocumentDuplicateIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
PencilSquareIcon,
|
||||
PlusIcon,
|
||||
PlusIcon, SparklesIcon,
|
||||
TrashIcon
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ArrowDownTrayIcon } from "@heroicons/react/24/solid";
|
||||
|
@ -589,9 +589,9 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
|
|||
params={{
|
||||
filterId: filter.id
|
||||
}}
|
||||
className="transition w-full break-words whitespace-wrap text-sm font-bold text-gray-800 dark:text-gray-100 hover:text-black dark:hover:text-gray-350"
|
||||
className="transition flex items-center w-full break-words whitespace-wrap text-sm font-bold text-gray-800 dark:text-gray-100 hover:text-black dark:hover:text-gray-350"
|
||||
>
|
||||
{filter.name}
|
||||
{filter.name} {filter.is_auto_updated && <SparklesIcon title="This filter is automatically updated by a list" className="ml-1 w-4 h-4 text-amber-500 dark:text-amber-400" aria-hidden="true"/>}
|
||||
</Link>
|
||||
<div className="flex items-center flex-wrap">
|
||||
<span className="mr-2 break-words whitespace-nowrap text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
|
|
196
web/src/screens/settings/Lists.tsx
Normal file
196
web/src/screens/settings/Lists.tsx
Normal file
|
@ -0,0 +1,196 @@
|
|||
/*
|
||||
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { PlusIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
import { useToggle } from "@hooks/hooks";
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { ListKeys } from "@api/query_keys";
|
||||
import { toast } from "@components/hot-toast";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { ListsQueryOptions } from "@api/queries";
|
||||
import { Section } from "@screens/settings/_components";
|
||||
import { EmptySimple } from "@components/emptystates";
|
||||
import { ListAddForm, ListUpdateForm } from "@forms";
|
||||
import { FC } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
function ListsSettings() {
|
||||
const [addFormIsOpen, toggleAddList] = useToggle(false);
|
||||
|
||||
const listsQuery = useSuspenseQuery(ListsQueryOptions())
|
||||
const lists = listsQuery.data
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Lists"
|
||||
description={
|
||||
<>
|
||||
Lists can automatically update your filters from arrs or other sources.<br/>
|
||||
</>
|
||||
}
|
||||
rightSide={
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAddList}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5 mr-1"/>
|
||||
Add new
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<ListAddForm isOpen={addFormIsOpen} toggle={toggleAddList} />
|
||||
|
||||
<div className="flex flex-col">
|
||||
{lists.length ? (
|
||||
<ul className="min-w-full relative">
|
||||
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
|
||||
<div
|
||||
className="flex col-span-2 sm:col-span-1 pl-0 sm:pl-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
|
||||
>
|
||||
Enabled
|
||||
</div>
|
||||
<div
|
||||
className="col-span-5 sm:col-span-4 pl-12 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
|
||||
>
|
||||
Name
|
||||
</div>
|
||||
<div
|
||||
className="hidden md:flex col-span-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
|
||||
>
|
||||
Filters
|
||||
</div>
|
||||
<div
|
||||
className="hidden md:flex col-span-1 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
|
||||
>
|
||||
Type
|
||||
</div>
|
||||
</li>
|
||||
{lists.map((list) => (
|
||||
<ListItem list={list} key={list.id}/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptySimple
|
||||
title="No lists"
|
||||
subtitle=""
|
||||
buttonText="Add new list"
|
||||
buttonAction={toggleAddList}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterPillProps {
|
||||
filter: ListFilter;
|
||||
}
|
||||
|
||||
const FilterPill: FC<FilterPillProps> = ({ filter }) => (
|
||||
<Link
|
||||
className="hidden sm:inline-flex items-center px-2 py-0.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400 hover:dark:bg-gray-750 hover:bg-gray-700"
|
||||
to={`/filters/$filterId`}
|
||||
params={{ filterId: filter.id }}
|
||||
>
|
||||
{filter.name}
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default ListsSettings;
|
||||
|
||||
interface ListItemProps {
|
||||
list: List;
|
||||
}
|
||||
|
||||
function ListItem({ list }: ListItemProps) {
|
||||
const [isOpen, toggleUpdate] = useToggle(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (req: List) => APIClient.lists.update(req),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
|
||||
|
||||
toast.custom(t => <Toast type="success" body={`List ${list.name} was ${list.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
|
||||
},
|
||||
onError: () => {
|
||||
toast.custom((t) => <Toast type="error" body="List state could not be updated" t={t} />);
|
||||
}
|
||||
});
|
||||
|
||||
const onToggleMutation = (newState: boolean) => {
|
||||
updateMutation.mutate({
|
||||
...list,
|
||||
enabled: newState
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<li>
|
||||
<ListUpdateForm isOpen={isOpen} toggle={toggleUpdate} data={list} />
|
||||
|
||||
<div className="grid grid-cols-12 items-center py-1.5">
|
||||
<div className="col-span-2 sm:col-span-1 flex pl-1 sm:pl-5 items-center">
|
||||
<Checkbox value={list.enabled ?? false} setValue={onToggleMutation}/>
|
||||
</div>
|
||||
<div
|
||||
className="col-span-5 sm:col-span-4 pl-12 sm:pr-6 py-3 block flex-col text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{list.name}
|
||||
</div>
|
||||
<div
|
||||
className="hidden md:block col-span-4 pr-6 py-3 space-x-1 text-left items-center whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{/*{list.filters.map(filter => <FilterPill filter={filter} key={filter.id} />)}*/}
|
||||
<ListItemFilters filters={list.filters} />
|
||||
</div>
|
||||
<div
|
||||
className="hidden md:block col-span-2 pr-6 py-3 text-left items-center whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{list.type}
|
||||
</div>
|
||||
<div className="col-span-1 flex first-letter:px-6 py-3 whitespace-nowrap text-right text-sm font-medium">
|
||||
<span
|
||||
className="col-span-1 px-6 text-blue-600 dark:text-gray-300 hover:text-blue-900 dark:hover:text-blue-500 cursor-pointer"
|
||||
onClick={toggleUpdate}
|
||||
>
|
||||
Edit
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListItemFiltersProps {
|
||||
filters: ListFilter[];
|
||||
}
|
||||
|
||||
const ListItemFilters = ({ filters }: ListItemFiltersProps) => {
|
||||
if (!filters.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = filters.slice(2);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-1">
|
||||
<FilterPill filter={filters[0]} />
|
||||
{filters.length > 1 ? (
|
||||
<FilterPill filter={filters[1]} />
|
||||
) : null}
|
||||
{filters.length > 2 ? (
|
||||
<span
|
||||
className="mr-2 inline-flex items-center px-2 py-0.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400"
|
||||
title={res.map(v => v.name).toString()}
|
||||
>
|
||||
+{filters.length - 2}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -9,6 +9,7 @@ export { default as DownloadClient } from "./DownloadClient";
|
|||
export { default as Feed } from "./Feed";
|
||||
export { default as Indexer } from "./Indexer";
|
||||
export { default as Irc } from "./Irc";
|
||||
export { default as Lists } from "./Lists";
|
||||
export { default as Logs } from "./Logs";
|
||||
export { default as Notification } from "./Notifications";
|
||||
export { default as Proxy } from "./Proxy";
|
||||
|
|
5
web/src/types/Download.d.ts
vendored
5
web/src/types/Download.d.ts
vendored
|
@ -64,4 +64,9 @@ interface DownloadClient {
|
|||
username: string;
|
||||
password: string;
|
||||
settings?: DownloadClientSettings;
|
||||
}
|
||||
|
||||
interface ArrTag {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
1
web/src/types/Filter.d.ts
vendored
1
web/src/types/Filter.d.ts
vendored
|
@ -74,6 +74,7 @@ interface Filter {
|
|||
max_seeders: number;
|
||||
min_leechers: number;
|
||||
max_leechers: number;
|
||||
is_auto_updated: boolean;
|
||||
actions_count: number;
|
||||
actions_enabled_count: number;
|
||||
actions: Action[];
|
||||
|
|
44
web/src/types/List.d.ts
vendored
Normal file
44
web/src/types/List.d.ts
vendored
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
interface List {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
type: ListType;
|
||||
client_id: number;
|
||||
url: string;
|
||||
headers: string[];
|
||||
api_key: string;
|
||||
filters: ListFilter[];
|
||||
match_release: boolean;
|
||||
tags_included: string[];
|
||||
tags_excluded: string[];
|
||||
include_unmonitored: boolean;
|
||||
include_alternate_titles: boolean;
|
||||
}
|
||||
|
||||
interface ListFilter {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ListCreate {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
type: ListType;
|
||||
client_id: number;
|
||||
url: string;
|
||||
headers: string[];
|
||||
api_key: string;
|
||||
filters: number[];
|
||||
match_release: boolean;
|
||||
tags_include: string[];
|
||||
tags_exclude: string[];
|
||||
include_unmonitored: boolean;
|
||||
include_alternate_titles: boolean;
|
||||
}
|
||||
|
||||
type ListType = "SONARR" | "RADARR" | "LIDARR" | "READARR" | "WHISPARR" | "MDBLIST" | "TRAKT" | "METACRITIC" | "STEAM" | "PLAINTEXT";
|
Loading…
Add table
Add a link
Reference in a new issue