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:
stacksmash76 2023-09-22 16:44:23 +00:00 committed by GitHub
parent 779383e2a4
commit f72fea998e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 722 additions and 91 deletions

View file

@ -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>

View file

@ -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>
); );
}; };

View 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">
&#8203;
</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>
);
}

View file

@ -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>
</> </>
)} )}

View 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}'`);
}
});*/
}
}

View 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;