Add label filter for torrent widget (#865)

This commit is contained in:
Manuel
2023-05-03 12:50:11 +02:00
committed by GitHub
parent 678c8d0018
commit 50aba040e4
5 changed files with 319 additions and 22 deletions

View File

@@ -12,6 +12,13 @@
},
"displayStaleTorrents": {
"label": "Display stale torrents"
},
"labelFilterIsWhitelist": {
"label": "Label list is a whitelist (instead of blacklist)"
},
"labelFilter": {
"label": "Label list",
"description": "When 'is whitelist' checked, this will act as a whitelist. If not checked, this is a blacklist. Will not do anything when empty"
}
}
},
@@ -33,7 +40,8 @@
"text": "Managed by {{appName}}, {{ratio}} ratio"
},
"body": {
"nothingFound": "No torrents found"
"nothingFound": "No torrents found",
"filterHidingItems": "{{count}} entries are hidden by your filters"
}
},
"lineChart": {

View File

@@ -1,8 +1,13 @@
import Consola from 'consola';
import { createMocks } from 'node-mocks-http';
import { describe, expect, it, vi } from 'vitest';
import 'vitest-fetch-mock';
import { ConfigType } from '../../../../types/config';
import MediaRequestsRoute from './index';
const mockedGetConfig = vi.fn();
@@ -88,6 +93,9 @@ describe('media-requests api', () => {
apps: [
{
url: 'http://my-overseerr.local',
behaviour: {
externalUrl: 'http://my-overseerr.external',
},
integration: {
type: 'overseerr',
properties: [
@@ -272,7 +280,7 @@ describe('media-requests api', () => {
airDate: '2023-12-08',
backdropPath: 'https://image.tmdb.org/t/p/original//mhjq8jr0qgrjnghnh.jpg',
createdAt: '2023-04-06T19:38:45.000Z',
href: 'http://my-overseerr.local/movie/99999999',
href: 'http://my-overseerr.external/movie/99999999',
id: 44,
name: 'Homarrrr Movie',
posterPath:

View File

@@ -1,19 +1,21 @@
import Consola from 'consola';
import { v4 as uuidv4 } from 'uuid';
import { Config, serviceItem } from '../types';
import { ConfigAppIntegrationType, ConfigAppType, IntegrationType } from '../../types/app';
import { AreaType } from '../../types/area';
import { CategoryType } from '../../types/category';
import { BackendConfigType } from '../../types/config';
import { SearchEngineCommonSettingsType } from '../../types/settings';
import { IWidget } from '../../widgets/widgets';
import { ICalendarWidget } from '../../widgets/calendar/CalendarTile';
import { IDashDotTile } from '../../widgets/dashDot/DashDotTile';
import { IDateWidget } from '../../widgets/date/DateTile';
import { ITorrent } from '../../widgets/torrent/TorrentTile';
import { ITorrentNetworkTraffic } from '../../widgets/download-speed/TorrentNetworkTrafficTile';
import { ITorrent } from '../../widgets/torrent/TorrentTile';
import { IUsenetWidget } from '../../widgets/useNet/UseNetTile';
import { IWeatherWidget } from '../../widgets/weather/WeatherTile';
import { IWidget } from '../../widgets/widgets';
import { Config, serviceItem } from '../types';
export function migrateConfig(config: Config): BackendConfigType {
const newConfig: BackendConfigType = {
@@ -189,6 +191,8 @@ const migrateModules = (config: Config): IWidget<string, any>[] => {
refreshInterval: 10,
displayCompletedTorrents: oldModule.options?.hideComplete?.value ?? false,
displayStaleTorrents: true,
labelFilter: [],
labelFilterIsWhitelist: true,
},
area: {
type: 'wrapper',

View File

@@ -0,0 +1,214 @@
import { NormalizedTorrent, TorrentState } from '@ctrl/shared-torrent';
import { describe, it, expect } from 'vitest';
import { ITorrent, filterTorrents } from './TorrentTile';
describe('TorrentTile', () => {
it('filter torrents when stale', () => {
// arrange
const widget: ITorrent = {
id: 'abc',
area: {
type: 'sidebar',
properties: {
location: 'left',
},
},
shape: {},
type: 'torrents-status',
properties: {
labelFilter: [],
labelFilterIsWhitelist: false,
displayCompletedTorrents: true,
displayStaleTorrents: false,
},
};
const torrents: NormalizedTorrent[] = [
constructTorrent('ABC', 'Nice Torrent', false, 672),
constructTorrent('HH', 'I am completed', true, 0),
constructTorrent('HH', 'I am stale', false, 0),
];
// act
const filtered = filterTorrents(widget, torrents);
// assert
expect(filtered.length).toBe(2);
expect(filtered.includes(torrents[0])).toBe(true);
expect(filtered.includes(torrents[1])).toBe(true);
expect(filtered.includes(torrents[2])).toBe(false);
});
it('not filter torrents when stale', () => {
// arrange
const widget: ITorrent = {
id: 'abc',
area: {
type: 'sidebar',
properties: {
location: 'left',
},
},
shape: {},
type: 'torrents-status',
properties: {
labelFilter: [],
labelFilterIsWhitelist: false,
displayCompletedTorrents: true,
displayStaleTorrents: true,
},
};
const torrents: NormalizedTorrent[] = [
constructTorrent('ABC', 'Nice Torrent', false, 672),
constructTorrent('HH', 'I am completed', true, 0),
constructTorrent('HH', 'I am stale', false, 0),
];
// act
const filtered = filterTorrents(widget, torrents);
// assert
expect(filtered.length).toBe(3);
expect(filtered.includes(torrents[0])).toBe(true);
expect(filtered.includes(torrents[1])).toBe(true);
expect(filtered.includes(torrents[2])).toBe(true);
});
it('filter when completed', () => {
// arrange
const widget: ITorrent = {
id: 'abc',
area: {
type: 'sidebar',
properties: {
location: 'left',
},
},
shape: {},
type: 'torrents-status',
properties: {
labelFilter: [],
labelFilterIsWhitelist: false,
displayCompletedTorrents: false,
displayStaleTorrents: true,
},
};
const torrents: NormalizedTorrent[] = [
constructTorrent('ABC', 'Nice Torrent', false, 672),
constructTorrent('HH', 'I am completed', true, 0),
constructTorrent('HH', 'I am stale', false, 0),
];
// act
const filtered = filterTorrents(widget, torrents);
// assert
expect(filtered.length).toBe(2);
expect(filtered.at(0)).toBe(torrents[0]);
expect(filtered.includes(torrents[1])).toBe(false);
expect(filtered.at(1)).toBe(torrents[2]);
});
it('filter by label when whitelist', () => {
// arrange
const widget: ITorrent = {
id: 'abc',
area: {
type: 'sidebar',
properties: {
location: 'left',
},
},
shape: {},
type: 'torrents-status',
properties: {
labelFilter: ['music', 'movie'],
labelFilterIsWhitelist: true,
displayCompletedTorrents: true,
displayStaleTorrents: true,
},
};
const torrents: NormalizedTorrent[] = [
constructTorrent('1', 'A sick drop', false, 672, 'music'),
constructTorrent('2', 'I cried', true, 0, 'movie'),
constructTorrent('3', 'Great Animations', false, 0, 'anime'),
];
// act
const filtered = filterTorrents(widget, torrents);
// assert
expect(filtered.length).toBe(2);
expect(filtered.at(0)).toBe(torrents[0]);
expect(filtered.at(1)).toBe(torrents[1]);
expect(filtered.includes(torrents[2])).toBe(false);
});
it('filter by label when blacklist', () => {
// arrange
const widget: ITorrent = {
id: 'abc',
area: {
type: 'sidebar',
properties: {
location: 'left',
},
},
shape: {},
type: 'torrents-status',
properties: {
labelFilter: ['music', 'movie'],
labelFilterIsWhitelist: false,
displayCompletedTorrents: false,
displayStaleTorrents: true,
},
};
const torrents: NormalizedTorrent[] = [
constructTorrent('ABC', 'Nice Torrent', false, 672, 'anime'),
constructTorrent('HH', 'I am completed', true, 0, 'movie'),
constructTorrent('HH', 'I am stale', false, 0, 'tv'),
];
// act
const filtered = filterTorrents(widget, torrents);
// assert
expect(filtered.length).toBe(2);
expect(filtered.at(0)).toBe(torrents[0]);
expect(filtered.includes(torrents[1])).toBe(false);
expect(filtered.at(1)).toBe(torrents[2]);
});
});
const constructTorrent = (
id: string,
name: string,
isCompleted: boolean,
downloadSpeed: number,
label?: string,
): NormalizedTorrent => ({
id,
name,
connectedPeers: 1,
connectedSeeds: 4,
dateAdded: '0',
downloadSpeed,
eta: 500,
isCompleted,
progress: 50,
queuePosition: 1,
ratio: 5.6,
raw: false,
savePath: '/downloads',
state: TorrentState.downloading,
stateMessage: 'Downloading',
totalDownloaded: 23024335,
totalPeers: 10,
totalSeeds: 450,
totalSize: 839539535,
totalSelected: 0,
totalUploaded: 378535535,
uploadSpeed: 8349,
label,
});

View File

@@ -1,3 +1,5 @@
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import {
Badge,
Center,
@@ -11,17 +13,22 @@ import {
Title,
} from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { IconFileDownload } from '@tabler/icons';
import { IconFileDownload, IconInfoCircle } from '@tabler/icons';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useTranslation } from 'next-i18next';
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed';
import { NormalizedDownloadQueueResponse } from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
import { AppIntegrationType } from '../../types/app';
import { defineWidget } from '../helper';
import { IWidget } from '../widgets';
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
import { AppIntegrationType } from '../../types/app';
import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed';
import { NormalizedDownloadQueueResponse } from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
import { BitTorrrentQueueItem } from './TorrentQueueItem';
dayjs.extend(duration);
@@ -41,6 +48,14 @@ const definition = defineWidget({
type: 'switch',
defaultValue: true,
},
labelFilterIsWhitelist: {
type: 'switch',
defaultValue: true,
},
labelFilter: {
type: 'multiple-text',
defaultValue: [] as string[],
},
},
gridstack: {
minWidth: 2,
@@ -59,7 +74,7 @@ interface TorrentTileProps {
function TorrentTile({ widget }: TorrentTileProps) {
const { t } = useTranslation('modules/torrents-status');
const { width } = useElementSize();
const { width, ref } = useElementSize();
const {
data,
@@ -121,23 +136,17 @@ function TorrentTile({ widget }: TorrentTileProps) {
);
}
const torrents = data.apps
.flatMap((app) => (app.type === 'torrent' ? app.torrents : []))
.filter((torrent) => (widget.properties.displayCompletedTorrents ? true : !torrent.isCompleted))
.filter((torrent) =>
widget.properties.displayStaleTorrents
? true
: torrent.isCompleted || torrent.downloadSpeed > 0
);
const torrents = data.apps.flatMap((app) => (app.type === 'torrent' ? app.torrents : []));
const filteredTorrents = filterTorrents(widget, torrents);
const difference = new Date().getTime() - dataUpdatedAt;
const duration = dayjs.duration(difference, 'ms');
const humanizedDuration = duration.humanize();
return (
<Flex direction="column" sx={{ height: '100%' }}>
<Flex direction="column" sx={{ height: '100%' }} ref={ref}>
<ScrollArea sx={{ height: '100%', width: '100%' }} mb="xs">
<Table highlightOnHover p="sm">
<Table striped highlightOnHover p="sm">
<thead>
<tr>
<th>{t('card.table.header.name')}</th>
@@ -149,9 +158,24 @@ function TorrentTile({ widget }: TorrentTileProps) {
</tr>
</thead>
<tbody>
{torrents.map((torrent, index) => (
{filteredTorrents.map((torrent, index) => (
<BitTorrrentQueueItem key={index} torrent={torrent} app={undefined} />
))}
{filteredTorrents.length !== torrents.length && (
<tr>
<td colSpan={width > MIN_WIDTH_MOBILE ? 6 : 3}>
<Flex gap="xs" align="center" justify="center">
<IconInfoCircle opacity={0.7} size={18} />
<Text align="center" color="dimmed">
{t('card.table.body.filterHidingItems', {
count: torrents.length - filteredTorrents.length,
})}
</Text>
</Flex>
</td>
</tr>
)}
</tbody>
</Table>
</ScrollArea>
@@ -170,4 +194,43 @@ function TorrentTile({ widget }: TorrentTileProps) {
);
}
export const filterTorrents = (widget: ITorrent, torrents: NormalizedTorrent[]) => {
let result = torrents;
if (!widget.properties.displayCompletedTorrents) {
result = result.filter((torrent) => !torrent.isCompleted);
}
if (widget.properties.labelFilter.length > 0) {
result = filterTorrentsByLabels(
result,
widget.properties.labelFilter,
widget.properties.labelFilterIsWhitelist
);
}
result = filterStaleTorrent(widget, result);
return result;
};
const filterStaleTorrent = (widget: ITorrent, torrents: NormalizedTorrent[]) => {
if (widget.properties.displayStaleTorrents) {
return torrents;
}
return torrents.filter((torrent) => torrent.isCompleted || torrent.downloadSpeed > 0);
};
const filterTorrentsByLabels = (
torrents: NormalizedTorrent[],
labels: string[],
isWhitelist: boolean
) => {
if (isWhitelist) {
return torrents.filter((torrent) => torrent.label && labels.includes(torrent.label));
}
return torrents.filter((torrent) => !labels.includes(torrent.label as string));
};
export default definition;