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: ,
- number: ,
- string: ,
+ const handleChangeField = (e: any) => {
+ onChangeField({ groupIndex, level, uniqueId, value: e });
};
- const filterInputMap = {
- 'album.dateAdded': ,
- 'album.genre': ,
- 'album.playCount': ,
- 'album.rating': ,
- 'album.releaseDate': ,
- 'album.title': ,
- 'album.year': ,
- 'artist.genre': ,
- 'artist.rating': ,
- 'artist.title': ,
- 'track.plays': ,
- 'track.rating': ,
- 'track.title': ,
+ const handleChangeOperator = (e: any) => {
+ onChangeOperator({ groupIndex, level, uniqueId, value: e });
+ };
+
+ const handleChangeValue = (e: any) => {
+ const isDirectValue =
+ typeof e === 'string' ||
+ typeof e === 'number' ||
+ typeof e === 'undefined' ||
+ typeof e === null;
+
+ if (isDirectValue) {
+ return onChangeValue({
+ groupIndex,
+ level,
+ uniqueId,
+ value: e,
+ });
+ }
+
+ return onChangeValue({
+ groupIndex,
+ level,
+ uniqueId,
+ value: e.currentTarget.value,
+ });
+ };
+
+ const filterOperatorMap = {
+ date: (
+
+ ),
+ id: (
+
+ ),
+ number: (
+
+ ),
+ string: (
+
+ ),
+ };
+
+ const filterInputValueMap = {
+ 'albumArtists.genre': (
+
+ ),
+ 'albumArtists.name': (
+
+ ),
+ 'albumArtists.ratings.value': (
+
+ ),
+ 'albums.dateAdded': (
+
+ ),
+ 'albums.genre': (
+
+ ),
+ 'albums.name': (
+
+ ),
+ 'albums.playCount': (
+ handleChangeValue(e)}
+ />
+ ),
+ 'albums.ratings.value': (
+
+ ),
+ 'albums.releaseDate': (
+
+ ),
+ 'albums.year': (
+
+ ),
+ 'artists.genre': (
+
+ ),
+ 'artists.name': (
+
+ ),
+ 'artists.ratings.value': (
+
+ ),
+ 'songs.name': (
+
+ ),
+ 'songs.playCount': (
+
+ ),
+ 'songs.ratings.value': (
+
+ ),
};
return (
-
+
- {selectedOption &&
- filterMap[
- OPTIONS_MAP[selectedOption as keyof typeof OPTIONS_MAP]
- .type as keyof typeof filterMap
- ]}
- {selectedOption &&
- filterInputMap[selectedOption as keyof typeof filterInputMap]}
+ {field ? (
+ filterOperatorMap[
+ OPTIONS_MAP[field as keyof typeof OPTIONS_MAP]
+ .type as keyof typeof filterOperatorMap
+ ]
+ ) : (
+
+ )}
+ {field ? (
+ filterInputValueMap[field as keyof typeof filterInputValueMap]
+ ) : (
+
+ )}
-
+
+
+
+
{({ height, width }) => (
@@ -201,7 +232,7 @@ export const AlbumListRoute = () => {
itemGap={20}
itemSize={200}
itemType={LibraryItem.ALBUM}
- minimumBatchSize={100}
+ minimumBatchSize={40}
width={width}
/>
)}