diff --git a/public/locales/en/modules/torrents-status.json b/public/locales/en/modules/torrents-status.json index e359ddaea..cd2674b4d 100644 --- a/public/locales/en/modules/torrents-status.json +++ b/public/locales/en/modules/torrents-status.json @@ -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": { diff --git a/src/pages/api/modules/media-requests/index.spec.ts b/src/pages/api/modules/media-requests/index.spec.ts index 69a6460a1..9f1cb8dcd 100644 --- a/src/pages/api/modules/media-requests/index.spec.ts +++ b/src/pages/api/modules/media-requests/index.spec.ts @@ -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: diff --git a/src/tools/config/migrateConfig.ts b/src/tools/config/migrateConfig.ts index fd783c9aa..defb78a26 100644 --- a/src/tools/config/migrateConfig.ts +++ b/src/tools/config/migrateConfig.ts @@ -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[] => { refreshInterval: 10, displayCompletedTorrents: oldModule.options?.hideComplete?.value ?? false, displayStaleTorrents: true, + labelFilter: [], + labelFilterIsWhitelist: true, }, area: { type: 'wrapper', diff --git a/src/widgets/torrent/TorrentTile.spec.ts b/src/widgets/torrent/TorrentTile.spec.ts new file mode 100644 index 000000000..98f2b8dfe --- /dev/null +++ b/src/widgets/torrent/TorrentTile.spec.ts @@ -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, +}); diff --git a/src/widgets/torrent/TorrentTile.tsx b/src/widgets/torrent/TorrentTile.tsx index 2a6c6f70d..872e69cf0 100644 --- a/src/widgets/torrent/TorrentTile.tsx +++ b/src/widgets/torrent/TorrentTile.tsx @@ -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 ( - + - +
@@ -149,9 +158,24 @@ function TorrentTile({ widget }: TorrentTileProps) { - {torrents.map((torrent, index) => ( + {filteredTorrents.map((torrent, index) => ( ))} + + {filteredTorrents.length !== torrents.length && ( + + + + )}
{t('card.table.header.name')}
MIN_WIDTH_MOBILE ? 6 : 3}> + + + + {t('card.table.body.filterHidingItems', { + count: torrents.length - filteredTorrents.length, + })} + + +
@@ -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;