feat: Implement virtual states to make filtering play state easier

* Convert 'queued' and 'dead queued' play states to queue filters
* Fix relation filter sql generation for count query
This commit is contained in:
FoxxMD
2026-07-08 13:25:41 +00:00
parent 7d8b00ab22
commit a3e056e7c5
9 changed files with 54 additions and 32 deletions
@@ -3,9 +3,9 @@ import { DbConcrete, runTransaction } from "../drizzleUtils.js";
import clone from 'clone';
import { Traverse, TraverseContext } from 'neotraverse/modern';
import { loggerNoop } from "../../../MaybeLogger.js";
import { DateLike, DeepReplaceValue, ErrorLike, PlayObject, PlayState, REGEX_ISO8601_LOOSE, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../../../../core/Atomic.js";
import { DateLike, DeepReplaceValue, ErrorLike, PlayObject, PlayState, QueueName, REGEX_ISO8601_LOOSE, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../../../../core/Atomic.js";
import { generateInputEntity, generatePlayEntity, PlayEntityOpts, hydratePlaySelect, PlayHydateOptions } from "../entityUtils.js";
import { playInputs, plays, queueStates, relations } from "../schema/schema.js";
import { playInputs, plays, queueStates, relations, schema } from "../schema/schema.js";
import { PlayNew, PlaySelect, PlayInputNew, FindWhere, FindMany, QueueStateSelect, FindWith, PlaySelectWithQueueStates, WhereClause, PlayWith } from "../drizzleTypes.js";;
import { MarkOptional, MarkRequired, PathValue } from "ts-essentials";
import { genGroupIdStrFromPlay, removeEmptyArrays, removeUndefinedKeys } from "../../../../utils.js";
@@ -23,7 +23,7 @@ import { SourceType } from "../../../infrastructure/config/source/sources.js";
// https://github.com/drizzle-team/drizzle-orm/issues/695 may be useful for typing models with relations?
export interface QueueCriteria {
queueName: string
queueName: QueueName
queueStatus: QueueStateSelect['queueStatus'][] | QueueStateSelect['queueStatus']
}
@@ -252,8 +252,9 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
const where = buildPlayWhere({componentId: args.componentId ?? this.componentId, ...rest});
// @ts-expect-error
const filter = relationsFilterToSQL(plays, where);
// https://github.com/drizzle-team/drizzle-orm/issues/5218#issuecomment-3854900241
const filter = relationsFilterToSQL(plays, where, relations.plays.relations, relations);
// https://github.com/drizzle-team/drizzle-orm/discussions/3119#discussioncomment-16379557
const count = await this.db.$count(plays, filter);
@@ -290,7 +290,7 @@ export const getConfigByTableName = <T extends TableName>(name: T) => {
}
}
const schema = {playInputs, plays, components, componentMigrations, queueStates, jobs};
export const schema = {playInputs, plays, components, componentMigrations, queueStates, jobs};
export type TSchema = typeof relations;
export type Schema = typeof schema;
+7 -6
View File
@@ -1,17 +1,17 @@
import { Badge, Separator, HStack } from "@chakra-ui/react";
import React, { ComponentProps, useState, useCallback, useEffect } from "react";
import React, { ComponentProps, useState, useCallback, useEffect, PropsWithChildren } from "react";
import { COMPONENT_STATE, ComponentCommonApiJson, componentStateToFriendly, MsSseEvent, PlayApiCommon } from "../../core/Api";
import { capitalize } from "../../core/StringUtils";
import { capitalizeWords } from "../../core/StringUtils";
import {useSSEContext, useSSEEvent} from "@flamefrontend/sse-runtime-react";
import { Second } from "../../core/Atomic";
import { useTimeout } from 'react-use-timeout';
export const PlayStateBadge = (props: ComponentProps<typeof Badge> & { state: PlayApiCommon['state'], suffix?: React.JSX.Element, hasDeadQueue?: boolean }) => {
export const PlayStateBadge = (props: PropsWithChildren<ComponentProps<typeof Badge>> & { state: PlayApiCommon['state'], suffix?: React.JSX.Element, hasDeadQueue?: boolean }) => {
const { state, suffix, ...rest } = props;
const { state, suffix, children, ...rest } = props;
let badgeColor = undefined;
let badgeText = capitalize(state);
let badgeText = capitalizeWords(state);
switch (state) {
case 'queued':
@@ -22,6 +22,7 @@ export const PlayStateBadge = (props: ComponentProps<typeof Badge> & { state: Pl
badgeColor = 'green';
break;
case 'failed':
case ('dead queued' as PlayApiCommon['state']):
badgeColor = 'red';
if(props.hasDeadQueue) {
badgeText = 'Dead Queued';
@@ -35,7 +36,7 @@ export const PlayStateBadge = (props: ComponentProps<typeof Badge> & { state: Pl
break;
}
return <Badge variant="surface" colorPalette={badgeColor} {...rest}>{badgeText}{suffix}</Badge>
return <Badge variant="surface" colorPalette={badgeColor} {...rest}>{children ?? badgeText}{suffix}</Badge>
}
const DEFAULT_EXPIRES = 10000;
@@ -121,13 +121,15 @@ export const ListContainerFetchable = (props: { componentId: number, componentTy
if ('componentId' in (payload.data as object) && (payload.data as Record<string, any>).componentId === props.componentId) {
switch (payload.type) {
case 'playInsert':
const componentData = payload.data as MsSseEventPayload<PlayApiCommonDetailed>;
{
const componentData = payload.data as MsSseEventPayload<PlayApiCommonDetailed>;
console.debug(`[Insert Check ${componentData.data.uid}] Recieved playInsert for Component ${componentId}, checking if Play can be inserted...`);
if(playInWindow(componentData.data, query)) {
queryClient.setQueryData(tanQueries.activities.list(componentId, query).queryKey, (old: InfiniteData<PaginatedResponse<PlayApiCommonDetailed>, unknown>) => {
return insertInfinitePlay(componentData.data, old);
});
}
}
}
}
}
});
@@ -189,7 +191,7 @@ const insertInfinitePlay = (data: PlayApiCommonDetailed, queryData: InfiniteData
data: [...playData.slice(0, beforeIndex),
{
...data,
// @ts-expect-error
// @ts-expect-error only used for inserts in this context
isNew: true
}
, ...playData.slice(beforeIndex)]
@@ -4,8 +4,6 @@ import React, { ComponentProps, Fragment, useMemo, useCallback, useState } from
import dayjs, { Dayjs } from 'dayjs';
import doy from 'dayjs/plugin/dayOfYear.js';
import "./PlayList.scss";
import { ToggleButtonVariant } from '../ToggleButton.js';
import { capitalize } from '../../../core/StringUtils.js';
import { PlayStateBadge } from '../Badges.js';
import {
DateFormatter,
@@ -25,9 +23,10 @@ import { QueryPlaysOptsJson } from '../../../backend/common/database/drizzle/rep
import { cardHeaderSeparator } from '../../utils/ComponentUtils.js';
import { CompareDateBetween } from '../../../backend/common/database/drizzle/repositories/BaseRepository.js';
import { CalendarButton, RefreshButton } from '../icons/ChakraIcons.js';
import { nanoid } from 'nanoid';
import { QueryPlaysOptsJsonRefreshable, tanQueries, useQueryWatcher } from '../../queries/index.js';
import { useQueryClient } from '@tanstack/react-query';
import { PlayStateUI } from '../../../core/Api.js';
import { capitalizeWords } from '../../../core/StringUtils.js';
const noop = (_) => null;
@@ -45,16 +44,16 @@ const SelectValue = () => {
interface PlayStateFilterProps {
mode: ComponentType
onChange?: (states: PlayState[]) => void
onChange?: (states: PlayStateUI[]) => void
}
export const PlayStateFilter = (props: PlayStateFilterProps & {value?: PlayState[] | undefined}) => {
export const PlayStateFilter = (props: PlayStateFilterProps & {value?: PlayStateUI[] | undefined}) => {
const {
mode,
onChange = noop,
value
} = props;
const availableStates = isComponentTypeSource(mode) ? PLAY_SOURCE_STATE : PLAY_CLIENT_STATE;
const selectOptions = createListCollection({ items: availableStates.map(x => ({ label: capitalize(x), value: x })) });
const availableStates = ['dead queued', ...(isComponentTypeSource(mode) ? PLAY_SOURCE_STATE : PLAY_CLIENT_STATE)];
const selectOptions = createListCollection({ items: availableStates.map(x => ({ label: capitalizeWords(x), value: x })) });
//const [enabledStates, setEnabledStates] = useState<PlayState[]>([]);
// maxW="420px"
return (
@@ -273,7 +272,7 @@ export const ListFilters = (props: {
const queryClient = useQueryClient();
const setState = useCallback((val: PlayState[]) => {
const setState = useCallback((val: PlayStateUI[]) => {
const {
state,
...rest
@@ -283,7 +282,7 @@ export const ListFilters = (props: {
...rest,
state: val
});
}, [onchange, filters]);
}, [onChange, filters]);
const setDateRange = useCallback((val: [string, string]) => {
const {
playedAt,
+19 -4
View File
@@ -6,10 +6,10 @@ import { QueryPlaysOpts, QueryPlaysOptsJson } from "../../backend/common/databas
import qs from 'qs';
import { baseUrl } from "../utils";
import { PaginatedResponse } from "../../backend/common/database/drizzle/repositories/BaseRepository";
import { ComponentsApiJson, PlayApiCommonDetailed } from "../../core/Api";
import { SourcePlayerJson } from "../../core/Atomic";
import { ComponentsApiJson, PlayApiCommonDetailed, PlayStateUI } from "../../core/Api";
import { CLIENT_DEAD_QUEUE, CLIENT_INGRESS_QUEUE, isPlayState, SourcePlayerJson } from "../../core/Atomic";
export type QueryPlaysOptsJsonRefreshable = QueryPlaysOptsJson & {nonce?: string};
export type QueryPlaysOptsJsonRefreshable = Omit<QueryPlaysOptsJson, 'state'> & {nonce?: string, state?: PlayStateUI[]};
const components = createQueryKeys('components', {
list: () => ({
@@ -32,11 +32,26 @@ const activities = createQueryKeys('activities', {
queryFn: (ctx) => {
const {
nonce,
state,
...rest
} = filters;
const derived: QueryPlaysOptsJson = rest;
if(state !== undefined) {
derived.state = state.filter(x => isPlayState(x));
// remove 'dead queued' derived play state and replace with filter for queue = 'dead' & state = 'queued'
if(state.includes('dead queued') && !rest.queues?.some(x => x.queueName === CLIENT_DEAD_QUEUE)) {
derived.queues = [...(rest.queues ?? []), {queueName: CLIENT_DEAD_QUEUE, queueStatus: 'queued'}];
}
// remove 'queued' play state and replace with filter for queue = 'ingress' & state = 'queued'
if(state.includes('queued') && !rest.queues?.some(x => x.queueName === CLIENT_INGRESS_QUEUE)) {
derived.queues = [...(derived.queues ?? []), {queueName: CLIENT_INGRESS_QUEUE, queueStatus: 'queued'}];
derived.state = derived.state.filter(x => x !== 'queued');
}
}
return ky.get(`components/${componentId}/plays`, {
baseUrl: baseUrl,
searchParams: qs.stringify({...rest, offset: ctx.pageParam})
searchParams: qs.stringify({...derived, offset: ctx.pageParam})
}).json<PaginatedResponse<PlayApiCommonDetailed>>()
}
}),
+3 -1
View File
@@ -151,4 +151,6 @@ export type MsSseEvent<T extends object = Record<string, any>> = {
export type SortPlaysBy = 'played' | 'seen';
export interface SortPlaysByProps {
sortBy: SortPlaysBy
}
}
export type PlayStateUI = PlayState | 'dead queued';
+1
View File
@@ -658,6 +658,7 @@ export type PlayClientState = PlayStateCommon | 'duped' | 'scrobbled';
export const PLAY_CLIENT_STATE = [...PLAY_STATE_COMMON, 'duped', 'scrobbled'];
export type PlayState = PlaySourceState | PlayClientState;
export const PLAY_STATES = Array.from(new Set([...PLAY_CLIENT_STATE, ...PLAY_SOURCE_STATE]));
export const isPlayState = (val: string): val is PlayState => PLAY_STATES.includes(val);
export type QueueStatus = 'queued' | 'completed' | 'failed';
+4 -3
View File
@@ -191,9 +191,10 @@ export const slice = (str: string, index: number, count: number, add?: string):
return str.slice(0, index) + (add || "") + str.slice(index + count);
}
export const capitalize = (str: any) => {
return str.charAt(0).toUpperCase() + str.slice(1)
}
export const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1)
export const capitalizeWords = (str: string, delimiter = ' ') => str.split(delimiter).map(x => capitalize(x)).join(delimiter);
/**
* Split a string-ish variable by a list of deliminators and return the first actually split array or default to returning the string as the first element.