diff --git a/server/controllers/albums.controller.ts b/server/controllers/albums.controller.ts index 8959a790c..6774d767c 100644 --- a/server/controllers/albums.controller.ts +++ b/server/controllers/albums.controller.ts @@ -24,10 +24,15 @@ const getList = async ( res: Response ) => { const { serverId } = req.params; - const { take, skip, serverUrlId } = req.query; + const { take, skip, serverUrlId, advancedFilters } = req.query; + + const decodedAdvancedFilters = advancedFilters && decodeURI(advancedFilters); + const jsonAdvancedFilters = + decodedAdvancedFilters && JSON.parse(decodedAdvancedFilters); const albums = await service.albums.findMany({ ...req.query, + advancedFilters: jsonAdvancedFilters, serverId, skip: Number(skip), take: Number(take), @@ -66,6 +71,7 @@ const getDetailSongList = async ( const albums = await service.albums.findMany({ ...req.query, + advancedFilters: undefined, serverId, skip: Number(skip), take: Number(take), diff --git a/server/helpers/albums.helpers.ts b/server/helpers/albums.helpers.ts index 11f08cad8..1b12ff76e 100644 --- a/server/helpers/albums.helpers.ts +++ b/server/helpers/albums.helpers.ts @@ -57,7 +57,7 @@ const sort = (sortBy: AlbumSort, orderBy: SortOrder) => { break; case AlbumSort.DATE_RELEASED: - order = { releaseDate: orderBy, year: orderBy }; + order = { releaseDate: orderBy }; break; case AlbumSort.DATE_RELEASED_YEAR: @@ -80,7 +80,204 @@ const sort = (sortBy: AlbumSort, orderBy: SortOrder) => { return order; }; +export enum FilterGroupType { + AND = 'AND', + OR = 'OR', +} + +export type AdvancedFilterRule = { + field: string | null; + operator: string | null; + uniqueId: string; + value: string | number | Date | undefined | null | any; +}; + +export type AdvancedFilterGroup = { + group: AdvancedFilterGroup[]; + rules: AdvancedFilterRule[]; + type: FilterGroupType; + uniqueId: string; +}; + +const advancedFilterGroup = ( + groups: AdvancedFilterGroup[], + user: AuthUser, + data: any[] +) => { + if (groups.length === 0) { + return data; + } + + const filterGroups: any[] = []; + + for (const group of groups) { + const rootType = group.type.toUpperCase(); + const query: any = { + [rootType]: [], + }; + + for (const rule of group.rules) { + if (rule.field && rule.operator) { + const [table, field, relationField] = rule.field.split('.'); + + if (field === 'ratings') { + if (table === 'albums') { + query[rootType].push({ + [field]: { + some: { + [relationField]: { + [rule.operator]: rule.value, + }, + userId: user.id, + }, + }, + }); + } else { + query[rootType].push({ + [table]: { + some: { + [field]: { + some: { + [relationField]: { + [rule.operator]: rule.value, + }, + userId: user.id, + }, + }, + }, + }, + }); + } + } else if (table === 'albums') { + const obj = { + [field]: { + [rule.operator]: rule.value, + mode: 'insensitive', + }, + }; + + query[rootType].push(obj); + } else { + const obj = { + [table]: { + some: { + [field]: { + [rule.operator]: rule.value, + mode: 'insensitive', + }, + }, + }, + }; + + query[rootType].push(obj); + } + } + } + + if (group.group.length > 0) { + const b = advancedFilterGroup(group.group, user, data); + b.forEach((c) => query[rootType].push(c)); + } + + data.push(query); + filterGroups.push(query); + } + + return filterGroups; +}; + +const advancedFilter = (filter: AdvancedFilterGroup, user: AuthUser) => { + const rootQueryType = filter.type.toUpperCase(); + const rootQuery = { + [rootQueryType]: [] as any[], + }; + + const operatorMap = { + '!=': 'not', + '!~': 'contains', + $: 'endsWith', + '<': 'lt', + '<=': 'lte', + '=': 'equals', + '>': 'gt', + '>=': 'gte', + '^': 'startsWith', + '~': 'contains', + }; + + for (const rule of filter.rules) { + if (rule.field && rule.operator) { + let [table, field, relationField] = rule.field.split('.'); + const condition = rule.operator === '!~' ? 'none' : 'some'; + const op = operatorMap[rule.operator as keyof typeof operatorMap]; + + switch (table) { + case 'albums': + if (field === 'ratings') { + rootQuery[rootQueryType].push({ + [field]: { + [condition]: { + [relationField]: { + [op]: rule.value, + }, + userId: user.id, + }, + }, + }); + break; + } + rootQuery[rootQueryType].push({ + [field]: { + mode: 'insensitive', + [op]: rule.value, + }, + }); + break; + + default: + if (field === 'ratings') { + rootQuery[rootQueryType].push({ + [table]: { + some: { + [field]: { + some: { + [relationField]: { + [op]: rule.value, + }, + userId: user.id, + }, + }, + }, + }, + }); + break; + } + + rootQuery[rootQueryType].push({ + [table]: { + [condition]: { + [field]: { + mode: 'insensitive', + [op]: rule.value, + }, + }, + }, + }); + break; + } + } + } + + const groups = advancedFilterGroup(filter.group, user, []); + for (const group of groups) { + rootQuery[rootQueryType].push(group); + } + + return rootQuery; +}; + export const albumHelpers = { + advancedFilter, include, sort, }; diff --git a/server/services/albums.service.ts b/server/services/albums.service.ts index c72379fe1..6ba800ebb 100644 --- a/server/services/albums.service.ts +++ b/server/services/albums.service.ts @@ -1,7 +1,8 @@ +import { Prisma } from '@prisma/client'; import { AuthUser } from '@/middleware'; import { OffsetPagination, SortOrder } from '@/types/types'; import { ApiError } from '@/utils'; -import { AlbumSort } from '@helpers/albums.helpers'; +import { AdvancedFilterGroup, AlbumSort } from '@helpers/albums.helpers'; import { helpers } from '@helpers/index'; import { prisma } from '@lib/prisma'; @@ -24,6 +25,7 @@ const findById = async (user: AuthUser, options: { id: string }) => { }; export type AlbumFindManyOptions = { + advancedFilters?: AdvancedFilterGroup; orderBy: SortOrder; serverFolderId?: string[]; serverId: string; @@ -32,8 +34,16 @@ export type AlbumFindManyOptions = { } & OffsetPagination; const findMany = async (options: AlbumFindManyOptions) => { - const { take, serverFolderId, skip, sortBy, orderBy, user, serverId } = - options; + const { + take, + serverFolderId, + skip, + sortBy, + orderBy, + user, + serverId, + advancedFilters, + } = options; const serverFolderIds = serverFolderId || @@ -42,6 +52,9 @@ const findMany = async (options: AlbumFindManyOptions) => { let totalEntries = 0; let albums; + const advancedFiltersQuery = + advancedFilters && helpers.albums.advancedFilter(advancedFilters, user); + if (sortBy === AlbumSort.RATING) { const [count, result] = await prisma.$transaction([ prisma.albumRating.count({ @@ -92,14 +105,24 @@ const findMany = async (options: AlbumFindManyOptions) => { } else { [totalEntries, albums] = await prisma.$transaction([ prisma.album.count({ - where: { OR: helpers.shared.serverFolderFilter(serverFolderIds) }, + where: { + AND: [ + helpers.shared.serverFolderFilter(serverFolderIds), + advancedFiltersQuery as Prisma.AlbumWhereInput, + ], + }, }), prisma.album.findMany({ include: helpers.albums.include(user, { songs: false }), orderBy: [helpers.albums.sort(sortBy, orderBy)], skip, take, - where: { OR: helpers.shared.serverFolderFilter(serverFolderIds) }, + where: { + AND: [ + helpers.shared.serverFolderFilter(serverFolderIds), + advancedFiltersQuery as Prisma.AlbumWhereInput, + ], + }, }), ]); } diff --git a/server/validations/albums.validation.ts b/server/validations/albums.validation.ts index b9f966082..ae7383e87 100644 --- a/server/validations/albums.validation.ts +++ b/server/validations/albums.validation.ts @@ -16,6 +16,7 @@ const list = { ...serverFolderIdValidation, ...orderByValidation, ...serverUrlIdValidation, + advancedFilters: z.optional(z.string()), sortBy: z.nativeEnum(AlbumSort), }), }; diff --git a/src/renderer/api/albums.api.ts b/src/renderer/api/albums.api.ts index 109e7c41d..d20780aea 100644 --- a/src/renderer/api/albums.api.ts +++ b/src/renderer/api/albums.api.ts @@ -18,6 +18,7 @@ export enum AlbumSort { } export type AlbumListParams = PaginationParams & { + advancedFilters?: string; orderBy: SortOrder; serverFolderId?: string[]; serverUrlId?: string; diff --git a/src/renderer/features/albums/components/advanced-filters.tsx b/src/renderer/features/albums/components/advanced-filters.tsx index 4af325d7d..e41afeb18 100644 --- a/src/renderer/features/albums/components/advanced-filters.tsx +++ b/src/renderer/features/albums/components/advanced-filters.tsx @@ -1,29 +1,31 @@ -import { useState } from 'react'; import { Box, Stack, Group } from '@mantine/core'; -import _ from 'lodash'; +import dayjs from 'dayjs'; +import get from 'lodash/get'; +import set from 'lodash/set'; import { nanoid } from 'nanoid/non-secure'; import { RiAddLine, RiMore2Line, RiSubtractLine } from 'react-icons/ri'; import { Button, + DatePicker, DropdownMenu, NumberInput, Select, TextInput, } from '@/renderer/components'; -enum FilterGroupType { - AND = 'and', - OR = 'or', +export enum FilterGroupType { + AND = 'AND', + OR = 'OR', } -type AdvancedFilterRule = { - field: string; - operator: string; +export type AdvancedFilterRule = { + field: string | null; + operator: string | null; uniqueId: string; - value: string; + value: string | number | Date | undefined | null | any; }; -type AdvancedFilterGroup = { +export type AdvancedFilterGroup = { group: AdvancedFilterGroup[]; rules: AdvancedFilterRule[]; type: FilterGroupType; @@ -33,6 +35,8 @@ type AdvancedFilterGroup = { const DATE_FILTER_OPTIONS_DATA = [ { label: 'is before', value: '<' }, { label: 'is after', value: '>' }, + { label: 'is before or equal to', value: '<=' }, + { label: 'is after or equal to', value: '>=' }, ]; const STRING_FILTER_OPTIONS_DATA = [ @@ -49,164 +53,413 @@ const NUMBER_FILTER_OPTIONS_DATA = [ { label: 'is not', value: '!=' }, { label: 'is greater than', value: '>' }, { label: 'is less than', value: '<' }, + { label: 'is greater than or equal to', value: '>=' }, + { label: 'is less than or equal to', value: '<=' }, +]; + +const ID_FILTER_OPTIONS_DATA = [ + { label: 'is', value: 'equals' }, + { label: 'is not', value: 'not' }, ]; const FILTER_GROUP_OPTIONS_DATA = [ { - label: 'Match ALL', + label: 'Match all', value: FilterGroupType.AND, }, { - label: 'Match ANY', + label: 'Match any', value: FilterGroupType.OR, }, ]; const FILTER_OPTIONS_DATA = [ { - label: 'Artist Title', - value: 'artist.title', + label: 'Artist Name', + value: 'artists.name', }, { label: 'Artist Rating', - value: 'artist.rating', + value: 'artists.ratings.value', }, { label: 'Artist Genre', - value: 'artist.genre', + value: 'artists.genre', }, { - label: 'Album Title', - value: 'album.title', + label: 'Album Artist Name', + value: 'albumArtists.name', + }, + { + label: 'Album Artist Rating', + value: 'albumArtists.ratings.value', + }, + { + label: 'Album Artist Genre', + value: 'albumArtists.genre', + }, + { + label: 'Album Name', + value: 'albums.name', }, { label: 'Album Genre', - value: 'album.genre', + value: 'albums.genre', }, { label: 'Album Rating', - value: 'album.rating', + value: 'albums.ratings.value', }, { label: 'Album Year', - value: 'album.year', + value: 'albums.year', }, { label: 'Album Release Date', - value: 'album.releaseDate', + value: 'albums.releaseDate', }, { label: 'Album Plays', - value: 'album.playCount', + value: 'albums.playCount', }, { label: 'Album Date Added', - value: 'album.dateAdded', + value: 'albums.dateAdded', }, { - label: 'Track Title', - value: 'track.title', + label: 'Track Name', + value: 'songs.name', }, { label: 'Track Plays', - value: 'track.plays', + value: 'songs.playCount', }, { label: 'Track Rating', - value: 'track.rating', + value: 'songs.ratings.value', }, ]; const OPTIONS_MAP = { - 'album.dateAdded': { + 'albumArtists.genre': { + type: 'id', + }, + 'albumArtists.name': { + type: 'string', + }, + 'albumArtists.ratings.value': { + type: 'number', + }, + 'albums.dateAdded': { type: 'date', }, - 'album.favorite': { + 'albums.favorite': { type: 'boolean', }, - 'album.genre': { + 'albums.genre': { + type: 'id', + }, + 'albums.name': { type: 'string', }, - 'album.playCount': { + 'albums.playCount': { type: 'number', }, - 'album.rating': { + 'albums.ratings.value': { type: 'number', }, - 'album.releaseDate': { + 'albums.releaseDate': { type: 'date', }, - 'album.title': { - type: 'string', - }, - 'album.year': { + 'albums.year': { type: 'number', }, - 'artist.genre': { + 'artists.genre': { + type: 'id', + }, + 'artists.name': { type: 'string', }, - 'artist.rating': { + 'artists.ratings.value': { type: 'number', }, - 'artist.title': { + 'songs.name': { type: 'string', }, - 'track.plays': { + 'songs.playCount': { type: 'number', }, - 'track.rating': { + 'songs.ratings.value': { type: 'number', }, - 'track.title': { - type: 'string', - }, }; -const FilterOption = ({ level, onDeleteRule, uniqueId, groupIndex }: any) => { - const [selectedOption, setSelectedOption] = useState< - string | null | typeof OPTIONS_MAP - >(FILTER_OPTIONS_DATA[0].value); +interface FilterOptionProps { + data: AdvancedFilterRule; + groupIndex: number[]; + level: number; + onChangeField: (args: any) => void; + onChangeOperator: (args: any) => void; + onChangeValue: (args: any) => void; + onDeleteRule: (args: DeleteArgs) => void; +} + +const FilterOption = ({ + data, + level, + onDeleteRule, + groupIndex, + onChangeField, + onChangeOperator, + onChangeValue, +}: FilterOptionProps) => { + const { field, operator, uniqueId, value } = data; const handleDeleteRule = () => { onDeleteRule({ groupIndex, level, uniqueId }); }; - const filterMap = { - date: , - string: , - 'album.playCount': , - 'album.rating': , - 'album.releaseDate': , - 'album.title': , - 'album.year': , - 'artist.genre': + ), + id: ( + + ), + string: ( + + ), + 'albumArtists.name': ( + + ), + 'albumArtists.ratings.value': ( + + ), + 'albums.dateAdded': ( + + ), + 'albums.genre': ( + + ), + 'artists.name': ( + + ), + 'artists.ratings.value': ( + + ), + 'songs.name': ( + + ), + 'songs.playCount': ( + + ), + 'songs.ratings.value': ( + + ), }; return ( - +