librqbit 8.1.1

The main library used by rqbit torrent client. The binary is just a small wrapper on top of it.
Documentation
import { useContext, useMemo, useState } from "react";
import { TorrentDetails, TorrentStats } from "../api-types";
import { FormCheckbox } from "./forms/FormCheckbox";
import { CiSquarePlus, CiSquareMinus } from "react-icons/ci";
import { IconButton } from "./buttons/IconButton";
import { formatBytes } from "../helper/formatBytes";
import { ProgressBar } from "./ProgressBar";
import sortBy from "lodash.sortby";
import { APIContext } from "../context";

type TorrentFileForCheckbox = {
  id: number;
  filename: string;
  pathComponents: string[];
  length: number;
  have_bytes: number;
};

type FileTree = {
  id: string;
  name: string;
  dirs: FileTree[];
  files: TorrentFileForCheckbox[];
};

const newFileTree = (
  torrentDetails: TorrentDetails,
  stats: TorrentStats | null
): FileTree => {
  const newFileTreeInner = (
    name: string,
    id: string,
    files: TorrentFileForCheckbox[],
    depth: number
  ): FileTree => {
    let directFiles: TorrentFileForCheckbox[] = [];
    let groups: FileTree[] = [];
    let groupsByName: { [key: string]: TorrentFileForCheckbox[] } = {};

    const getGroup = (prefix: string): TorrentFileForCheckbox[] => {
      groupsByName[prefix] = groupsByName[prefix] || [];
      return groupsByName[prefix];
    };

    files.forEach((file: TorrentFileForCheckbox) => {
      if (depth == file.pathComponents.length - 1) {
        directFiles.push(file);
        return;
      }
      getGroup(file.pathComponents[depth]).push(file);
    });

    directFiles = sortBy(directFiles, (f) => f.filename);

    let sortedGroupsByName = sortBy(
      Object.entries(groupsByName),
      ([k, _]) => k
    );

    let childId = 0;
    for (const [key, value] of sortedGroupsByName) {
      groups.push(newFileTreeInner(key, id + "." + childId, value, depth + 1));
      childId += 1;
    }
    return {
      name,
      id,
      dirs: groups,
      files: directFiles,
    };
  };

  return newFileTreeInner(
    "",
    "filetree-root",
    torrentDetails.files
      .map((file, id) => {
        if (file.attributes.padding) {
          return null;
        }
        return {
          id,
          filename: file.components[file.components.length - 1],
          pathComponents: file.components,
          length: file.length,
          have_bytes: stats ? (stats.file_progress[id] ?? 0) : 0,
        };
      })
      .filter((f) => f !== null),
    0
  );
};

const FileTreeComponent: React.FC<{
  torrentId?: number;
  tree: FileTree;
  torrentDetails: TorrentDetails;
  torrentStats: TorrentStats | null;
  selectedFiles: Set<number>;
  setSelectedFiles: (_: Set<number>) => void;
  initialExpanded: boolean;
  showProgressBar?: boolean;
  disabled?: boolean;
  allowStream?: boolean;
}> = ({
  torrentId,
  tree,
  selectedFiles,
  setSelectedFiles,
  initialExpanded,
  torrentDetails,
  torrentStats,
  showProgressBar,
  disabled,
  allowStream,
}) => {
  const API = useContext(APIContext);
  let [expanded, setExpanded] = useState(initialExpanded);
  let children = useMemo(() => {
    let getAllChildren = (tree: FileTree): number[] => {
      let children = tree.dirs.flatMap(getAllChildren);
      children.push(...tree.files.map((file) => file.id));
      return children;
    };
    return getAllChildren(tree);
  }, [tree]);

  const handleToggleTree: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    if (e.target.checked) {
      let copy = new Set(selectedFiles);
      children.forEach((c) => copy.add(c));
      setSelectedFiles(copy);
    } else {
      let copy = new Set(selectedFiles);
      children.forEach((c) => copy.delete(c));
      setSelectedFiles(copy);
    }
  };

  const handleToggleFile = (toggledId: number) => {
    if (selectedFiles.has(toggledId)) {
      let copy = new Set(selectedFiles);
      copy.delete(toggledId);
      setSelectedFiles(copy);
    } else {
      let copy = new Set(selectedFiles);
      copy.add(toggledId);
      setSelectedFiles(copy);
    }
  };

  const getTotalSelectedFiles = () => {
    return children.filter((c) => selectedFiles.has(c)).length;
  };

  const getTotalSelectedBytes = () => {
    return children
      .filter((c) => selectedFiles.has(c))
      .map((c) => torrentDetails.files[c].length)
      .reduce((a, b) => a + b, 0);
  };

  const fileLink = (file: TorrentFileForCheckbox) => {
    if (allowStream && torrentId != null) {
      return API.getTorrentStreamUrl(torrentId, file.id, file.filename);
    }
  };

  return (
    <>
      <div className="flex items-center">
        <IconButton onClick={() => setExpanded(!expanded)}>
          {expanded ? <CiSquareMinus /> : <CiSquarePlus />}
        </IconButton>
        <FormCheckbox
          checked={children.every((c) => selectedFiles.has(c))}
          label={`${
            tree.name ? tree.name + ", " : ""
          } ${getTotalSelectedFiles()} files, ${formatBytes(
            getTotalSelectedBytes()
          )}`}
          name={tree.id}
          onChange={handleToggleTree}
        ></FormCheckbox>
      </div>

      <div className="pl-5" hidden={!expanded}>
        {tree.dirs.map((dir) => (
          <FileTreeComponent
            torrentId={torrentId}
            torrentDetails={torrentDetails}
            torrentStats={torrentStats}
            key={dir.name}
            tree={dir}
            selectedFiles={selectedFiles}
            setSelectedFiles={setSelectedFiles}
            initialExpanded={false}
            showProgressBar={showProgressBar}
            disabled={disabled}
            allowStream={allowStream}
          />
        ))}
        <div className="pl-1">
          {tree.files.map((file) => (
            <div
              key={file.id}
              className={`${
                showProgressBar
                  ? "grid grid-cols-1 gap-1 items-start lg:grid-cols-2 mb-2 lg:mb-0"
                  : ""
              }`}
            >
              <FormCheckbox
                checked={selectedFiles.has(file.id)}
                label={`${file.filename} (${formatBytes(file.length)})`}
                name={`torrent-${torrentId}-file-${file.id}`}
                disabled={disabled}
                onChange={() => handleToggleFile(file.id)}
                labelLink={fileLink(file)}
              ></FormCheckbox>
              {showProgressBar && (
                <ProgressBar
                  now={(file.have_bytes / file.length) * 100}
                  variant={file.have_bytes == file.length ? "success" : "info"}
                />
              )}
            </div>
          ))}
        </div>
      </div>
    </>
  );
};

export const FileListInput: React.FC<{
  torrentId?: number;
  torrentDetails: TorrentDetails;
  torrentStats: TorrentStats | null;
  selectedFiles: Set<number>;
  setSelectedFiles: (_: Set<number>) => void;
  showProgressBar?: boolean;
  disabled?: boolean;
  allowStream?: boolean;
}> = ({
  torrentId,
  torrentDetails,
  selectedFiles,
  setSelectedFiles,
  torrentStats,
  showProgressBar,
  disabled,
  allowStream,
}) => {
  let fileTree = useMemo(
    () => newFileTree(torrentDetails, torrentStats),
    [torrentDetails, torrentStats]
  );

  return (
    <FileTreeComponent
      torrentId={torrentId}
      torrentDetails={torrentDetails}
      torrentStats={torrentStats}
      tree={fileTree}
      selectedFiles={selectedFiles}
      setSelectedFiles={setSelectedFiles}
      initialExpanded={true}
      showProgressBar={showProgressBar}
      disabled={disabled}
      allowStream={allowStream}
    />
  );
};