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="sm:flex sm:items-start">
|
||||
<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">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
|
|
|
@ -24,9 +24,17 @@ export const CustomTooltip = ({
|
|||
return (
|
||||
<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>
|
||||
<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}
|
||||
</Tooltip>
|
||||
</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,
|
||||
PencilSquareIcon,
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
TrashIcon
|
||||
TrashIcon,
|
||||
ArrowUpOnSquareIcon
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ArrowDownTrayIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
|
@ -33,6 +34,8 @@ import Toast from "@components/notifications/Toast";
|
|||
import { EmptyListState } from "@components/emptystates";
|
||||
import { DeleteModal } from "@components/modals";
|
||||
|
||||
import { Importer } from "./Importer";
|
||||
|
||||
export const filterKeys = {
|
||||
all: ["filters"] as const,
|
||||
lists: () => [...filterKeys.all, "list"] as const,
|
||||
|
@ -78,68 +81,22 @@ const FilterListReducer = (state: FilterListState, action: Actions): FilterListS
|
|||
};
|
||||
|
||||
export function Filters() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createFilterIsOpen, setCreateFilterIsOpen] = useState(false);
|
||||
const toggleCreateFilter = () => {
|
||||
setCreateFilterIsOpen(!createFilterIsOpen);
|
||||
};
|
||||
|
||||
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 (
|
||||
<main>
|
||||
<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>
|
||||
<Menu as="div" className="relative">
|
||||
{({ open }) => (
|
||||
|
@ -154,7 +111,7 @@ export function Filters() {
|
|||
}}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5 mr-1" />
|
||||
Add Filter
|
||||
Create Filter
|
||||
</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">
|
||||
<ChevronDownIcon className="h-5 w-5" />
|
||||
|
@ -169,17 +126,18 @@ export function Filters() {
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
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>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
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)}
|
||||
>
|
||||
Import Filter
|
||||
<ArrowUpOnSquareIcon className="mr-1 w-4 h-4" />Import filter
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
|
@ -190,36 +148,6 @@ export function Filters() {
|
|||
</Menu>
|
||||
</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} />
|
||||
</main>
|
||||
);
|
||||
|
@ -735,7 +663,16 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
|
|||
/>
|
||||
</span>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
|
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