feat: add torznab feed support (#246)

* feat(torznab): initial impl

* feat: torznab processing

* feat: torznab more scheduling

* feat: feeds web

* feat(feeds): create on indexer create

* feat(feeds): update migration

* feat(feeds): restart on update

* feat(feeds): set cron schedule

* feat(feeds): use basic empty state

* chore: remove duplicate migrations

* feat: parse release size from torznab

* chore: cleanup unused code
This commit is contained in:
Ludvig Lundgren 2022-04-25 12:58:54 +02:00 committed by GitHub
parent d4d864cd2c
commit bb62e724a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 2408 additions and 361 deletions

View file

@ -0,0 +1,115 @@
import {useMutation} from "react-query";
import {APIClient} from "../../api/APIClient";
import {queryClient} from "../../App";
import {toast} from "react-hot-toast";
import Toast from "../../components/notifications/Toast";
import {SlideOver} from "../../components/panels";
import {NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide} from "../../components/inputs";
import {ImplementationMap} from "../../screens/settings/Feed";
interface UpdateProps {
isOpen: boolean;
toggle: any;
feed: Feed;
}
export function FeedUpdateForm({isOpen, toggle, feed}: UpdateProps) {
const mutation = useMutation(
(feed: Feed) => APIClient.feeds.update(feed),
{
onSuccess: () => {
queryClient.invalidateQueries(["feeds"]);
toast.custom((t) => <Toast type="success" body={`${feed.name} was updated successfully`} t={t}/>)
toggle();
},
}
);
const deleteMutation = useMutation(
(feedID: number) => APIClient.feeds.delete(feedID),
{
onSuccess: () => {
queryClient.invalidateQueries(["feeds"]);
toast.custom((t) => <Toast type="success" body={`${feed.name} was deleted.`} t={t}/>)
},
}
);
const onSubmit = (formData: any) => {
mutation.mutate(formData);
}
const deleteAction = () => {
deleteMutation.mutate(feed.id);
};
const initialValues = {
id: feed.id,
indexer: feed.indexer,
enabled: feed.enabled,
type: feed.type,
name: feed.name,
url: feed.url,
api_key: feed.api_key,
interval: feed.interval,
}
return (
<SlideOver
type="UPDATE"
title="Feed"
isOpen={isOpen}
toggle={toggle}
onSubmit={onSubmit}
deleteAction={deleteAction}
initialValues={initialValues}
>
{(values) => (
<div>
<TextFieldWide name="name" label="Name" required={true}/>
<div className="space-y-4 divide-y divide-gray-200 dark:divide-gray-700">
<div className="py-4 flex items-center justify-between space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
<div>
<label
htmlFor="type"
className="block text-sm font-medium text-gray-900 dark:text-white"
>
Type
</label>
</div>
<div className="flex justify-end sm:col-span-2">
{ImplementationMap[feed.type]}
</div>
</div>
<div className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<SwitchGroupWide name="enabled" label="Enabled"/>
</div>
</div>
{componentMap[values.type]}
</div>
)}
</SlideOver>
)
}
function FormFieldsTorznab() {
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-5">
<TextFieldWide
name="url"
label="URL"
help="Torznab url"
/>
<PasswordFieldWide name="api_key" label="API key" />
<NumberFieldWide name="interval" label="Refresh interval" help="Minutes. Recommended 15-30. To low and risk ban." />
</div>
);
}
const componentMap: any = {
TORZNAB: <FormFieldsTorznab/>,
};

View file

@ -8,7 +8,7 @@ import type { FieldProps } from "formik";
import { XIcon } from "@heroicons/react/solid";
import { Dialog, Transition } from "@headlessui/react";
import { sleep } from "../../utils";
import {sleep, slugify} from "../../utils";
import { queryClient } from "../../App";
import DEBUG from "../../components/debug";
import { APIClient } from "../../api/APIClient";
@ -81,12 +81,37 @@ const IrcSettingFields = (ind: IndexerDefinition, indexer: string) => {
}
return null
})}
</div>
)}
</Fragment>
)
}
}
{/* <div hidden={false}>
<TextFieldWide name="irc.server" label="Server" defaultValue={ind.irc.server} />
<NumberFieldWide name="irc.port" label="Port" defaultValue={ind.irc.port} />
<SwitchGroupWide name="irc.tls" label="TLS" defaultValue={ind.irc.tls} />
</div> */}
const FeedSettingFields = (ind: IndexerDefinition, indexer: string) => {
if (indexer !== "") {
return (
<Fragment>
{ind && ind.torznab && ind.torznab.settings && (
<div className="">
<div className="px-6 space-y-1">
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">Torznab</Dialog.Title>
<p className="text-sm text-gray-500 dark:text-gray-200">
Torznab feed
</p>
</div>
<TextFieldWide name="name" label="Name" defaultValue={""} />
{ind.torznab.settings.map((f: IndexerSetting, idx: number) => {
switch (f.type) {
case "text":
return <TextFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} />
case "secret":
return <PasswordFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} />
}
return null
})}
</div>
)}
</Fragment>
@ -119,6 +144,22 @@ const SettingFields = (ind: IndexerDefinition, indexer: string) => {
}
}
function slugIdentifier(name: string) {
const l = name.toLowerCase()
const r = l.replaceAll("torznab", "")
return slugify(`torznab-${r}`)
}
// interface initialValues {
// enabled: boolean;
// identifier: string;
// implementation: string;
// name: string;
// irc?: Record<string, unknown>;
// feed?: Record<string, unknown>;
// settings?: Record<string, unknown>;
// }
interface AddProps {
isOpen: boolean;
toggle: any;
@ -151,104 +192,77 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
(network: IrcNetworkCreate) => APIClient.irc.createNetwork(network)
);
const feedMutation = useMutation(
(feed: FeedCreate) => APIClient.feeds.create(feed)
);
const onSubmit = (formData: any) => {
const ind = data && data.find(i => i.identifier === formData.identifier);
if (!ind)
return;
const channels: IrcChannel[] = [];
if (ind.irc.channels.length) {
ind.irc.channels.forEach(element => {
channels.push({
id: 0,
enabled: true,
name: element,
password: "",
detached: false,
monitoring: false
if (formData.implementation === "torznab") {
// create slug for indexer identifier as "torznab-indexer_name"
const name = slugIdentifier(formData.name)
const createFeed: FeedCreate = {
name: formData.name,
enabled: false,
type: "TORZNAB",
url: formData.feed.url,
api_key: formData.feed.api_key,
interval: 30,
indexer: name,
indexer_id: 0,
}
mutation.mutate(formData, {
onSuccess: (indexer) => {
createFeed.indexer_id = indexer!.id
feedMutation.mutate(createFeed)
}
});
return;
}
if (formData.implementation === "irc") {
const channels: IrcChannel[] = [];
if (ind.irc?.channels.length) {
ind.irc.channels.forEach(element => {
channels.push({
id: 0,
enabled: true,
name: element,
password: "",
detached: false,
monitoring: false
});
});
}
const network: IrcNetworkCreate = {
name: ind.irc.network,
pass: "",
enabled: false,
connected: false,
server: ind.irc.server,
port: ind.irc.port,
tls: ind.irc.tls,
nickserv: formData.irc.nickserv,
invite_command: formData.irc.invite_command,
channels: channels,
}
mutation.mutate(formData, {
onSuccess: () => {
ircMutation.mutate(network)
}
});
}
const network: IrcNetworkCreate = {
name: ind.irc.network,
pass: "",
enabled: false,
connected: false,
server: ind.irc.server,
port: ind.irc.port,
tls: ind.irc.tls,
nickserv: formData.irc.nickserv,
invite_command: formData.irc.invite_command,
channels: channels,
}
mutation.mutate(formData, {
onSuccess: () => ircMutation.mutate(network)
});
};
const renderSettingFields = (indexer: string) => {
if (indexer !== "") {
const ind = data && data.find(i => i.identifier === indexer);
return (
<div key="opt">
{ind && ind.settings && ind.settings.map((f: any, idx: number) => {
switch (f.type) {
case "text":
return (
<TextFieldWide name={`settings.${f.name}`} label={f.label} key={idx} help={f.help} defaultValue="" />
)
case "secret":
return (
<PasswordFieldWide name={`settings.${f.name}`} label={f.label} key={idx} help={f.help} defaultValue="" />
)
}
return null
})}
<div hidden={true}>
<TextFieldWide name="name" label="Name" defaultValue={ind?.name} />
</div>
</div>
)
}
}
const renderIrcSettingFields = (indexer: string) => {
if (indexer !== "") {
const ind = data && data.find(i => i.identifier === indexer);
return (
<Fragment>
{ind && ind.irc && ind.irc.settings && (
<div className="border-t border-gray-200 dark:border-gray-700 py-5">
<div className="px-6 space-y-1">
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">IRC</Dialog.Title>
<p className="text-sm text-gray-500 dark:text-gray-200">
Networks, channels and invite commands are configured automatically.
</p>
</div>
{ind.irc.settings.map((f: IndexerSetting, idx: number) => {
switch (f.type) {
case "text":
return <TextFieldWide name={`irc.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} />
case "secret":
return <PasswordFieldWide name={`irc.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} />
}
return null
})}
{/* <div hidden={false}>
<TextFieldWide name="irc.server" label="Server" defaultValue={ind.irc.server} />
<NumberFieldWide name="irc.port" label="Port" defaultValue={ind.irc.port} />
<SwitchGroupWide name="irc.tls" label="TLS" defaultValue={ind.irc.tls} />
</div> */}
</div>
)}
</Fragment>
)
}
}
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}>
@ -271,10 +285,10 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
initialValues={{
enabled: true,
identifier: "",
implementation: "irc",
name: "",
irc: {
invite_command: "",
},
irc: {},
feed: {},
settings: {},
}}
onSubmit={onSubmit}
@ -344,8 +358,9 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
setFieldValue(field.name, option?.value ?? "")
const ind = data!.find(i => i.identifier === option.value);
setFieldValue("implementation", ind?.implementation ? ind.implementation : "irc")
setIndexer(ind!)
if (ind!.irc.settings) {
if (ind!.irc?.settings) {
ind!.irc.settings.forEach((s) => {
setFieldValue(`irc.${s.name}`, s.default ?? "")
})
@ -371,6 +386,7 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
</div>
{IrcSettingFields(indexer, values.identifier)}
{FeedSettingFields(indexer, values.identifier)}
</div>
<div
@ -440,7 +456,7 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
}
const renderSettingFields = (settings: IndexerSetting[]) => {
if (settings === undefined) {
if (settings === undefined || settings === null) {
return null
}
@ -468,6 +484,7 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
name: indexer.name,
enabled: indexer.enabled,
identifier: indexer.identifier,
implementation: indexer.implementation,
settings: indexer.settings?.reduce(
(o: Record<string, string>, obj: IndexerSetting) => ({
...o,