From 7376266913ca89fc18432506f4400d8f9a48dad0 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 20 Jun 2026 23:19:34 +0200 Subject: [PATCH] feat(requests): add more sorting options --- next-env.d.ts | 2 +- seerr-api.yml | 3 +- server/lib/requestSort.test.ts | 381 +++++++++++++++++++++++++++ server/lib/requestSort.ts | 187 +++++++++++++ server/routes/request.test.ts | 110 ++++++++ server/routes/request.ts | 55 ++-- src/components/RequestList/index.tsx | 30 ++- src/i18n/locale/en.json | 4 + 8 files changed, 743 insertions(+), 29 deletions(-) create mode 100644 server/lib/requestSort.test.ts create mode 100644 server/lib/requestSort.ts diff --git a/next-env.d.ts b/next-env.d.ts index 19709046af..7996d352f4 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/seerr-api.yml b/seerr-api.yml index 6d58dd3cbf..47defc6a62 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -6325,10 +6325,11 @@ paths: name: sort schema: type: string - enum: [added, modified] + enum: [added, modified, popularity, releaseDate, voteAverage, title] default: added - in: query name: sortDirection + description: Sort direction for the selected sort field. schema: type: string enum: [asc, desc] diff --git a/server/lib/requestSort.test.ts b/server/lib/requestSort.test.ts new file mode 100644 index 0000000000..05d6023b2d --- /dev/null +++ b/server/lib/requestSort.test.ts @@ -0,0 +1,381 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; +import { + getRequestSortColumn, + isTmdbRequestSort, + parseRequestSort, + sortRequestsByTmdbField, +} from '@server/lib/requestSort'; + +function createRequest( + id: number, + tmdbId: number, + type: MediaType = MediaType.MOVIE +): MediaRequest { + return { + id, + type, + media: { tmdbId }, + } as MediaRequest; +} + +function createTmdbMock( + movies: Record< + number, + { + original_title: string; + popularity: number; + vote_average: number; + release_date: string; + } + > = {}, + tv: Record< + number, + { + original_name: string; + popularity: number; + vote_average: number; + first_air_date: string; + } + > = {}, + failingIds: Set = new Set() +) { + let movieFetchCount = 0; + let tvFetchCount = 0; + + const tmdb = { + getMovie: async ({ movieId }: { movieId: number }) => { + movieFetchCount++; + + if (failingIds.has(movieId) || !movies[movieId]) { + throw new Error('Movie not found'); + } + + return movies[movieId]; + }, + getTvShow: async ({ tvId }: { tvId: number }) => { + tvFetchCount++; + + if (failingIds.has(tvId) || !tv[tvId]) { + throw new Error('TV show not found'); + } + + return tv[tvId]; + }, + }; + + return { + tmdb: tmdb as Pick, + getFetchCounts: () => ({ movieFetchCount, tvFetchCount }), + }; +} + +describe('parseRequestSort', () => { + it('parses all documented sort fields with sortDirection', () => { + const cases = [ + { + sort: 'added', + sortDirection: 'desc', + field: 'added', + direction: 'desc', + }, + { sort: 'added', sortDirection: 'asc', field: 'added', direction: 'asc' }, + { + sort: 'modified', + sortDirection: 'desc', + field: 'modified', + direction: 'desc', + }, + { + sort: 'modified', + sortDirection: 'asc', + field: 'modified', + direction: 'asc', + }, + { + sort: 'popularity', + sortDirection: 'desc', + field: 'popularity', + direction: 'desc', + }, + { + sort: 'releaseDate', + sortDirection: 'asc', + field: 'releaseDate', + direction: 'asc', + }, + { + sort: 'voteAverage', + sortDirection: 'desc', + field: 'voteAverage', + direction: 'desc', + }, + { sort: 'title', sortDirection: 'asc', field: 'title', direction: 'asc' }, + ] as const; + + for (const { sort, sortDirection, field, direction } of cases) { + assert.deepEqual(parseRequestSort(sort, sortDirection), { + field, + direction, + }); + } + }); + + it('defaults to added desc when sort or sortDirection are omitted', () => { + assert.deepEqual(parseRequestSort('added'), { + field: 'added', + direction: 'desc', + }); + assert.deepEqual(parseRequestSort(undefined, undefined), { + field: 'added', + direction: 'desc', + }); + }); + + it('defaults unknown sort values to added', () => { + assert.deepEqual(parseRequestSort('popularity.desc', 'asc'), { + field: 'added', + direction: 'asc', + }); + }); +}); + +describe('isTmdbRequestSort', () => { + it('identifies TMDB-backed sort fields', () => { + assert.equal(isTmdbRequestSort('popularity'), true); + assert.equal(isTmdbRequestSort('releaseDate'), true); + assert.equal(isTmdbRequestSort('voteAverage'), true); + assert.equal(isTmdbRequestSort('title'), true); + assert.equal(isTmdbRequestSort('added'), false); + assert.equal(isTmdbRequestSort('modified'), false); + }); +}); + +describe('getRequestSortColumn', () => { + it('maps request date and modified date to database columns', () => { + assert.equal(getRequestSortColumn('added'), 'request.createdAt'); + assert.equal(getRequestSortColumn('modified'), 'request.updatedAt'); + }); +}); + +describe('sortRequestsByTmdbField', () => { + it('fetches TMDB data only once per unique type-tmdbId key', async () => { + const { tmdb, getFetchCounts } = createTmdbMock({ + 100: { + original_title: 'Shared Movie', + popularity: 10, + vote_average: 7, + release_date: '2020-01-01', + }, + }); + + const requests = [ + createRequest(1, 100), + createRequest(2, 100), + createRequest(3, 101, MediaType.TV), + ]; + + await sortRequestsByTmdbField(requests, 'title', 'asc', tmdb); + + assert.equal(getFetchCounts().movieFetchCount, 1); + assert.equal(getFetchCounts().tvFetchCount, 1); + }); + + it('places requests without TMDB data at the end', async () => { + const { tmdb } = createTmdbMock({ + 100: { + original_title: 'Alpha', + popularity: 10, + vote_average: 7, + release_date: '2020-01-01', + }, + }); + + const requests = [ + createRequest(1, 999), + createRequest(2, 100), + createRequest(3, 998), + ]; + + const sorted = await sortRequestsByTmdbField( + requests, + 'title', + 'asc', + tmdb + ); + + assert.deepEqual( + sorted.map((request) => request.id), + [2, 1, 3] + ); + }); + + it('sorts by each TMDB field in ascending and descending order', async () => { + const { tmdb } = createTmdbMock({ + 1: { + original_title: 'Alpha', + popularity: 10, + vote_average: 5, + release_date: '2020-01-01', + }, + 2: { + original_title: 'Zulu', + popularity: 20, + vote_average: 9, + release_date: '2022-01-01', + }, + }); + + const requests = [createRequest(2, 2), createRequest(1, 1)]; + + const titleAsc = await sortRequestsByTmdbField( + requests, + 'title', + 'asc', + tmdb + ); + const titleDesc = await sortRequestsByTmdbField( + requests, + 'title', + 'desc', + tmdb + ); + const popularityAsc = await sortRequestsByTmdbField( + requests, + 'popularity', + 'asc', + tmdb + ); + const popularityDesc = await sortRequestsByTmdbField( + requests, + 'popularity', + 'desc', + tmdb + ); + const voteAverageAsc = await sortRequestsByTmdbField( + requests, + 'voteAverage', + 'asc', + tmdb + ); + const voteAverageDesc = await sortRequestsByTmdbField( + requests, + 'voteAverage', + 'desc', + tmdb + ); + const releaseDateAsc = await sortRequestsByTmdbField( + requests, + 'releaseDate', + 'asc', + tmdb + ); + const releaseDateDesc = await sortRequestsByTmdbField( + requests, + 'releaseDate', + 'desc', + tmdb + ); + + assert.deepEqual( + titleAsc.map((request) => request.id), + [1, 2] + ); + assert.deepEqual( + titleDesc.map((request) => request.id), + [2, 1] + ); + assert.deepEqual( + popularityAsc.map((request) => request.id), + [1, 2] + ); + assert.deepEqual( + popularityDesc.map((request) => request.id), + [2, 1] + ); + assert.deepEqual( + voteAverageAsc.map((request) => request.id), + [1, 2] + ); + assert.deepEqual( + voteAverageDesc.map((request) => request.id), + [2, 1] + ); + assert.deepEqual( + releaseDateAsc.map((request) => request.id), + [1, 2] + ); + assert.deepEqual( + releaseDateDesc.map((request) => request.id), + [2, 1] + ); + }); + + it('uses request id as a directional tie-breaker', async () => { + const { tmdb } = createTmdbMock({ + 100: { + original_title: 'Same Title', + popularity: 10, + vote_average: 7, + release_date: '2020-01-01', + }, + 101: { + original_title: 'Same Title', + popularity: 10, + vote_average: 7, + release_date: '2020-01-01', + }, + }); + + const requests = [createRequest(5, 101), createRequest(2, 100)]; + + const asc = await sortRequestsByTmdbField(requests, 'title', 'asc', tmdb); + const desc = await sortRequestsByTmdbField(requests, 'title', 'desc', tmdb); + + assert.deepEqual( + asc.map((request) => request.id), + [2, 5] + ); + assert.deepEqual( + desc.map((request) => request.id), + [5, 2] + ); + }); + + it('handles TMDB fetch failures gracefully', async () => { + const { tmdb } = createTmdbMock( + { + 100: { + original_title: 'Available', + popularity: 10, + vote_average: 7, + release_date: '2020-01-01', + }, + }, + {}, + new Set([200]) + ); + + const requests = [ + createRequest(1, 200), + createRequest(2, 100), + createRequest(3, 200), + ]; + + const sorted = await sortRequestsByTmdbField( + requests, + 'popularity', + 'desc', + tmdb + ); + + assert.deepEqual( + sorted.map((request) => request.id), + [2, 3, 1] + ); + }); +}); diff --git a/server/lib/requestSort.ts b/server/lib/requestSort.ts new file mode 100644 index 0000000000..928024e87b --- /dev/null +++ b/server/lib/requestSort.ts @@ -0,0 +1,187 @@ +import TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; + +export type RequestSortField = + | 'added' + | 'modified' + | 'popularity' + | 'releaseDate' + | 'voteAverage' + | 'title'; + +export type RequestSortDirection = 'asc' | 'desc'; + +export interface ParsedRequestSort { + field: RequestSortField; + direction: RequestSortDirection; +} + +const TMDB_SORT_FIELDS: RequestSortField[] = [ + 'popularity', + 'releaseDate', + 'voteAverage', + 'title', +]; + +const SORT_FIELDS: RequestSortField[] = [ + 'added', + 'modified', + 'popularity', + 'releaseDate', + 'voteAverage', + 'title', +]; + +export function isTmdbRequestSort( + field: RequestSortField +): field is Exclude { + return TMDB_SORT_FIELDS.includes(field); +} + +function isRequestSortField(value?: string): value is RequestSortField { + return SORT_FIELDS.includes(value as RequestSortField); +} + +export function parseRequestSort( + sort?: string, + sortDirection?: string +): ParsedRequestSort { + const field: RequestSortField = isRequestSortField(sort) ? sort : 'added'; + const direction: RequestSortDirection = + sortDirection === 'asc' ? 'asc' : 'desc'; + + return { field, direction }; +} + +export type DbRequestSortField = 'added' | 'modified'; + +export function getRequestSortColumn(field: DbRequestSortField): string { + switch (field) { + case 'modified': + return 'request.updatedAt'; + default: + return 'request.createdAt'; + } +} + +interface MediaSortCache { + title: string; + popularity: number; + voteAverage: number; + releaseDate: string; +} + +interface TmdbSortProvider { + getMovie: TheMovieDb['getMovie']; + getTvShow: TheMovieDb['getTvShow']; +} + +async function fetchMediaSortCache( + tmdb: TmdbSortProvider, + mediaType: MediaType, + tmdbId: number +): Promise { + try { + if (mediaType === MediaType.MOVIE) { + const movie = await tmdb.getMovie({ movieId: tmdbId }); + + return { + title: movie.original_title?.toLowerCase() ?? '', + popularity: movie.popularity ?? 0, + voteAverage: movie.vote_average ?? 0, + releaseDate: movie.release_date ?? '', + }; + } + + const tv = await tmdb.getTvShow({ tvId: tmdbId }); + + return { + title: tv.original_name?.toLowerCase() ?? '', + popularity: tv.popularity ?? 0, + voteAverage: tv.vote_average ?? 0, + releaseDate: tv.first_air_date ?? '', + }; + } catch { + return null; + } +} + +export async function sortRequestsByTmdbField( + requests: MediaRequest[], + field: Exclude, + direction: RequestSortDirection, + tmdb: TmdbSortProvider = new TheMovieDb() +): Promise { + const uniqueMedia = new Map< + string, + { mediaType: MediaType; tmdbId: number } + >(); + + for (const request of requests) { + const key = `${request.type}-${request.media.tmdbId}`; + + if (!uniqueMedia.has(key)) { + uniqueMedia.set(key, { + mediaType: request.type, + tmdbId: request.media.tmdbId, + }); + } + } + + const cacheMap = new Map(); + + await Promise.all( + [...uniqueMedia.entries()].map(async ([key, { mediaType, tmdbId }]) => { + const data = await fetchMediaSortCache(tmdb, mediaType, tmdbId); + + if (data) { + cacheMap.set(key, data); + } + }) + ); + + const multiplier = direction === 'asc' ? 1 : -1; + + return [...requests].sort((a, b) => { + const keyA = `${a.type}-${a.media.tmdbId}`; + const keyB = `${b.type}-${b.media.tmdbId}`; + const cacheA = cacheMap.get(keyA); + const cacheB = cacheMap.get(keyB); + + if (!cacheA && !cacheB) { + return (a.id - b.id) * multiplier; + } + + if (!cacheA) { + return 1; + } + + if (!cacheB) { + return -1; + } + + let comparison = 0; + + switch (field) { + case 'title': + comparison = cacheA.title.localeCompare(cacheB.title); + break; + case 'popularity': + comparison = cacheA.popularity - cacheB.popularity; + break; + case 'voteAverage': + comparison = cacheA.voteAverage - cacheB.voteAverage; + break; + case 'releaseDate': + comparison = cacheA.releaseDate.localeCompare(cacheB.releaseDate); + break; + } + + if (comparison === 0) { + return (a.id - b.id) * multiplier; + } + + return comparison * multiplier; + }); +} diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index d90f6d3b0c..e286fa2a66 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -117,6 +117,116 @@ async function seedRequest(status = MediaRequestStatus.PENDING) { }); } +describe('GET /request', () => { + it('sorts by request date ascending', async () => { + const userRepo = getRepository(User); + const mediaRepo = getRepository(Media); + const requestRepo = getRepository(MediaRequest); + + const requestedBy = await userRepo.findOneOrFail({ + where: { email: 'admin@seerr.dev' }, + }); + + const media = await mediaRepo.save( + new Media({ + mediaType: MediaType.MOVIE, + tmdbId: 77701, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + }) + ); + + const olderRequest = await requestRepo.save( + new MediaRequest({ + type: MediaType.MOVIE, + status: MediaRequestStatus.PENDING, + media, + requestedBy, + is4k: false, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + }) + ); + + const newerRequest = await requestRepo.save( + new MediaRequest({ + type: MediaType.MOVIE, + status: MediaRequestStatus.PENDING, + media, + requestedBy, + is4k: false, + createdAt: new Date('2025-01-01T00:00:00.000Z'), + updatedAt: new Date('2025-01-01T00:00:00.000Z'), + }) + ); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.get( + '/request?filter=all&sort=added&sortDirection=asc&take=10' + ); + + assert.strictEqual(res.status, 200); + assert.deepEqual( + res.body.results.map((request: { id: number }) => request.id), + [olderRequest.id, newerRequest.id] + ); + }); + + it('sorts by modified date with sortDirection', async () => { + const userRepo = getRepository(User); + const mediaRepo = getRepository(Media); + const requestRepo = getRepository(MediaRequest); + + const requestedBy = await userRepo.findOneOrFail({ + where: { email: 'admin@seerr.dev' }, + }); + + const media = await mediaRepo.save( + new Media({ + mediaType: MediaType.MOVIE, + tmdbId: 77702, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + }) + ); + + const olderRequest = await requestRepo.save( + new MediaRequest({ + type: MediaType.MOVIE, + status: MediaRequestStatus.PENDING, + media, + requestedBy, + is4k: false, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + }) + ); + + const newerRequest = await requestRepo.save( + new MediaRequest({ + type: MediaType.MOVIE, + status: MediaRequestStatus.PENDING, + media, + requestedBy, + is4k: false, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2025-01-01T00:00:00.000Z'), + }) + ); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.get( + '/request?filter=all&sort=modified&sortDirection=desc&take=10' + ); + + assert.strictEqual(res.status, 200); + assert.deepEqual( + res.body.results.map((request: { id: number }) => request.id), + [newerRequest.id, olderRequest.id] + ); + }); +}); + describe('DELETE /request/:requestId', () => { it('allows the owner to delete their own pending request', async () => { const mediaRequest = await seedRequest(); diff --git a/server/routes/request.ts b/server/routes/request.ts index fafa90692e..bf57b9d157 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -22,6 +22,12 @@ import type { RequestResultsResponse, } from '@server/interfaces/api/requestInterfaces'; import { Permission } from '@server/lib/permissions'; +import { + getRequestSortColumn, + isTmdbRequestSort, + parseRequestSort, + sortRequestsByTmdbField, +} from '@server/lib/requestSort'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; @@ -103,24 +109,11 @@ requestRoutes.get, RequestResultsResponse>( ]; } - let sortFilter: string; - let sortDirection: 'ASC' | 'DESC'; - - switch (req.query.sort) { - case 'modified': - sortFilter = 'request.updatedAt'; - break; - default: - sortFilter = 'request.id'; - } - - switch (req.query.sortDirection) { - case 'asc': - sortDirection = 'ASC'; - break; - default: - sortDirection = 'DESC'; - } + const { field: sortField, direction: sortDirection } = parseRequestSort( + req.query.sort as string | undefined, + req.query.sortDirection as string | undefined + ); + const sortDirectionSql = sortDirection.toUpperCase() as 'ASC' | 'DESC'; let query = getRepository(MediaRequest) .createQueryBuilder('request') @@ -175,11 +168,27 @@ requestRoutes.get, RequestResultsResponse>( break; } - const [requests, requestCount] = await query - .orderBy(sortFilter, sortDirection) - .take(pageSize) - .skip(skip) - .getManyAndCount(); + let requests: MediaRequest[]; + let requestCount: number; + + if (isTmdbRequestSort(sortField)) { + const allRequests = await query.getMany(); + const sortedRequests = await sortRequestsByTmdbField( + allRequests, + sortField, + sortDirection + ); + + requestCount = sortedRequests.length; + requests = sortedRequests.slice(skip, skip + pageSize); + } else { + [requests, requestCount] = await query + .orderBy(getRequestSortColumn(sortField), sortDirectionSql) + .addOrderBy('request.id', sortDirectionSql) + .take(pageSize) + .skip(skip) + .getManyAndCount(); + } const settings = getSettings(); diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index a5e21f2320..82f123d1f0 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -30,11 +30,25 @@ const messages = defineMessages('components.RequestList', { showallrequests: 'Show All Requests', sortAdded: 'Most Recent', sortModified: 'Last Modified', + sortPopularity: 'Popularity', + sortReleaseDate: 'Release / First Air Date', + sortTmdbRating: 'TMDB Rating', + sortTitle: 'Title', sortDirection: 'Toggle Sort Direction', unableToConnect: 'Unable to connect to {services}. Some information may be unavailable.', }); +type Sort = + | 'added' + | 'modified' + | 'popularity' + | 'releaseDate' + | 'voteAverage' + | 'title'; + +type SortDirection = 'asc' | 'desc'; + enum Filter { ALL = 'all', PENDING = 'pending', @@ -47,10 +61,6 @@ enum Filter { COMPLETED = 'completed', } -type Sort = 'added' | 'modified'; - -type SortDirection = 'asc' | 'desc'; - type MediaType = 'all' | 'movie' | 'tv'; const RequestList = () => { @@ -269,6 +279,18 @@ const RequestList = () => { + + + +