mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 16:59:12 +00:00
feat(web): add autodl-irssi filter import (#1132)
* improve filter importing code feat: added autodl-irssi filter importer/parser enhancement: improved filter importing code enhancement: redesigned filter list page fix(DeleteModal): don't center text on mobile fix(CustomTooltip): don't set opacity (avoid console.log spam), update prop names * fix wrong variable ref name mistake * switch position of buttons, use old blue * give back the dropdown menu you stole
This commit is contained in:
parent
779383e2a4
commit
f72fea998e
6 changed files with 722 additions and 91 deletions
|
@ -29,7 +29,7 @@ const ModalUpper = ({ title, text }: ModalUpperProps) => (
|
||||||
<div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
<div className="sm:flex sm:items-start">
|
<div className="sm:flex sm:items-start">
|
||||||
<ExclamationTriangleIcon className="h-16 w-16 text-red-500 dark:text-red-500" aria-hidden="true" />
|
<ExclamationTriangleIcon className="h-16 w-16 text-red-500 dark:text-red-500" aria-hidden="true" />
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:pr-8 sm:text-left max-w-full">
|
<div className="mt-3 text-left sm:mt-0 sm:ml-4 sm:pr-8 max-w-full">
|
||||||
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900 dark:text-white break-words">
|
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900 dark:text-white break-words">
|
||||||
{title}
|
{title}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
|
|
|
@ -24,9 +24,17 @@ export const CustomTooltip = ({
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg id={id} className="ml-1 w-4 h-4 text-gray-500 dark:text-gray-400 fill-current" viewBox="0 0 72 72"><path d="M32 2C15.432 2 2 15.432 2 32s13.432 30 30 30s30-13.432 30-30S48.568 2 32 2m5 49.75H27v-24h10v24m-5-29.5a5 5 0 1 1 0-10a5 5 0 0 1 0 10"/></svg>
|
<svg id={id} className="ml-1 w-4 h-4 text-gray-500 dark:text-gray-400 fill-current" viewBox="0 0 72 72"><path d="M32 2C15.432 2 2 15.432 2 32s13.432 30 30 30s30-13.432 30-30S48.568 2 32 2m5 49.75H27v-24h10v24m-5-29.5a5 5 0 1 1 0-10a5 5 0 0 1 0 10"/></svg>
|
||||||
<Tooltip style= {{ maxWidth: "350px", fontSize: "12px", textTransform: "none", fontWeight: "normal", borderRadius: "0.375rem", backgroundColor: "#34343A", color: "#fff", opacity: "1" }} delayShow={100} delayHide={150} place={place} anchorId={id} data-html={true} clickable={clickable}>
|
<Tooltip
|
||||||
|
style={{ maxWidth: "350px", fontSize: "12px", textTransform: "none", fontWeight: "normal", borderRadius: "0.375rem", backgroundColor: "#34343A", color: "#fff" }}
|
||||||
|
delayShow={100}
|
||||||
|
delayHide={150}
|
||||||
|
place={place}
|
||||||
|
anchorSelect={id}
|
||||||
|
data-html={true}
|
||||||
|
clickable={clickable}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
280
web/src/screens/filters/Importer.tsx
Normal file
280
web/src/screens/filters/Importer.tsx
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
import { Fragment, useRef, useState } from "react";
|
||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import Toast from "@components/notifications/Toast";
|
||||||
|
|
||||||
|
import { filterKeys } from "./List";
|
||||||
|
import { AutodlIrssiConfigParser } from "./_configParser";
|
||||||
|
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
interface ImporterProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (newState: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModalLowerProps extends ImporterProps {
|
||||||
|
onImportClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalUpper = ({ children }: { children: React.ReactNode; }) => (
|
||||||
|
<div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:py-6 sm:px-4 sm:pb-4">
|
||||||
|
<div className="mt-3 text-left sm:mt-0 sm:ml-4 sm:pr-8 max-w-full">
|
||||||
|
<Dialog.Title as="h3" className="mb-3 text-lg leading-6 font-medium text-gray-900 dark:text-white break-words">
|
||||||
|
Import filter (in JSON or autodl-irssi format)
|
||||||
|
</Dialog.Title>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ModalLower = ({ isOpen, setIsOpen, onImportClick }: ModalLowerProps) => (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 border-t border-gray-300 dark:border-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full inline-flex justify-center rounded-md border border-lime-500 shadow-sm px-4 py-2 bg-lime-700 text-base font-medium text-white hover:bg-lime-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-lime-500 sm:ml-3 sm:w-auto sm:text-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isOpen) {
|
||||||
|
onImportClick();
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base 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 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ImportJSON = async (inputFilterText: string) => {
|
||||||
|
let newFilter = {} as Filter;
|
||||||
|
try {
|
||||||
|
const importedData = JSON.parse(inputFilterText);
|
||||||
|
|
||||||
|
// Fetch existing filters from the API
|
||||||
|
const existingFilters = await APIClient.filters.getAll();
|
||||||
|
|
||||||
|
// Create a unique filter title by appending an incremental number if title is taken by another filter
|
||||||
|
let nameCounter = 0;
|
||||||
|
let uniqueFilterName = importedData.name;
|
||||||
|
while (existingFilters.some((filter) => filter.name === uniqueFilterName)) {
|
||||||
|
nameCounter++;
|
||||||
|
uniqueFilterName = `${importedData.name}-${nameCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new filter using the API
|
||||||
|
newFilter = {
|
||||||
|
resolutions: [],
|
||||||
|
sources: [],
|
||||||
|
codecs: [],
|
||||||
|
containers: [],
|
||||||
|
...importedData.data,
|
||||||
|
name: uniqueFilterName
|
||||||
|
} as Filter;
|
||||||
|
|
||||||
|
await APIClient.filters.create(newFilter);
|
||||||
|
|
||||||
|
toast.custom((t) =>
|
||||||
|
<Toast
|
||||||
|
type="success"
|
||||||
|
body={`Filter '${uniqueFilterName}' imported successfully!`}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failure while importing JSON filter: ", e);
|
||||||
|
console.error(" --> Filter: ", newFilter);
|
||||||
|
|
||||||
|
toast.custom((t) =>
|
||||||
|
<Toast
|
||||||
|
type="error"
|
||||||
|
body="Failed to import JSON data. Information logged to console."
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImportAutodlIrssi = async (inputText: string) => {
|
||||||
|
const parser = new AutodlIrssiConfigParser();
|
||||||
|
parser.Parse(inputText);
|
||||||
|
|
||||||
|
let numSuccess = 0;
|
||||||
|
for (const filter of parser.releaseFilters) {
|
||||||
|
try {
|
||||||
|
await APIClient.filters.create(filter.values as unknown as Filter);
|
||||||
|
++numSuccess;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to import autodl-irssi filter '${filter.name}': `, e);
|
||||||
|
console.error(" --> Filter: ", filter);
|
||||||
|
|
||||||
|
toast.custom((t) =>
|
||||||
|
<Toast
|
||||||
|
type="error"
|
||||||
|
body={`Failed to import filter autodl-irssi filter '${filter.name}'. Information logged to console.`}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numSuccess === parser.releaseFilters.length) {
|
||||||
|
toast.custom((t) =>
|
||||||
|
<Toast
|
||||||
|
type="success"
|
||||||
|
body={
|
||||||
|
numSuccess === 1
|
||||||
|
? `Filter '${parser.releaseFilters[0].name}' imported successfully!`
|
||||||
|
: `All ${numSuccess} filters imported successfully!`
|
||||||
|
}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.custom((t) =>
|
||||||
|
<Toast
|
||||||
|
type="info"
|
||||||
|
body={`${numSuccess}/${parser.releaseFilters.length} filters imported successfully. See console for details.`}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Importer = ({
|
||||||
|
isOpen,
|
||||||
|
setIsOpen
|
||||||
|
}: ImporterProps) => {
|
||||||
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const [inputFilterText, setInputFilterText] = useState("");
|
||||||
|
const [parserWarnings, setParserWarnings] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const isJSON = (inputText: string) => (
|
||||||
|
inputText.indexOf("{") <= 3 && inputText.lastIndexOf("}") >= (inputText.length - 3 - 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
const showAutodlImportWarnings = (inputText: string) => {
|
||||||
|
inputText = inputText.trim();
|
||||||
|
|
||||||
|
if (isJSON(inputText)) {
|
||||||
|
// If it's JSON, don't do anything
|
||||||
|
return setParserWarnings([]);
|
||||||
|
} else {
|
||||||
|
const parser = new AutodlIrssiConfigParser();
|
||||||
|
parser.Parse(inputText);
|
||||||
|
|
||||||
|
setParserWarnings(parser.GetWarnings());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// This function handles the import of a filter from a JSON string
|
||||||
|
const handleImportJson = async () => {
|
||||||
|
try {
|
||||||
|
const inputText = inputFilterText.trim();
|
||||||
|
|
||||||
|
if (isJSON(inputText)) {
|
||||||
|
console.log("Parsing import filter as JSON");
|
||||||
|
await ImportJSON(inputText);
|
||||||
|
} else {
|
||||||
|
console.log("Parsing import filter in autodl-irssi format");
|
||||||
|
await ImportAutodlIrssi(inputText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// This should never be called
|
||||||
|
console.error("Critical error while importing filter: ", error);
|
||||||
|
} finally {
|
||||||
|
setIsOpen(false);
|
||||||
|
// Invalidate filter cache, and trigger refresh request
|
||||||
|
await queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
|
<Dialog
|
||||||
|
as="div"
|
||||||
|
static
|
||||||
|
className="fixed z-10 inset-0 overflow-y-auto"
|
||||||
|
initialFocus={textAreaRef}
|
||||||
|
open={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 bg-gray-700/60 dark:bg-black/60 transition-opacity" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
|
​
|
||||||
|
</span>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
>
|
||||||
|
<div className="inline-block align-bottom border border-transparent dark:border-gray-700 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle w-full sm:max-w-6xl">
|
||||||
|
<ModalUpper>
|
||||||
|
<textarea
|
||||||
|
className="form-input resize-y block w-full shadow-sm sm:text-sm rounded-md border py-2.5 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-400 dark:border-gray-700 bg-gray-100 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
placeholder="Paste your filter data here (either autobrr JSON format or your entire autodl-irssi config)"
|
||||||
|
value={inputFilterText}
|
||||||
|
onChange={(event) => {
|
||||||
|
const inputText = event.target.value;
|
||||||
|
showAutodlImportWarnings(inputText);
|
||||||
|
setInputFilterText(inputText);
|
||||||
|
}}
|
||||||
|
style={{ minHeight: "30vh", maxHeight: "50vh" }}
|
||||||
|
/>
|
||||||
|
{parserWarnings.length ? (
|
||||||
|
<>
|
||||||
|
<h4 className="flex flex-row items-center gap-1 text-base text-black dark:text-white mt-2 mb-1">
|
||||||
|
<ExclamationTriangleIcon
|
||||||
|
className="h-6 w-6 text-amber-500 dark:text-yellow-500"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Import Warnings
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto pl-2 pr-1 py-1 rounded-lg min-w-full border border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-400">
|
||||||
|
{parserWarnings.map((line, idx) => (
|
||||||
|
<p key={`parser-warning-${idx}`}>{line}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</ModalUpper>
|
||||||
|
<ModalLower isOpen={isOpen} setIsOpen={setIsOpen} onImportClick={handleImportJson} />
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
}
|
|
@ -20,7 +20,8 @@ import {
|
||||||
EllipsisHorizontalIcon,
|
EllipsisHorizontalIcon,
|
||||||
PencilSquareIcon,
|
PencilSquareIcon,
|
||||||
ChatBubbleBottomCenterTextIcon,
|
ChatBubbleBottomCenterTextIcon,
|
||||||
TrashIcon
|
TrashIcon,
|
||||||
|
ArrowUpOnSquareIcon
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { ArrowDownTrayIcon } from "@heroicons/react/24/solid";
|
import { ArrowDownTrayIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
@ -33,6 +34,8 @@ import Toast from "@components/notifications/Toast";
|
||||||
import { EmptyListState } from "@components/emptystates";
|
import { EmptyListState } from "@components/emptystates";
|
||||||
import { DeleteModal } from "@components/modals";
|
import { DeleteModal } from "@components/modals";
|
||||||
|
|
||||||
|
import { Importer } from "./Importer";
|
||||||
|
|
||||||
export const filterKeys = {
|
export const filterKeys = {
|
||||||
all: ["filters"] as const,
|
all: ["filters"] as const,
|
||||||
lists: () => [...filterKeys.all, "list"] as const,
|
lists: () => [...filterKeys.all, "list"] as const,
|
||||||
|
@ -78,68 +81,22 @@ const FilterListReducer = (state: FilterListState, action: Actions): FilterListS
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Filters() {
|
export function Filters() {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [createFilterIsOpen, setCreateFilterIsOpen] = useState(false);
|
const [createFilterIsOpen, setCreateFilterIsOpen] = useState(false);
|
||||||
const toggleCreateFilter = () => {
|
const toggleCreateFilter = () => {
|
||||||
setCreateFilterIsOpen(!createFilterIsOpen);
|
setCreateFilterIsOpen(!createFilterIsOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [importJson, setImportJson] = useState("");
|
|
||||||
|
|
||||||
// This function handles the import of a filter from a JSON string
|
|
||||||
const handleImportJson = async () => {
|
|
||||||
try {
|
|
||||||
const importedData = JSON.parse(importJson);
|
|
||||||
|
|
||||||
// Extract the filter data and name from the imported object
|
|
||||||
const importedFilter = importedData.data;
|
|
||||||
const filterName = importedData.name;
|
|
||||||
|
|
||||||
// Check if the required properties are present and add them with default values if they are missing
|
|
||||||
const requiredProperties = ["resolutions", "sources", "codecs", "containers"];
|
|
||||||
requiredProperties.forEach((property) => {
|
|
||||||
if (!importedFilter.hasOwnProperty(property)) {
|
|
||||||
importedFilter[property] = [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch existing filters from the API
|
|
||||||
const existingFilters = await APIClient.filters.getAll();
|
|
||||||
|
|
||||||
// Create a unique filter title by appending an incremental number if title is taken by another filter
|
|
||||||
let nameCounter = 0;
|
|
||||||
let uniqueFilterName = filterName;
|
|
||||||
while (existingFilters.some((filter) => filter.name === uniqueFilterName)) {
|
|
||||||
nameCounter++;
|
|
||||||
uniqueFilterName = `${filterName}-${nameCounter}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new filter using the API
|
|
||||||
const newFilter: Filter = {
|
|
||||||
...importedFilter,
|
|
||||||
name: uniqueFilterName
|
|
||||||
};
|
|
||||||
|
|
||||||
await APIClient.filters.create(newFilter);
|
|
||||||
|
|
||||||
// Update the filter list
|
|
||||||
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
|
|
||||||
|
|
||||||
toast.custom((t) => <Toast type="success" body="Filter imported successfully." t={t} />);
|
|
||||||
setShowImportModal(false);
|
|
||||||
} catch (error) {
|
|
||||||
// Log the error and show an error toast message
|
|
||||||
console.error("Error:", error);
|
|
||||||
toast.custom((t) => <Toast type="error" body="Failed to import JSON data. Please check your input." t={t} />);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<FilterAddForm isOpen={createFilterIsOpen} toggle={toggleCreateFilter} />
|
<FilterAddForm isOpen={createFilterIsOpen} toggle={toggleCreateFilter} />
|
||||||
<div className="my-6 max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between">
|
<Importer
|
||||||
|
isOpen={showImportModal}
|
||||||
|
setIsOpen={setShowImportModal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center flex-col sm:flex-row my-6 max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<h1 className="text-3xl font-bold text-black dark:text-white">Filters</h1>
|
<h1 className="text-3xl font-bold text-black dark:text-white">Filters</h1>
|
||||||
<Menu as="div" className="relative">
|
<Menu as="div" className="relative">
|
||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
|
@ -154,7 +111,7 @@ export function Filters() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-5 w-5 mr-1" />
|
<PlusIcon className="h-5 w-5 mr-1" />
|
||||||
Add Filter
|
Create Filter
|
||||||
</button>
|
</button>
|
||||||
<Menu.Button className="relative inline-flex items-center px-2 py-2 border-l border-spacing-1 dark:border-black shadow-sm text-sm font-medium rounded-r-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">
|
<Menu.Button className="relative inline-flex items-center px-2 py-2 border-l border-spacing-1 dark:border-black shadow-sm text-sm font-medium rounded-r-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">
|
||||||
<ChevronDownIcon className="h-5 w-5" />
|
<ChevronDownIcon className="h-5 w-5" />
|
||||||
|
@ -169,17 +126,18 @@ export function Filters() {
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items className="absolute z-10 right-0 mt-0.5 w-46 bg-white dark:bg-gray-700 rounded-md shadow-lg">
|
<Menu.Items className="absolute z-10 right-0 mt-0.5 bg-white dark:bg-gray-700 rounded-md shadow-lg">
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
active ? "bg-gray-50 dark:bg-gray-600" : "",
|
active ? "bg-gray-50 dark:bg-gray-600" : "",
|
||||||
"w-full text-left py-2 px-4 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500")}
|
"flex items-center w-full text-left py-2 px-4 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||||
|
)}
|
||||||
onClick={() => setShowImportModal(true)}
|
onClick={() => setShowImportModal(true)}
|
||||||
>
|
>
|
||||||
Import Filter
|
<ArrowUpOnSquareIcon className="mr-1 w-4 h-4" />Import filter
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
@ -190,36 +148,6 @@ export function Filters() {
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showImportModal && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="w-1/2 md:w-1/2 bg-white dark:bg-gray-800 p-6 rounded-md shadow-lg">
|
|
||||||
<h2 className="text-lg font-medium mb-4 text-black dark:text-white">Import Filter JSON</h2>
|
|
||||||
<textarea
|
|
||||||
className="form-input block w-full resize-y rounded-md border-gray-300 dark:bg-gray-800 dark:border-gray-600 shadow-sm text-sm font-medium text-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-500 mb-4"
|
|
||||||
placeholder="Paste JSON data here"
|
|
||||||
value={importJson}
|
|
||||||
onChange={(event) => setImportJson(event.target.value)}
|
|
||||||
style={{ minHeight: "30vh", maxHeight: "50vh" }}
|
|
||||||
/>
|
|
||||||
<div className="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={() => setShowImportModal(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ml-4 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"
|
|
||||||
onClick={handleImportJson}
|
|
||||||
>
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FilterList toggleCreateFilter={toggleCreateFilter} />
|
<FilterList toggleCreateFilter={toggleCreateFilter} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
@ -735,7 +663,16 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-800 dark:text-gray-500">
|
<span className="text-sm text-gray-800 dark:text-gray-500">
|
||||||
<Tooltip style={{ width: "350px", fontSize: "12px", textTransform: "none", fontWeight: "normal", borderRadius: "0.375rem", backgroundColor: "#34343A", color: "#fff", opacity: "1", whiteSpace: "pre-wrap", overflow: "hidden", textOverflow: "ellipsis" }} delayShow={100} delayHide={150} data-html={true} place="right" data-tooltip-id={`tooltip-actions-${filter.id}`} html="<p>You need to setup an action in the filter otherwise you will not get any snatches.</p>" />
|
<Tooltip
|
||||||
|
style={{ width: "350px", fontSize: "12px", textTransform: "none", fontWeight: "normal", borderRadius: "0.375rem", backgroundColor: "#34343A", color: "#fff", whiteSpace: "pre-wrap", overflow: "hidden", textOverflow: "ellipsis" }}
|
||||||
|
delayShow={100}
|
||||||
|
delayHide={150}
|
||||||
|
data-html={true}
|
||||||
|
place="right"
|
||||||
|
data-tooltip-id={`tooltip-actions-${filter.id}`}
|
||||||
|
>
|
||||||
|
<p>You need to setup an action in the filter otherwise you will not get any snatches.</p>
|
||||||
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
332
web/src/screens/filters/_configParser.ts
Normal file
332
web/src/screens/filters/_configParser.ts
Normal file
|
@ -0,0 +1,332 @@
|
||||||
|
import { formatISO9075 } from "date-fns";
|
||||||
|
import * as CONST from "./_const";
|
||||||
|
|
||||||
|
type SubPrimitive = string | number | boolean | symbol | undefined;
|
||||||
|
type Primitive = SubPrimitive | SubPrimitive[];
|
||||||
|
|
||||||
|
type ValueObject = Record<string, Primitive>;
|
||||||
|
|
||||||
|
class ParserFilter {
|
||||||
|
public values: ValueObject = {};
|
||||||
|
public warnings: string[] = [];
|
||||||
|
|
||||||
|
public name: string;
|
||||||
|
|
||||||
|
constructor(name: string) {
|
||||||
|
this.name = name;
|
||||||
|
this.values = {
|
||||||
|
name: `${name} - autodl-irssi import (${formatISO9075(Date.now())})`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public OnParseLine(key: string, value: string) {
|
||||||
|
if (key in CONST.FILTER_SUBSTITUTION_MAP) {
|
||||||
|
key = CONST.FILTER_SUBSTITUTION_MAP[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "log_score":
|
||||||
|
// In this case we need to set 2 properties in autobrr instead of only 1
|
||||||
|
this.values["log"] = true;
|
||||||
|
|
||||||
|
// log_score is an integer
|
||||||
|
const delim = value.indexOf("-");
|
||||||
|
if (delim !== -1) {
|
||||||
|
value = value.slice(0, delim);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "max_downloads_unit":
|
||||||
|
value = value.toUpperCase();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key in CONST.FILTER_FIELDS) {
|
||||||
|
switch (CONST.FILTER_FIELDS[key]) {
|
||||||
|
case "number":
|
||||||
|
const parsedNum = parseFloat(value);
|
||||||
|
this.values[key] = parsedNum;
|
||||||
|
|
||||||
|
if (isNaN(parsedNum)) {
|
||||||
|
this.warnings.push(
|
||||||
|
`[Filter=${this.name}] Failed to convert field '${key}' to a number. Got value: '${value}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "boolean":
|
||||||
|
this.values[key] = value.toLowerCase() === "true";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.values[key] = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.values[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private FixupMatchLogic(fieldName: string) {
|
||||||
|
const logicAnyField = `${fieldName}_any`;
|
||||||
|
if (logicAnyField in this.values && fieldName in this.values) {
|
||||||
|
this.values[`${fieldName}_match_logic`] = this.values[logicAnyField] ? "ANY" : "ALL";
|
||||||
|
}
|
||||||
|
|
||||||
|
delete this.values[logicAnyField];
|
||||||
|
}
|
||||||
|
|
||||||
|
public FixupValues() {
|
||||||
|
// Force-disable this filter
|
||||||
|
this.values["enabled"] = false;
|
||||||
|
|
||||||
|
// Convert into string arrays if necessary
|
||||||
|
for (const key of Object.keys(this.values)) {
|
||||||
|
// If key is not in FILTER_FIELDS, skip...
|
||||||
|
if (!(key in CONST.FILTER_FIELDS)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyType = CONST.FILTER_FIELDS[key];
|
||||||
|
if (!keyType.endsWith("string")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(this.values[key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split a string by ',' and create an array out of it
|
||||||
|
const entries = (this.values[key] as string)
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim());
|
||||||
|
|
||||||
|
this.values[key] = keyType === "string" ? entries.join(",") : entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add missing []string fields
|
||||||
|
for (const [fieldName, fieldType] of Object.entries(CONST.FILTER_FIELDS)) {
|
||||||
|
if (fieldName in this.values) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === "[]string") {
|
||||||
|
this.values[fieldName] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.FixupMatchLogic("tags");
|
||||||
|
this.FixupMatchLogic("except_tags");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParserIrcNetwork {
|
||||||
|
public values: ValueObject = {};
|
||||||
|
public warnings: string[] = [];
|
||||||
|
|
||||||
|
private serverName: string;
|
||||||
|
|
||||||
|
constructor(serverName: string) {
|
||||||
|
this.serverName = serverName;
|
||||||
|
this.values = {
|
||||||
|
"name": serverName,
|
||||||
|
"server": serverName,
|
||||||
|
"channels": []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public OnParseLine(key: string, value: string) {
|
||||||
|
this.warnings.push(
|
||||||
|
`[IrcNetwork=${this.serverName}] Autobrr currently doesn't support import of field '${key}' (value: ${value})`
|
||||||
|
);
|
||||||
|
/*if (key in CONST.IRC_SUBSTITUTION_MAP) {
|
||||||
|
key = CONST.IRC_SUBSTITUTION_MAP[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key in CONST.IRC_FIELDS) {
|
||||||
|
switch (CONST.IRC_FIELDS[key]) {
|
||||||
|
case "number":
|
||||||
|
const parsedNum = parseFloat(value);
|
||||||
|
this.values[key] = parsedNum;
|
||||||
|
|
||||||
|
if (isNaN(parsedNum)) {
|
||||||
|
this.warnings.push(
|
||||||
|
`[IrcNetwork=${this.serverName}] Failed to convert field '${key}' to a number. Got value: '${value}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "boolean":
|
||||||
|
this.values[key] = value.toLowerCase() === "true";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.values[key] = value;
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
|
||||||
|
public FixupValues() {
|
||||||
|
this.values["enabled"] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GetChannels() {
|
||||||
|
return this.values["channels"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParserIrcChannel {
|
||||||
|
public values: ValueObject = {};
|
||||||
|
public warnings: string[] = [];
|
||||||
|
|
||||||
|
public serverName: string;
|
||||||
|
|
||||||
|
constructor(serverName: string) {
|
||||||
|
this.serverName = serverName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OnParseLine(key: string, value: string) {
|
||||||
|
// TODO: autobrr doesn't respect invite-command
|
||||||
|
// if (["name", "password"].includes(key))
|
||||||
|
// this.values[key] = value;
|
||||||
|
|
||||||
|
this.warnings.push(
|
||||||
|
`[IrcChannel=${this.serverName}] Autobrr currently doesn't support import of field '${key}' (value: ${value})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FixupValues() {
|
||||||
|
this.values["enabled"] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// erm.. todo?
|
||||||
|
const TRACKER = "tracker" as const;
|
||||||
|
const OPTIONS = "options" as const;
|
||||||
|
// *cough* later dude, trust me *cough*
|
||||||
|
const FILTER = "filter" as const;
|
||||||
|
const SERVER = "server" as const;
|
||||||
|
const CHANNEL = "channel" as const;
|
||||||
|
|
||||||
|
export class AutodlIrssiConfigParser {
|
||||||
|
// Temporary storage objects
|
||||||
|
private releaseFilter?: ParserFilter = undefined;
|
||||||
|
private ircNetwork?: ParserIrcNetwork = undefined;
|
||||||
|
private ircChannel?: ParserIrcChannel = undefined;
|
||||||
|
|
||||||
|
// Where we'll keep our parsed objects
|
||||||
|
public releaseFilters: ParserFilter[] = [];
|
||||||
|
public ircNetworks: ParserIrcNetwork[] = [];
|
||||||
|
public ircChannels: ParserIrcChannel[] = [];
|
||||||
|
|
||||||
|
private regexHeader: RegExp = new RegExp(/\[([^\s]*)\s?(.*?)?]/);
|
||||||
|
private regexKeyValue: RegExp = new RegExp(/([^\s]*)\s?=\s?(.*)/);
|
||||||
|
|
||||||
|
private sectionName: string = "";
|
||||||
|
|
||||||
|
// Save content we've parsed so far
|
||||||
|
private Save() {
|
||||||
|
if (this.releaseFilter !== undefined) {
|
||||||
|
this.releaseFilter.FixupValues();
|
||||||
|
this.releaseFilters.push(this.releaseFilter);
|
||||||
|
} else if (this.ircNetwork !== undefined) {
|
||||||
|
this.ircNetwork.FixupValues();
|
||||||
|
this.ircNetworks.push(this.ircNetwork);
|
||||||
|
} else if (this.ircChannel !== undefined) {
|
||||||
|
this.ircChannel.FixupValues();
|
||||||
|
this.ircChannels.push(this.ircChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.releaseFilter = undefined;
|
||||||
|
this.ircNetwork = undefined;
|
||||||
|
this.ircChannel = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GetHeader(line: string): boolean {
|
||||||
|
const match = line.match(this.regexHeader);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.Save();
|
||||||
|
this.sectionName = match[1];
|
||||||
|
|
||||||
|
const rightLeftover = match[2];
|
||||||
|
if (!rightLeftover) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (match[1]) {
|
||||||
|
case FILTER:
|
||||||
|
this.releaseFilter = new ParserFilter(rightLeftover);
|
||||||
|
break;
|
||||||
|
case SERVER:
|
||||||
|
this.ircNetwork = new ParserIrcNetwork(rightLeftover);
|
||||||
|
break;
|
||||||
|
case CHANNEL:
|
||||||
|
this.ircChannel = new ParserIrcChannel(rightLeftover);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GetWarnings() {
|
||||||
|
return this.releaseFilters.flatMap((filter) => filter.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Parse(content: string) {
|
||||||
|
content.split("\n").forEach((line) => {
|
||||||
|
line = line.trim();
|
||||||
|
|
||||||
|
if (!line.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header was parsed, go further
|
||||||
|
if (this.GetHeader(line)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.sectionName.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = line.match(this.regexKeyValue);
|
||||||
|
if (!match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = match[1].replaceAll("-", "_").trim();
|
||||||
|
const value = match[2].trim();
|
||||||
|
|
||||||
|
if (this.releaseFilter) {
|
||||||
|
this.releaseFilter.OnParseLine(key, value);
|
||||||
|
} else if (this.ircNetwork !== undefined) {
|
||||||
|
this.ircNetwork.OnParseLine(key, value);
|
||||||
|
} else if (this.ircChannel !== undefined) {
|
||||||
|
this.ircChannel.OnParseLine(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save the remainder
|
||||||
|
this.Save();
|
||||||
|
|
||||||
|
// TODO: we don't support importing of irc networks/channels
|
||||||
|
/*this.ircChannels.forEach((channel) => {
|
||||||
|
let foundNetwork = false;
|
||||||
|
for (let i = 0; i < this.ircNetworks.length; ++i) {
|
||||||
|
if (channel.serverName === this.ircNetworks[i].values["server"]) {
|
||||||
|
this.ircNetworks[i].values["channels"].push(channel.values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundNetwork) {
|
||||||
|
this.warnings.push(`Failed to find related IRC network for channel '${channel.serverName}'`);
|
||||||
|
}
|
||||||
|
});*/
|
||||||
|
}
|
||||||
|
}
|
74
web/src/screens/filters/_const.ts
Normal file
74
web/src/screens/filters/_const.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
export const FILTER_FIELDS: Record<string, string> = {
|
||||||
|
"id": "number",
|
||||||
|
"enabled": "boolean",
|
||||||
|
"delay": "number",
|
||||||
|
"priority": "number",
|
||||||
|
"log_score": "number",
|
||||||
|
"max_downloads": "number",
|
||||||
|
"use_regex": "boolean",
|
||||||
|
"scene": "boolean",
|
||||||
|
"smart_episode": "boolean",
|
||||||
|
"freeleech": "boolean",
|
||||||
|
"perfect_flac": "boolean",
|
||||||
|
"download_duplicates": "boolean",
|
||||||
|
"cue": "boolean",
|
||||||
|
"log": "boolean",
|
||||||
|
"match_releases": "string",
|
||||||
|
"except_releases": "string",
|
||||||
|
"match_release_groups": "string",
|
||||||
|
"except_release_groups": "string",
|
||||||
|
"shows": "string",
|
||||||
|
"seasons": "string",
|
||||||
|
"episodes": "string",
|
||||||
|
"years": "string",
|
||||||
|
"artists": "string",
|
||||||
|
"albums": "string",
|
||||||
|
"except_release_types": "string",
|
||||||
|
"match_categories": "string",
|
||||||
|
"except_categories": "string",
|
||||||
|
"match_uploaders": "string",
|
||||||
|
"except_uploaders": "string",
|
||||||
|
"tags": "string",
|
||||||
|
"except_tags": "string",
|
||||||
|
"match_sites": "string",
|
||||||
|
"except_sites": "string",
|
||||||
|
"origins": "[]string",
|
||||||
|
"except_origins": "[]string",
|
||||||
|
"bonus": "[]string",
|
||||||
|
"resolutions": "[]string",
|
||||||
|
"codecs": "[]string",
|
||||||
|
"sources": "[]string",
|
||||||
|
"containers": "[]string",
|
||||||
|
"match_hdr": "[]string",
|
||||||
|
"except_hdr": "[]string",
|
||||||
|
"match_other": "[]string",
|
||||||
|
"except_other": "[]string",
|
||||||
|
"match_release_types": "[]string",
|
||||||
|
"tags_any": "boolean",
|
||||||
|
"except_tags_any": "boolean",
|
||||||
|
"formats": "[]string",
|
||||||
|
"quality": "[]string",
|
||||||
|
"media": "[]string"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const IRC_FIELDS: Record<string, string> = {
|
||||||
|
"enabled": "boolean",
|
||||||
|
"port": "number",
|
||||||
|
"tls": "boolean"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const IRC_SUBSTITUTION_MAP: Record<string, string> = {
|
||||||
|
"ssl": "tls",
|
||||||
|
"nick": "nickserv_account",
|
||||||
|
"ident_password": "nickserv_password",
|
||||||
|
"server-password": "pass",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const FILTER_SUBSTITUTION_MAP: Record<string, string> = {
|
||||||
|
"freeleech_percents": "freeleech_percent",
|
||||||
|
"encoders": "codecs",
|
||||||
|
"bitrates": "quality",
|
||||||
|
"max_downloads_per": "max_downloads_unit",
|
||||||
|
"log_scores": "log_score",
|
||||||
|
"upload_delay_secs": "delay"
|
||||||
|
} as const;
|
Loading…
Add table
Add a link
Reference in a new issue