mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-11 16:05:47 +01:00
✨ Add label filter for torrent widget (#865)
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
214
src/widgets/torrent/TorrentTile.spec.ts
Normal file
214
src/widgets/torrent/TorrentTile.spec.ts
Normal 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,
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user