From f9ef8458007dabb7d43e6aa038275db9ed5d4fd1 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:25:41 -0400 Subject: [PATCH] initial port for dynamic playlists --- .../expo/modules/astracar/AstraCarCatalog.kt | 267 +++++- package.json | 1 + src/app/(tabs)/library/playlist/[id].tsx | 39 +- .../(tabs)/library/playlist/edit-dynamic.tsx | 849 ++++++++++++++++++ src/components/library/CollapsingDetail.tsx | 4 + src/components/library/PlaylistRow.tsx | 4 + src/components/library/PlaylistsView.tsx | 29 + src/components/sheets/PlaylistPickerSheet.tsx | 5 +- src/db/dynamicPlaylistSql.test.mts | 77 ++ src/db/dynamicPlaylistSql.ts | 165 ++++ src/db/playlistQueries.ts | 241 ++++- src/db/queries.ts | 40 +- src/db/schema.ts | 21 +- src/library/folderTree.test.mts | 4 + src/library/remoteSync.ts | 2 + src/shared/playlists/dynamicPlaylist.test.mts | 86 ++ src/shared/playlists/dynamicPlaylist.ts | 391 ++++++++ src/stores/playlistStore.ts | 31 + src/types/library.ts | 4 + src/types/playlist.ts | 2 + 20 files changed, 2226 insertions(+), 36 deletions(-) create mode 100644 src/app/(tabs)/library/playlist/edit-dynamic.tsx create mode 100644 src/db/dynamicPlaylistSql.test.mts create mode 100644 src/db/dynamicPlaylistSql.ts create mode 100644 src/shared/playlists/dynamicPlaylist.test.mts create mode 100644 src/shared/playlists/dynamicPlaylist.ts diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCatalog.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCatalog.kt index cd17830..cd3a574 100644 --- a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCatalog.kt +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCatalog.kt @@ -12,6 +12,7 @@ import android.support.v4.media.MediaMetadataCompat import android.util.Log import java.io.File import java.util.Locale +import org.json.JSONObject private const val TAG = "AstraCarCatalog" @@ -242,12 +243,15 @@ class AstraCarCatalog(private val context: Context) { ) private fun getPlaylistTracks(db: SQLiteDatabase, playlistId: Long): List = - queryTracks( - db, - "SELECT $TRACK_COLUMNS FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path " + - "WHERE pt.playlist_id = ? ORDER BY pt.position, pt.id", - arrayOf(playlistId.toString()), - ) + getPlaylistRuleRow(db, playlistId)?.let { row -> + if (row.kind == "dynamic") getDynamicPlaylistTracks(db, row.dynamicRulesJson) + else queryTracks( + db, + "SELECT $TRACK_COLUMNS FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path " + + "WHERE pt.playlist_id = ? ORDER BY pt.position, pt.id", + arrayOf(playlistId.toString()), + ) + } ?: emptyList() private fun getAlbumTracks(db: SQLiteDatabase, identityKey: String): List = queryTracks( @@ -304,10 +308,29 @@ class AstraCarCatalog(private val context: Context) { } } - private fun getPlaylists(db: SQLiteDatabase): List = - db.rawQuery( + private fun getPlaylistRuleRow(db: SQLiteDatabase, playlistId: Long): PlaylistRuleRow? { + if (!hasColumn(db, "playlists", "kind")) return PlaylistRuleRow("normal", null) + return db.rawQuery( + "SELECT kind, dynamic_rules_json FROM playlists WHERE id = ? LIMIT 1", + arrayOf(playlistId.toString()), + ).use { cursor -> + if (!cursor.moveToFirst()) return@use null + PlaylistRuleRow( + kind = if (cursor.string("kind") == "dynamic") "dynamic" else "normal", + dynamicRulesJson = cursor.nullableString("dynamic_rules_json"), + ) + } + } + + private fun getPlaylists(db: SQLiteDatabase): List { + val supportsDynamic = hasColumn(db, "playlists", "kind") && hasColumn(db, "playlists", "dynamic_rules_json") + val kindColumns = + if (supportsDynamic) "p.kind, p.dynamic_rules_json," + else "'normal' AS kind, NULL AS dynamic_rules_json," + + return db.rawQuery( """ - SELECT p.id, p.name, + SELECT p.id, p.name, $kindColumns (SELECT t.artwork_hash FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path WHERE pt.playlist_id = p.id AND t.artwork_hash IS NOT NULL @@ -330,19 +353,225 @@ class AstraCarCatalog(private val context: Context) { ).use { cursor -> buildList { while (cursor.moveToNext()) { + val kind = if (cursor.string("kind") == "dynamic") "dynamic" else "normal" + val rulesJson = cursor.nullableString("dynamic_rules_json") + val dynamicTracks = if (kind == "dynamic") getDynamicPlaylistTracks(db, rulesJson) else null + val firstDynamicCover = dynamicTracks?.firstOrNull { + it.artworkHash != null || (it.sourceId != null && !it.artworkSourceId.isNullOrBlank()) + } add( PlaylistRow( id = cursor.long("id"), name = cursor.string("name"), - artworkHash = cursor.nullableString("artwork_hash"), - sourceId = cursor.nullableLong("source_id"), - artworkSourceId = cursor.nullableString("artwork_source_id"), - trackCount = cursor.long("track_count"), + kind = kind, + artworkHash = firstDynamicCover?.artworkHash ?: cursor.nullableString("artwork_hash"), + sourceId = firstDynamicCover?.sourceId ?: cursor.nullableLong("source_id"), + artworkSourceId = firstDynamicCover?.artworkSourceId ?: cursor.nullableString("artwork_source_id"), + trackCount = dynamicTracks?.size?.toLong() ?: cursor.long("track_count"), ), ) } } } + } + + private fun hasColumn(db: SQLiteDatabase, table: String, column: String): Boolean = + db.rawQuery("PRAGMA table_info($table)", emptyArray()).use { cursor -> + while (cursor.moveToNext()) { + if (cursor.string("name") == column) return@use true + } + false + } + + private fun getDynamicPlaylistTracks(db: SQLiteDatabase, rawRules: String?): List { + val rules = parseDynamicRules(rawRules) + val conditions = rules.optJSONArray("conditions") + val joins = StringBuilder() + val where = mutableListOf() + val args = mutableListOf() + var needsFavoriteJoin = false + + if (conditions != null) { + for (i in 0 until conditions.length()) { + val condition = conditions.optJSONObject(i) ?: continue + if (condition.optString("kind") == "exact" && condition.optString("field") == "favorite") { + needsFavoriteJoin = true + } + } + if (needsFavoriteJoin) joins.append("LEFT JOIN favorites f ON f.track_path = t.path") + + for (i in 0 until conditions.length()) { + appendDynamicCondition(conditions.optJSONObject(i) ?: continue, where, args) + } + } + + val sort = rules.optJSONObject("sort") + val orderBy = dynamicOrderBy( + field = sort?.optString("field") ?: "title", + direction = sort?.optString("direction") ?: "asc", + ) + val limit = rules.opt("limit").let { value -> + when (value) { + is Number -> value.toInt().coerceIn(1, 5000) + else -> null + } + } + val limitSql = limit?.let { " LIMIT $it" }.orEmpty() + val whereSql = if (where.isEmpty()) "1 = 1" else where.joinToString("\n AND ") + + return queryTracks( + db, + "SELECT $TRACK_COLUMNS FROM tracks t $joins WHERE $whereSql ORDER BY $orderBy$limitSql", + args.toTypedArray(), + ) + } + + private fun parseDynamicRules(rawRules: String?): JSONObject { + if (rawRules.isNullOrBlank()) return JSONObject("""{"version":1,"conditions":[],"sort":{"field":"title","direction":"asc"},"limit":null}""") + return try { + JSONObject(rawRules) + } catch (_: Throwable) { + JSONObject("""{"version":1,"conditions":[],"sort":{"field":"title","direction":"asc"},"limit":null}""") + } + } + + private fun appendDynamicCondition( + condition: JSONObject, + where: MutableList, + args: MutableList, + ) { + when (condition.optString("kind")) { + "text" -> appendDynamicTextCondition(condition, where, args) + "exact" -> appendDynamicExactCondition(condition, where, args) + "numeric" -> appendDynamicNumericCondition(condition, where, args) + "date" -> appendDynamicDateCondition(condition, where, args) + } + } + + private fun appendDynamicTextCondition( + condition: JSONObject, + where: MutableList, + args: MutableList, + ) { + val expression = when (condition.optString("field")) { + "title" -> "t.title" + "artist" -> "t.artist" + "album" -> "t.album" + "album_artist" -> "t.album_artist" + "genre" -> "t.genre" + "format" -> "t.format" + "musical_key" -> "t.musical_key" + else -> return + } + val value = condition.optString("value").trim().lowercase(Locale.ROOT) + if (value.isEmpty()) return + when (condition.optString("operator")) { + "contains" -> { + where.add("LOWER(COALESCE($expression, '')) LIKE ?") + args.add("%$value%") + } + "is_not" -> { + where.add("LOWER(COALESCE($expression, '')) <> ?") + args.add(value) + } + else -> { + where.add("LOWER(COALESCE($expression, '')) = ?") + args.add(value) + } + } + } + + private fun appendDynamicExactCondition( + condition: JSONObject, + where: MutableList, + args: MutableList, + ) { + if (condition.optString("field") == "source_type") { + val operator = if (condition.optString("operator") == "is_not") "<>" else "=" + where.add("t.source_type $operator ?") + args.add(condition.optString("value")) + return + } + if (condition.optString("field") != "favorite") return + + val value = condition.optBoolean("value", false) + val expectsFavorite = if (condition.optString("operator") == "is_not") !value else value + where.add("f.track_path IS ${if (expectsFavorite) "NOT NULL" else "NULL"}") + } + + private fun appendDynamicNumericCondition( + condition: JSONObject, + where: MutableList, + args: MutableList, + ) { + val expression = when (condition.optString("field")) { + "play_count" -> "COALESCE(t.play_count, 0)" + "year" -> "t.year" + "duration_seconds" -> "t.duration" + "bpm" -> "t.bpm" + else -> return + } + val value = condition.optDouble("value", Double.NaN) + if (value.isNaN() || value.isInfinite()) return + val operator = when (condition.optString("operator")) { + "lte" -> "<=" + "gte" -> ">=" + else -> "=" + } + where.add("$expression $operator ?") + args.add(value.toString()) + } + + private fun appendDynamicDateCondition( + condition: JSONObject, + where: MutableList, + args: MutableList, + ) { + val field = condition.optString("field") + val expression = when (field) { + "last_played_at" -> "t.last_played_at" + "added_at" -> "t.added_at" + else -> return + } + val operator = condition.optString("operator") + if (field == "last_played_at" && operator == "never") { + where.add("$expression IS NULL") + return + } + + val days = condition.optInt("value", 1).coerceAtLeast(1) + val cutoff = System.currentTimeMillis() - days * 24L * 60L * 60L * 1000L + if (field == "last_played_at") { + if (operator == "within_days") { + where.add("$expression >= ?") + } else { + where.add("($expression IS NULL OR $expression < ?)") + } + args.add(cutoff.toString()) + return + } + + where.add("$expression ${if (operator == "older_than_days") "<" else ">="} ?") + args.add(cutoff.toString()) + } + + private fun dynamicOrderBy(field: String, direction: String): String { + val sort = when (field) { + "artist" -> DynamicSort("t.artist", nullable = false, text = true) + "album" -> DynamicSort("t.album", nullable = false, text = true) + "added_at" -> DynamicSort("t.added_at", nullable = false) + "last_played_at" -> DynamicSort("t.last_played_at", nullable = true) + "play_count" -> DynamicSort("COALESCE(t.play_count, 0)", nullable = false) + "year" -> DynamicSort("t.year", nullable = true) + "duration_seconds" -> DynamicSort("t.duration", nullable = false) + "bpm" -> DynamicSort("t.bpm", nullable = true) + else -> DynamicSort("t.title", nullable = false, text = true) + } + val dir = if (direction == "desc") "DESC" else "ASC" + val expression = if (sort.text) "${sort.expression} COLLATE NOCASE" else sort.expression + val nullablePrefix = if (sort.nullable) "CASE WHEN ${sort.expression} IS NULL THEN 1 ELSE 0 END ASC, " else "" + return "$nullablePrefix$expression $dir, t.path COLLATE NOCASE ASC" + } private fun getArtistGroupingMode(db: SQLiteDatabase): String = db.rawQuery( @@ -538,12 +767,24 @@ private data class AlbumRow( private data class PlaylistRow( val id: Long, val name: String, + val kind: String, val artworkHash: String?, val sourceId: Long?, val artworkSourceId: String?, val trackCount: Long, ) +private data class PlaylistRuleRow( + val kind: String, + val dynamicRulesJson: String?, +) + +private data class DynamicSort( + val expression: String, + val nullable: Boolean, + val text: Boolean = false, +) + private data class ArtistRow( val artist: String, val trackCount: Long, diff --git a/package.json b/package.json index 833b398..4547177 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "web": "expo start --web", "lint": "expo lint", "test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts", + "test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts src/db/dynamicPlaylistSql.test.mts", "typecheck": "tsc --noEmit", "postinstall": "patch-package" }, diff --git a/src/app/(tabs)/library/playlist/[id].tsx b/src/app/(tabs)/library/playlist/[id].tsx index 47d52af..34607e2 100644 --- a/src/app/(tabs)/library/playlist/[id].tsx +++ b/src/app/(tabs)/library/playlist/[id].tsx @@ -11,7 +11,7 @@ import { import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; import { FlashList } from '@shopify/flash-list'; -import { useLocalSearchParams } from 'expo-router'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; @@ -56,6 +56,7 @@ function MissingRow({ entry, onLongPress }: { entry: PlaylistTrackEntry; onLongP } export default function PlaylistScreen() { + const router = useRouter(); const { id, from } = useLocalSearchParams<{ id: string; from?: string }>(); const handleBack = useLibraryDetailBack(from); const isFavorites = id === 'favorites'; @@ -86,6 +87,7 @@ export default function PlaylistScreen() { const playlist = isFavorites ? null : playlists.find((entry) => entry.id === playlistId); const name = isFavorites ? 'Favorites' : (playlist?.name ?? 'Playlist'); const coverHash = playlist?.auto_cover_hash ?? null; + const isDynamic = playlist?.kind === 'dynamic'; const entries: PlaylistTrackEntry[] = useMemo( () => @@ -138,7 +140,7 @@ export default function PlaylistScreen() { // Move/remove only exist on real playlists; favorites rows use the standard // sheet (its favorite toggle is the "remove" affordance there). const extraItems: TrackActionSheetItem[] = - playlistId != null && actionEntry + playlistId != null && actionEntry && !isDynamic ? [ { key: 'move-up', @@ -221,6 +223,25 @@ export default function PlaylistScreen() { backdropUri={coverHash ? artworkThumbUri(coverHash) : null} title={name} heroMeta={{meta}} + heroExtra={ + isDynamic && playlistId != null ? ( + + router.push({ + pathname: '/library/playlist/edit-dynamic' as never, + params: { id: String(playlistId) }, + }) + } + accessibilityRole="button" + > + + + Edit rules + + + ) : null + } disabled={playable.length === 0} onBack={handleBack} onPlay={() => startPlayback(0)} @@ -243,7 +264,7 @@ export default function PlaylistScreen() { title={missingEntry.fallback_title ?? 'Missing track'} subtitle={missingEntry.fallback_artist ?? 'Track not in library'} /> - {playlistId != null ? ( + {playlistId != null && !isDynamic ? ( = { + title: 'Title', + artist: 'Artist', + album: 'Album', + added_at: 'Added', + last_played_at: 'Last played', + play_count: 'Play count', + year: 'Year', + duration_seconds: 'Duration', + bpm: 'BPM', +}; + +function createDefaultCondition(fieldKey: ConditionFieldKey = 'text:artist'): DynamicPlaylistCondition { + const [kind, field] = fieldKey.split(':') as [DynamicPlaylistCondition['kind'], string]; + if (kind === 'numeric') { + return { + kind, + field: field as DynamicPlaylistNumericField, + operator: 'gte', + value: field === 'play_count' ? 1 : 0, + }; + } + if (kind === 'date') { + return field === 'last_played_at' + ? { kind, field, operator: 'not_within_days', value: 30 } + : { kind, field: 'added_at', operator: 'within_days', value: 30 }; + } + if (kind === 'exact') { + return field === 'source_type' + ? { kind, field, operator: 'is', value: 'local' } + : { kind, field: 'favorite', operator: 'is', value: true }; + } + return { kind, field: field as DynamicPlaylistTextField, operator: 'contains', value: '' }; +} + +function getConditionFieldKey(condition: DynamicPlaylistCondition): ConditionFieldKey { + return `${condition.kind}:${condition.field}` as ConditionFieldKey; +} + +function fieldLabel(condition: DynamicPlaylistCondition): string { + return FIELD_OPTIONS.find((option) => option.key === getConditionFieldKey(condition))?.label ?? condition.field; +} + +function updateCondition( + rules: DynamicPlaylistRulesV1, + index: number, + condition: DynamicPlaylistCondition +): DynamicPlaylistRulesV1 { + return { + ...rules, + conditions: rules.conditions.map((entry, entryIndex) => (entryIndex === index ? condition : entry)), + }; +} + +function removeCondition(rules: DynamicPlaylistRulesV1, index: number): DynamicPlaylistRulesV1 { + return { + ...rules, + conditions: rules.conditions.filter((_, entryIndex) => entryIndex !== index), + }; +} + +function updateTextOperator( + condition: DynamicPlaylistTextCondition, + operator: DynamicPlaylistTextCondition['operator'] +): DynamicPlaylistTextCondition { + return { ...condition, operator }; +} + +function updateNumericOperator( + condition: DynamicPlaylistNumericCondition, + operator: DynamicPlaylistNumericCondition['operator'] +): DynamicPlaylistNumericCondition { + return { ...condition, operator }; +} + +function updateDateOperator( + condition: DynamicPlaylistLastPlayedCondition | DynamicPlaylistAddedAtCondition, + operator: string +): DynamicPlaylistLastPlayedCondition | DynamicPlaylistAddedAtCondition { + if (condition.field === 'last_played_at') { + if (operator === 'never') return { kind: 'date', field: 'last_played_at', operator }; + return { + kind: 'date', + field: 'last_played_at', + operator: operator === 'within_days' ? 'within_days' : 'not_within_days', + value: condition.value ?? 30, + }; + } + + return { + kind: 'date', + field: 'added_at', + operator: operator === 'older_than_days' ? 'older_than_days' : 'within_days', + value: condition.value, + }; +} + +function updateExactOperator( + condition: DynamicPlaylistSourceCondition | DynamicPlaylistFavoriteCondition, + operator: DynamicPlaylistSourceCondition['operator'] +): DynamicPlaylistSourceCondition | DynamicPlaylistFavoriteCondition { + return { ...condition, operator } as DynamicPlaylistSourceCondition | DynamicPlaylistFavoriteCondition; +} + +function Chip({ + label, + selected, + onPress, +}: { + label: string; + selected?: boolean; + onPress: () => void; +}) { + return ( + + + {label} + + + ); +} + +function OperatorChips({ + condition, + onChange, +}: { + condition: DynamicPlaylistCondition; + onChange: (condition: DynamicPlaylistCondition) => void; +}) { + if (condition.kind === 'text') { + const options: [DynamicPlaylistTextCondition['operator'], string][] = [ + ['contains', 'Contains'], + ['is', 'Is'], + ['is_not', 'Is not'], + ]; + return ( + + {options.map(([operator, label]) => ( + onChange(updateTextOperator(condition, operator))} + /> + ))} + + ); + } + + if (condition.kind === 'numeric') { + const options: [DynamicPlaylistNumericCondition['operator'], string][] = [ + ['eq', 'Is'], + ['gte', 'At least'], + ['lte', 'At most'], + ]; + return ( + + {options.map(([operator, label]) => ( + onChange(updateNumericOperator(condition, operator))} + /> + ))} + + ); + } + + if (condition.kind === 'date') { + const options = + condition.field === 'last_played_at' + ? [ + ['never', 'Never'], + ['within_days', 'Within'], + ['not_within_days', 'Not within'], + ] + : [ + ['within_days', 'Within'], + ['older_than_days', 'Older than'], + ]; + return ( + + {options.map(([operator, label]) => ( + onChange(updateDateOperator(condition, operator))} + /> + ))} + + ); + } + + const options: [DynamicPlaylistSourceCondition['operator'], string][] = [ + ['is', 'Is'], + ['is_not', 'Is not'], + ]; + return ( + + {options.map(([operator, label]) => ( + onChange(updateExactOperator(condition, operator))} + /> + ))} + + ); +} + +function ConditionValue({ + condition, + onChange, +}: { + condition: DynamicPlaylistCondition; + onChange: (condition: DynamicPlaylistCondition) => void; +}) { + if (condition.kind === 'text') { + return ( + onChange({ ...condition, value })} + placeholder="Value" + placeholderTextColor={colors.textTertiary} + selectionColor={colors.accent} + /> + ); + } + + if (condition.kind === 'numeric') { + return ( + onChange({ ...condition, value: Number(value) })} + keyboardType="numeric" + placeholder="0" + placeholderTextColor={colors.textTertiary} + selectionColor={colors.accent} + /> + ); + } + + if (condition.kind === 'date') { + if (condition.field === 'last_played_at' && condition.operator === 'never') return null; + return ( + onChange({ ...condition, value: Number(value) } as DynamicPlaylistCondition)} + keyboardType="numeric" + placeholder="Days" + placeholderTextColor={colors.textTertiary} + selectionColor={colors.accent} + /> + ); + } + + if (condition.field === 'source_type') { + const options: DynamicPlaylistSourceCondition['value'][] = ['local', 'subsonic', 'jellyfin']; + return ( + + {options.map((value) => ( + onChange({ ...condition, value })} + /> + ))} + + ); + } + + return ( + + onChange({ ...condition, value: true })} + /> + onChange({ ...condition, value: false })} + /> + + ); +} + +export default function DynamicPlaylistEditorScreen() { + const router = useRouter(); + const { id } = useLocalSearchParams<{ id?: string }>(); + const playlistId = id ? Number(id) : null; + const isEditing = playlistId !== null && Number.isInteger(playlistId) && playlistId > 0; + + const playlists = usePlaylistStore((s) => s.playlists); + const createDynamicPlaylist = usePlaylistStore((s) => s.createDynamicPlaylist); + const getDynamicPlaylistRules = usePlaylistStore((s) => s.getDynamicPlaylistRules); + const updateDynamicPlaylistRules = usePlaylistStore((s) => s.updateDynamicPlaylistRules); + const renamePlaylist = usePlaylistStore((s) => s.renamePlaylist); + const previewDynamicPlaylist = usePlaylistStore((s) => s.previewDynamicPlaylist); + + const playlist = isEditing ? playlists.find((entry) => entry.id === playlistId) : null; + const [name, setName] = useState(() => (isEditing ? playlist?.name ?? 'Dynamic playlist' : '')); + const [rules, setRules] = useState(() => createDefaultDynamicPlaylistRules()); + const [picker, setPicker] = useState(null); + const [isLoadingRules, setIsLoadingRules] = useState(isEditing); + const [isSaving, setIsSaving] = useState(false); + const [preview, setPreview] = useState(null); + const [previewError, setPreviewError] = useState(null); + const [isPreviewLoading, setIsPreviewLoading] = useState(false); + + useEffect(() => { + if (!isEditing || playlistId == null) return; + let didCancel = false; + void getDynamicPlaylistRules(playlistId) + .then((nextRules) => { + if (!didCancel) { + setName(playlist?.name ?? 'Dynamic playlist'); + setRules(nextRules); + } + }) + .catch((err) => { + if (!didCancel) { + Alert.alert('Rules unavailable', err instanceof Error ? err.message : String(err)); + router.back(); + } + }) + .finally(() => { + if (!didCancel) setIsLoadingRules(false); + }); + return () => { + didCancel = true; + }; + }, [getDynamicPlaylistRules, isEditing, playlist?.name, playlistId, router]); + + const normalizedRulesError = useMemo(() => { + try { + normalizeDynamicPlaylistRules(rules); + return null; + } catch (err) { + return err instanceof Error ? err.message : 'Rules are incomplete.'; + } + }, [rules]); + + useEffect(() => { + let didCancel = false; + const timeoutId = setTimeout(() => { + const loadPreview = async () => { + setIsPreviewLoading(true); + setPreviewError(null); + try { + const normalizedRules = normalizeDynamicPlaylistRules(rules); + const nextPreview = await previewDynamicPlaylist(normalizedRules); + if (!didCancel) setPreview(nextPreview); + } catch (err) { + if (!didCancel) { + setPreview(null); + setPreviewError(err instanceof Error ? err.message : 'Preview failed.'); + } + } finally { + if (!didCancel) setIsPreviewLoading(false); + } + }; + void loadPreview(); + }, 300); + + return () => { + didCancel = true; + clearTimeout(timeoutId); + }; + }, [previewDynamicPlaylist, rules]); + + const saveDisabled = !name.trim() || normalizedRulesError !== null || isLoadingRules || isSaving; + + const addCondition = () => { + setRules((current) => ({ + ...current, + conditions: [...current.conditions, createDefaultCondition()], + })); + }; + + const save = () => { + if (saveDisabled) return; + void (async () => { + setIsSaving(true); + try { + const normalizedRules = normalizeDynamicPlaylistRules(rules); + const trimmedName = name.trim(); + if (isEditing && playlistId != null) { + if (playlist && playlist.name !== trimmedName) { + await renamePlaylist(playlistId, trimmedName); + } + await updateDynamicPlaylistRules(playlistId, normalizedRules); + router.back(); + return; + } + + const createdPlaylist = await createDynamicPlaylist(trimmedName, normalizedRules); + router.replace(`/library/playlist/${createdPlaylist.id}`); + } catch (err) { + Alert.alert('Save failed', err instanceof Error ? err.message : String(err)); + } finally { + setIsSaving(false); + } + })(); + }; + + const renderPicker = () => { + if (picker === null) return null; + if (picker.kind === 'sort') { + return ( + setPicker(null)}> + + {Object.entries(SORT_LABELS).map(([field, label]) => ( + { + setRules((current) => ({ + ...current, + sort: { ...current.sort, field: field as DynamicPlaylistSortField }, + })); + setPicker(null); + }} + /> + ))} + + ); + } + + return ( + setPicker(null)}> + + {FIELD_OPTIONS.map((option) => ( + { + setRules((current) => updateCondition(current, picker.index, createDefaultCondition(option.key))); + setPicker(null); + }} + /> + ))} + + ); + }; + + return ( + + + router.back()} + hitSlop={8} + style={styles.headerButton} + accessibilityRole="button" + accessibilityLabel="Back" + > + + + + {isEditing ? 'Edit dynamic playlist' : 'New dynamic playlist'} + + + + {isSaving ? 'Saving' : 'Save'} + + + + + + + + NAME + + + + + + + STARTERS + + + {DYNAMIC_PLAYLIST_PRESETS.map((preset) => ( + setRules(preset.rules)} /> + ))} + + + + + + + FILTERS + + + setRules(createDefaultDynamicPlaylistRules())} accessibilityRole="button"> + + Reset + + + + + Add + + + + + + {rules.conditions.length === 0 ? ( + + No filters + + ) : null} + + {rules.conditions.map((condition, index) => ( + + + setPicker({ kind: 'field', index })} + accessibilityRole="button" + > + + {fieldLabel(condition)} + + + + setRules((current) => removeCondition(current, index))} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Remove filter" + > + + + + setRules((current) => updateCondition(current, index, next))} + /> + setRules((current) => updateCondition(current, index, next))} + /> + + ))} + + + + + SORT + + setPicker({ kind: 'sort' })} accessibilityRole="button"> + {SORT_LABELS[rules.sort.field]} + + + + setRules((current) => ({ ...current, sort: { ...current.sort, direction: 'asc' } }))} + /> + setRules((current) => ({ ...current, sort: { ...current.sort, direction: 'desc' } }))} + /> + + + setRules((current) => ({ + ...current, + limit: value.trim() ? Number(value) : null, + })) + } + keyboardType="numeric" + placeholder="Limit" + placeholderTextColor={colors.textTertiary} + selectionColor={colors.accent} + /> + + + + + + PREVIEW + + + {isPreviewLoading ? 'Loading' : `${preview?.track_count ?? 0} tracks`} + + + {previewError || normalizedRulesError ? ( + + {previewError ?? normalizedRulesError} + + ) : preview?.tracks.length ? ( + + {preview.tracks.slice(0, 8).map((track) => ( + + + {track.title} + + + {[track.artist, track.album].filter(Boolean).join(' / ')} + + + ))} + + ) : ( + + No matches + + )} + + + + {picker !== null ? renderPicker() : null} + + ); +} + +const styles = StyleSheet.create({ + screen: { + paddingTop: 0, + }, + header: { + minHeight: 58, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingTop: spacing.md, + borderBottomColor: colors.glassBorder, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + headerButton: { + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + flex: 1, + fontSize: fontSize.base, + }, + headerSave: { + minWidth: 54, + alignItems: 'flex-end', + paddingVertical: spacing.sm, + }, + disabled: { + opacity: 0.45, + }, + content: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.lg, + paddingBottom: spacing.xxl, + gap: spacing.xl, + }, + section: { + gap: spacing.sm, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.md, + }, + sectionLabel: { + color: colors.textTertiary, + letterSpacing: 1, + }, + inlineActions: { + flexDirection: 'row', + gap: spacing.lg, + }, + input: { + minHeight: 44, + color: colors.textPrimary, + fontFamily: fonts.sans.regular, + fontSize: fontSize.base, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgTertiary, + }, + chipRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + chip: { + minHeight: 34, + justifyContent: 'center', + borderRadius: radius.pill, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + backgroundColor: colors.glassBg, + }, + chipSelected: { + borderColor: colors.accent, + backgroundColor: colors.accentGlow, + }, + condition: { + gap: spacing.sm, + paddingVertical: spacing.md, + borderBottomColor: colors.glassBorder, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + conditionHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + fieldButton: { + minHeight: 44, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + paddingHorizontal: spacing.md, + backgroundColor: colors.bgTertiary, + }, + emptyLine: { + paddingVertical: spacing.sm, + }, + errorText: { + paddingVertical: spacing.sm, + }, + previewList: { + borderTopColor: colors.glassBorder, + borderTopWidth: StyleSheet.hairlineWidth, + }, + previewRow: { + gap: 2, + paddingVertical: spacing.sm, + borderBottomColor: colors.glassBorder, + borderBottomWidth: StyleSheet.hairlineWidth, + }, +}); diff --git a/src/components/library/CollapsingDetail.tsx b/src/components/library/CollapsingDetail.tsx index c2eb01c..f04f6e5 100644 --- a/src/components/library/CollapsingDetail.tsx +++ b/src/components/library/CollapsingDetail.tsx @@ -120,6 +120,7 @@ export function CollapsingHeader({ backdropUri, title, heroMeta, + heroExtra, disabled, onBack, onPlay, @@ -136,6 +137,8 @@ export function CollapsingHeader({ title: string; /** The middle of the hero block, between title and buttons (subtitle/meta or stat chips). */ heroMeta: ReactNode; + /** Optional compact control below meta, before the Play / Shuffle buttons. */ + heroExtra?: ReactNode; disabled?: boolean; onBack: () => void; onPlay: () => void; @@ -235,6 +238,7 @@ export function CollapsingHeader({ {title} {heroMeta} + {heroExtra} void; onLongPress?: () => void; }) { @@ -63,6 +66,7 @@ export function PlaylistRow({ {name} {remote ? : null} + {dynamic ? : null} {`${trackCount} ${trackCount === 1 ? 'track' : 'tracks'}`} diff --git a/src/components/library/PlaylistsView.tsx b/src/components/library/PlaylistsView.tsx index c7223c2..d8218c5 100644 --- a/src/components/library/PlaylistsView.tsx +++ b/src/components/library/PlaylistsView.tsx @@ -108,6 +108,23 @@ export function PlaylistsView({ ] : menuFor ? [ + ...(menuFor.kind === 'dynamic' + ? [ + { + key: 'edit-rules', + label: 'Edit rules', + icon: 'options-outline' as const, + onPress: () => { + const id = menuFor.id; + setMenuFor(null); + router.push({ + pathname: '/library/playlist/edit-dynamic' as never, + params: { id: String(id) }, + }); + }, + }, + ] + : []), { key: 'rename', label: 'Rename…', @@ -168,6 +185,7 @@ export function PlaylistsView({ missingCount={item.missing_track_count} coverHash={item.auto_cover_hash} remote={item.remote_source_id != null} + dynamic={item.kind === 'dynamic'} onPress={() => router.push(`/library/playlist/${item.id}`)} onLongPress={() => setMenuFor(item)} /> @@ -192,6 +210,16 @@ export function PlaylistsView({ New playlist + router.push('/library/playlist/edit-dynamic' as never)} + accessibilityRole="button" + > + + + New dynamic + + void handleImport()} @@ -240,6 +268,7 @@ const styles = StyleSheet.create({ }, actions: { flexDirection: 'row', + flexWrap: 'wrap', gap: spacing.md, marginTop: spacing.lg, }, diff --git a/src/components/sheets/PlaylistPickerSheet.tsx b/src/components/sheets/PlaylistPickerSheet.tsx index e0e5b74..8df61e8 100644 --- a/src/components/sheets/PlaylistPickerSheet.tsx +++ b/src/components/sheets/PlaylistPickerSheet.tsx @@ -46,6 +46,7 @@ export function PlaylistPickerSheet({ const addTracksToPlaylist = usePlaylistStore((s) => s.addTracksToPlaylist); const createPlaylist = usePlaylistStore((s) => s.createPlaylist); const trimmedPlaylistName = playlistName.trim(); + const targetPlaylists = playlists.filter((playlist) => playlist.kind !== 'dynamic'); const addToExisting = (playlistId: number) => { onClose(); @@ -104,12 +105,12 @@ export function PlaylistPickerSheet({ {onBackToMenu ? ( ) : null} - {playlists.length === 0 ? ( + {targetPlaylists.length === 0 ? ( No playlists yet. ) : null} - {playlists.map((playlist) => ( + {targetPlaylists.map((playlist) => ( ): DynamicPlaylistRulesV1 { + return { + version: 1, + conditions: [], + sort: { field: 'title', direction: 'asc' }, + limit: null, + ...overrides, + }; +} + +test('builds text, favorite, source, numeric, and date filters', () => { + const where = buildDynamicPlaylistWhereClause( + rules({ + conditions: [ + { kind: 'text', field: 'artist', operator: 'contains', value: 'Jane' }, + { kind: 'exact', field: 'favorite', operator: 'is', value: true }, + { kind: 'exact', field: 'source_type', operator: 'is_not', value: 'jellyfin' }, + { kind: 'numeric', field: 'play_count', operator: 'gte', value: 2 }, + { kind: 'date', field: 'added_at', operator: 'within_days', value: 7 }, + ], + }), + NOW + ); + + assert.equal(where.joins, 'LEFT JOIN favorites f ON f.track_path = t.path'); + assert.match(where.where, /LOWER\(COALESCE\(t.artist, ''\)\) LIKE \?/); + assert.match(where.where, /f.track_path IS NOT NULL/); + assert.match(where.where, /t.source_type <> \?/); + assert.match(where.where, /COALESCE\(t.play_count, 0\) >= \?/); + assert.match(where.where, /t.added_at >= \?/); + assert.deepEqual(where.params, ['%jane%', 'jellyfin', 2, NOW - 7 * 24 * 60 * 60 * 1000]); +}); + +test('builds last-played never and not-within filters', () => { + const never = buildDynamicPlaylistWhereClause( + rules({ + conditions: [{ kind: 'date', field: 'last_played_at', operator: 'never' }], + }), + NOW + ); + assert.equal(never.where, 't.last_played_at IS NULL'); + assert.deepEqual(never.params, []); + + const stale = buildDynamicPlaylistWhereClause( + rules({ + conditions: [{ kind: 'date', field: 'last_played_at', operator: 'not_within_days', value: 30 }], + }), + NOW + ); + assert.equal(stale.where, '(t.last_played_at IS NULL OR t.last_played_at < ?)'); + assert.deepEqual(stale.params, [NOW - 30 * 24 * 60 * 60 * 1000]); +}); + +test('builds stable sort clauses with null handling', () => { + assert.equal( + buildDynamicPlaylistOrderByClause( + rules({ sort: { field: 'play_count', direction: 'desc' } }) + ), + 'COALESCE(t.play_count, 0) DESC, t.path COLLATE NOCASE ASC' + ); + assert.equal( + buildDynamicPlaylistOrderByClause( + rules({ sort: { field: 'last_played_at', direction: 'asc' } }) + ), + 'CASE WHEN t.last_played_at IS NULL THEN 1 ELSE 0 END ASC, t.last_played_at ASC, t.path COLLATE NOCASE ASC' + ); +}); diff --git a/src/db/dynamicPlaylistSql.ts b/src/db/dynamicPlaylistSql.ts new file mode 100644 index 0000000..38e2c1f --- /dev/null +++ b/src/db/dynamicPlaylistSql.ts @@ -0,0 +1,165 @@ +import type { + DynamicPlaylistCondition, + DynamicPlaylistDateField, + DynamicPlaylistNumericField, + DynamicPlaylistRulesV1, + DynamicPlaylistSortField, + DynamicPlaylistTextField, +} from '../shared/playlists/dynamicPlaylist'; + +export interface DynamicPlaylistWhere { + joins: string; + where: string; + params: (string | number | null)[]; +} + +export interface DynamicPlaylistOrderField { + expression: string; + nullable: boolean; + text?: boolean; +} + +const DYNAMIC_TEXT_FIELD_SQL: Record = { + title: 't.title', + artist: 't.artist', + album: 't.album', + album_artist: 't.album_artist', + genre: 't.genre', + format: 't.format', + musical_key: 't.musical_key', +}; + +const DYNAMIC_NUMERIC_FIELD_SQL: Record = { + play_count: 'COALESCE(t.play_count, 0)', + year: 't.year', + duration_seconds: 't.duration', + bpm: 't.bpm', +}; + +const DYNAMIC_DATE_FIELD_SQL: Record = { + last_played_at: 't.last_played_at', + added_at: 't.added_at', +}; + +export const DYNAMIC_SORT_FIELD_SQL: Record = { + title: { expression: 't.title', nullable: false, text: true }, + artist: { expression: 't.artist', nullable: false, text: true }, + album: { expression: 't.album', nullable: false, text: true }, + added_at: { expression: 't.added_at', nullable: false }, + last_played_at: { expression: 't.last_played_at', nullable: true }, + play_count: { expression: 'COALESCE(t.play_count, 0)', nullable: false }, + year: { expression: 't.year', nullable: true }, + duration_seconds: { expression: 't.duration', nullable: false }, + bpm: { expression: 't.bpm', nullable: true }, +}; + +function appendDynamicTextCondition( + condition: Extract, + whereClauses: string[], + params: (string | number | null)[] +): void { + const expression = DYNAMIC_TEXT_FIELD_SQL[condition.field]; + const normalizedValue = condition.value.toLocaleLowerCase(); + if (condition.operator === 'contains') { + whereClauses.push(`LOWER(COALESCE(${expression}, '')) LIKE ?`); + params.push(`%${normalizedValue}%`); + return; + } + + whereClauses.push(`LOWER(COALESCE(${expression}, '')) ${condition.operator === 'is' ? '=' : '<>'} ?`); + params.push(normalizedValue); +} + +function appendDynamicExactCondition( + condition: Extract, + whereClauses: string[], + params: (string | number | null)[] +): void { + if (condition.field === 'source_type') { + whereClauses.push(`t.source_type ${condition.operator === 'is' ? '=' : '<>'} ?`); + params.push(condition.value); + return; + } + + const expectsFavorite = condition.operator === 'is' ? condition.value : !condition.value; + whereClauses.push(`f.track_path IS ${expectsFavorite ? 'NOT NULL' : 'NULL'}`); +} + +function appendDynamicNumericCondition( + condition: Extract, + whereClauses: string[], + params: (string | number | null)[] +): void { + const expression = DYNAMIC_NUMERIC_FIELD_SQL[condition.field]; + const operator = condition.operator === 'eq' ? '=' : condition.operator === 'gte' ? '>=' : '<='; + whereClauses.push(`${expression} ${operator} ?`); + params.push(condition.value); +} + +function appendDynamicDateCondition( + condition: Extract, + whereClauses: string[], + params: (string | number | null)[], + now: number +): void { + const expression = DYNAMIC_DATE_FIELD_SQL[condition.field]; + if (condition.field === 'last_played_at' && condition.operator === 'never') { + whereClauses.push(`${expression} IS NULL`); + return; + } + + const dayValue = typeof condition.value === 'number' ? condition.value : 1; + const cutoff = now - dayValue * 24 * 60 * 60 * 1000; + if (condition.field === 'last_played_at') { + if (condition.operator === 'within_days') { + whereClauses.push(`${expression} >= ?`); + params.push(cutoff); + return; + } + whereClauses.push(`(${expression} IS NULL OR ${expression} < ?)`); + params.push(cutoff); + return; + } + + whereClauses.push(`${expression} ${condition.operator === 'within_days' ? '>=' : '<'} ?`); + params.push(cutoff); +} + +export function buildDynamicPlaylistWhereClause( + rules: DynamicPlaylistRulesV1, + now: number = Date.now() +): DynamicPlaylistWhere { + const whereClauses: string[] = []; + const params: (string | number | null)[] = []; + const needsFavoriteJoin = rules.conditions.some( + (condition) => condition.kind === 'exact' && condition.field === 'favorite' + ); + + for (const condition of rules.conditions) { + if (condition.kind === 'text') { + appendDynamicTextCondition(condition, whereClauses, params); + } else if (condition.kind === 'exact') { + appendDynamicExactCondition(condition, whereClauses, params); + } else if (condition.kind === 'numeric') { + appendDynamicNumericCondition(condition, whereClauses, params); + } else { + appendDynamicDateCondition(condition, whereClauses, params, now); + } + } + + return { + joins: needsFavoriteJoin ? 'LEFT JOIN favorites f ON f.track_path = t.path' : '', + where: whereClauses.length > 0 ? whereClauses.join('\n AND ') : '1 = 1', + params, + }; +} + +export function buildDynamicPlaylistOrderByClause(rules: DynamicPlaylistRulesV1): string { + const sort = DYNAMIC_SORT_FIELD_SQL[rules.sort.field] ?? DYNAMIC_SORT_FIELD_SQL.title; + const direction = rules.sort.direction === 'desc' ? 'DESC' : 'ASC'; + const expression = sort.text ? `${sort.expression} COLLATE NOCASE` : sort.expression; + const nullablePrefix = sort.nullable + ? `CASE WHEN ${sort.expression} IS NULL THEN 1 ELSE 0 END ASC, ` + : ''; + return `${nullablePrefix}${expression} ${direction}, t.path COLLATE NOCASE ASC`; +} diff --git a/src/db/playlistQueries.ts b/src/db/playlistQueries.ts index 09d87b8..176417b 100644 --- a/src/db/playlistQueries.ts +++ b/src/db/playlistQueries.ts @@ -5,9 +5,21 @@ import type { DbTrack } from '@/types/library'; import type { Playlist, PlaylistTrackEntry } from '@/types/playlist'; import type { RemotePlaylist } from '@/types/remote'; import type { LibraryDatabase } from './database'; +import { + createDefaultDynamicPlaylistRules, + normalizeDynamicPlaylistRules, + type DynamicPlaylistPreview, + type DynamicPlaylistRulesV1, + type PlaylistKind, +} from '@/shared/playlists/dynamicPlaylist'; +import { + buildDynamicPlaylistOrderByClause, + buildDynamicPlaylistWhereClause, +} from './dynamicPlaylistSql'; const PLAYLIST_SELECT = ` - SELECT p.id, p.name, p.created_at, p.updated_at, p.last_played_at, p.remote_source_id, + SELECT p.id, p.name, p.kind, p.dynamic_rules_json, + p.created_at, p.updated_at, p.last_played_at, p.remote_source_id, (SELECT t.artwork_hash FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path WHERE pt.playlist_id = p.id AND t.artwork_hash IS NOT NULL @@ -21,15 +33,168 @@ const PLAYLIST_SELECT = ` FROM playlists p `; -export function getPlaylists(db: LibraryDatabase): Promise { - return db.all(` +interface PlaylistSummaryRow extends Playlist { + dynamic_rules_json: string | null; +} + +interface PlaylistRuleRow { + id: number; + kind: PlaylistKind; + dynamic_rules_json: string | null; +} + +const DYNAMIC_PLAYLIST_PREVIEW_TRACK_LIMIT = 25; + +function normalizePlaylistKind(value: unknown): PlaylistKind { + return value === 'dynamic' ? 'dynamic' : 'normal'; +} + +function serializeDynamicPlaylistRules(rules: DynamicPlaylistRulesV1): string { + return JSON.stringify(normalizeDynamicPlaylistRules(rules)); +} + +function parseDynamicPlaylistRules(rawRules: unknown): DynamicPlaylistRulesV1 { + if (typeof rawRules !== 'string' || rawRules.trim().length === 0) { + return createDefaultDynamicPlaylistRules(); + } + + try { + return normalizeDynamicPlaylistRules(JSON.parse(rawRules)); + } catch { + return createDefaultDynamicPlaylistRules(); + } +} + +async function readPlaylistRuleRow( + db: LibraryDatabase, + playlistId: number +): Promise { + if (!Number.isInteger(playlistId) || playlistId <= 0) return null; + const row = await db.get( + 'SELECT id, kind, dynamic_rules_json FROM playlists WHERE id = ? LIMIT 1', + [playlistId] + ); + if (!row) return null; + return { + ...row, + kind: normalizePlaylistKind(row.kind), + }; +} + +async function assertNormalPlaylist( + db: LibraryDatabase, + playlistId: number, + action: string +): Promise { + const row = await readPlaylistRuleRow(db, playlistId); + if (row?.kind === 'dynamic') { + throw new Error(`Dynamic playlists cannot ${action}.`); + } +} + +async function requireDynamicPlaylistRulesForId( + db: LibraryDatabase, + playlistId: number +): Promise { + const row = await readPlaylistRuleRow(db, playlistId); + if (!row) { + throw new Error('Playlist not found.'); + } + if (row.kind !== 'dynamic') { + throw new Error('Playlist is not dynamic.'); + } + return parseDynamicPlaylistRules(row.dynamic_rules_json); +} + +async function getDynamicPlaylistTracksForRules( + db: LibraryDatabase, + rules: DynamicPlaylistRulesV1 +): Promise { + const normalizedRules = normalizeDynamicPlaylistRules(rules); + const { joins, where, params } = buildDynamicPlaylistWhereClause(normalizedRules); + const orderBy = buildDynamicPlaylistOrderByClause(normalizedRules); + const limitSql = normalizedRules.limit === null ? '' : '\n LIMIT ?'; + const limitParams = normalizedRules.limit === null ? [] : [normalizedRules.limit]; + + return db.all( + `SELECT t.* FROM tracks t + ${joins} + WHERE ${where} + ORDER BY ${orderBy}${limitSql}`, + [...params, ...limitParams] + ); +} + +function dynamicTracksToEntries(tracks: DbTrack[]): PlaylistTrackEntry[] { + return tracks.map((track, index) => ({ + id: -index - 1, + track_path: track.path, + position: index, + added_at: track.added_at, + missing: false, + fallback_title: null, + fallback_artist: null, + fallback_album: null, + track, + })); +} + +async function buildDynamicPlaylistSummary( + db: LibraryDatabase, + row: PlaylistSummaryRow +): Promise { + const tracks = await getDynamicPlaylistTracksForRules(db, parseDynamicPlaylistRules(row.dynamic_rules_json)); + return { + id: row.id, + name: row.name, + kind: 'dynamic', + created_at: row.created_at, + updated_at: row.updated_at, + last_played_at: row.last_played_at, + auto_cover_hash: tracks.find((track) => track.artwork_hash)?.artwork_hash ?? null, + track_count: tracks.length, + missing_track_count: 0, + remote_source_id: null, + }; +} + +function buildNormalPlaylistSummary(row: PlaylistSummaryRow): Playlist { + return { + id: row.id, + name: row.name, + kind: 'normal', + created_at: row.created_at, + updated_at: row.updated_at, + last_played_at: row.last_played_at, + auto_cover_hash: row.auto_cover_hash, + track_count: row.track_count, + missing_track_count: row.missing_track_count, + remote_source_id: row.remote_source_id, + }; +} + +export async function getPlaylists(db: LibraryDatabase): Promise { + const rows = await db.all(` ${PLAYLIST_SELECT} ORDER BY (p.last_played_at IS NULL), p.last_played_at DESC, p.updated_at DESC `); + const playlists: Playlist[] = []; + for (const row of rows) { + playlists.push( + normalizePlaylistKind(row.kind) === 'dynamic' + ? await buildDynamicPlaylistSummary(db, row) + : buildNormalPlaylistSummary(row) + ); + } + return playlists; } export async function getPlaylist(db: LibraryDatabase, id: number): Promise { - return db.get(`${PLAYLIST_SELECT} WHERE p.id = ?`, [id]); + const row = await db.get(`${PLAYLIST_SELECT} WHERE p.id = ?`, [id]); + if (!row) return undefined; + return normalizePlaylistKind(row.kind) === 'dynamic' + ? buildDynamicPlaylistSummary(db, row) + : buildNormalPlaylistSummary(row); } export async function createPlaylist(db: LibraryDatabase, name: string): Promise { @@ -43,6 +208,63 @@ export async function createPlaylist(db: LibraryDatabase, name: string): Promise return row; } +export async function createDynamicPlaylist( + db: LibraryDatabase, + name: string, + rules: DynamicPlaylistRulesV1 +): Promise { + const trimmedName = name.trim(); + if (!trimmedName) { + throw new Error('Playlist name is required.'); + } + + const now = Date.now(); + const result = await db.run( + `INSERT INTO playlists (name, created_at, updated_at, kind, dynamic_rules_json) + VALUES (?, ?, ?, 'dynamic', ?)`, + [trimmedName, now, now, serializeDynamicPlaylistRules(rules)] + ); + const row = await getPlaylist(db, result.lastInsertRowid); + if (!row) throw new Error('Dynamic playlist insert failed'); + return row; +} + +export function getDynamicPlaylistRules( + db: LibraryDatabase, + playlistId: number +): Promise { + return requireDynamicPlaylistRulesForId(db, playlistId); +} + +export async function updateDynamicPlaylistRules( + db: LibraryDatabase, + playlistId: number, + rules: DynamicPlaylistRulesV1 +): Promise { + await requireDynamicPlaylistRulesForId(db, playlistId); + await db.run('UPDATE playlists SET dynamic_rules_json = ?, updated_at = ? WHERE id = ?', [ + serializeDynamicPlaylistRules(rules), + Date.now(), + playlistId, + ]); +} + +export async function previewDynamicPlaylist( + db: LibraryDatabase, + rules: DynamicPlaylistRulesV1 +): Promise { + const tracks = await getDynamicPlaylistTracksForRules(db, normalizeDynamicPlaylistRules(rules)); + return { + track_count: tracks.length, + tracks: tracks.slice(0, DYNAMIC_PLAYLIST_PREVIEW_TRACK_LIMIT).map((track) => ({ + path: track.path, + title: track.title, + artist: track.artist, + album: track.album, + })), + }; +} + export async function renamePlaylist(db: LibraryDatabase, id: number, name: string): Promise { await db.run('UPDATE playlists SET name = ?, updated_at = ? WHERE id = ?', [ name, @@ -79,6 +301,14 @@ export async function getPlaylistEntries( db: LibraryDatabase, playlistId: number ): Promise { + const ruleRow = await readPlaylistRuleRow(db, playlistId); + if (ruleRow?.kind === 'dynamic') { + return dynamicTracksToEntries(await getDynamicPlaylistTracksForRules( + db, + parseDynamicPlaylistRules(ruleRow.dynamic_rules_json) + )); + } + const rows = await db.all( `SELECT pt.id AS entry_id, pt.track_path AS entry_track_path, pt.position AS entry_position, pt.added_at AS entry_added_at, @@ -129,6 +359,7 @@ export async function addPlaylistEntries( playlistId: number, entries: PlaylistEntryInsert[] ): Promise { + await assertNormalPlaylist(db, playlistId, 'accept manual tracks'); if (entries.length === 0) return 0; let inserted = 0; await db.transaction(async (tx) => { @@ -185,6 +416,7 @@ export async function removeFromPlaylist( playlistId: number, trackPath: string ): Promise { + await assertNormalPlaylist(db, playlistId, 'remove tracks manually'); await db.transaction(async (tx) => { await tx.run('DELETE FROM playlist_tracks WHERE playlist_id = ? AND track_path = ?', [ playlistId, @@ -202,6 +434,7 @@ export async function movePlaylistTrack( trackPath: string, direction: -1 | 1 ): Promise { + await assertNormalPlaylist(db, playlistId, 'reorder tracks manually'); await db.transaction(async (tx) => { const row = await tx.get<{ id: number; position: number }>( 'SELECT id, position FROM playlist_tracks WHERE playlist_id = ? AND track_path = ?', diff --git a/src/db/queries.ts b/src/db/queries.ts index cabc827..1360875 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -122,16 +122,18 @@ export interface RemoteTrackUpsert { bitrate: number | null; channels: number | null; codec: string | null; + bpm: number | null; + musical_key: string | null; } const UPSERT_REMOTE_TRACK_SQL = ` INSERT INTO tracks ( path, folder_id, title, artist, album, album_artist, album_identity_key, duration, track_number, disc_number, year, genre, artwork_hash, format, - sample_rate, bit_depth, bitrate, channels, codec, source_type, source_id, - source_track_id, source_path, artwork_source_id, file_name, size, mtime, + sample_rate, bit_depth, bitrate, channels, codec, bpm, musical_key, + source_type, source_id, source_track_id, source_path, artwork_source_id, file_name, size, mtime, added_at, modified_at - ) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', NULL, 0, ?, ?) + ) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', NULL, 0, ?, ?) ON CONFLICT(path) DO UPDATE SET title = excluded.title, artist = excluded.artist, @@ -149,6 +151,8 @@ const UPSERT_REMOTE_TRACK_SQL = ` bitrate = excluded.bitrate, channels = excluded.channels, codec = excluded.codec, + bpm = excluded.bpm, + musical_key = excluded.musical_key, source_track_id = excluded.source_track_id, source_path = excluded.source_path, artwork_source_id = excluded.artwork_source_id, @@ -181,6 +185,8 @@ export async function upsertRemoteTracks( row.bitrate, row.channels, row.codec, + row.bpm, + row.musical_key, row.source_type, row.source_id, row.source_track_id, @@ -273,15 +279,25 @@ export async function getTrackCount(db: LibraryDatabase): Promise { * samples / external paths are ignored unless they are actually in the library. */ export async function markTrackPlayed(db: LibraryDatabase, path: string): Promise { - const result = await db.run( - `INSERT INTO playback_history (track_path, last_played_at, play_count) - SELECT path, ?, 1 FROM tracks WHERE path = ? - ON CONFLICT(track_path) DO UPDATE SET - last_played_at = excluded.last_played_at, - play_count = playback_history.play_count + 1`, - [Date.now(), path] - ); - return result.changes > 0; + const playedAt = Date.now(); + let recorded = false; + await db.transaction(async (tx) => { + const trackResult = await tx.run( + 'UPDATE tracks SET play_count = play_count + 1, last_played_at = ? WHERE path = ?', + [playedAt, path] + ); + if (trackResult.changes === 0) return; + await tx.run( + `INSERT INTO playback_history (track_path, last_played_at, play_count) + VALUES (?, ?, 1) + ON CONFLICT(track_path) DO UPDATE SET + last_played_at = excluded.last_played_at, + play_count = playback_history.play_count + 1`, + [path, playedAt] + ); + recorded = true; + }); + return recorded; } export function getRecentlyPlayedTracks( diff --git a/src/db/schema.ts b/src/db/schema.ts index 985902b..7a78df1 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -16,11 +16,12 @@ // marks playlists that mirror a server playlist (remote_source_id/remote_playlist_id) // so remote playlist sync can upsert + reconcile them; v13 adds `remote_sources.art_auth` // — a self-contained cover-art URL template the native Android Auto artwork provider uses -// to fetch server art without a JS round-trip. +// to fetch server art without a JS round-trip; v14 adds desktop-style dynamic +// playlist rules plus fresh aggregate track play stats (no playback_history backfill). import type { LibraryDatabase } from './database'; -export const SCHEMA_VERSION = 13; +export const SCHEMA_VERSION = 14; // One statement per entry — op-sqlite executes single statements. const MIGRATIONS: readonly (readonly string[])[] = [ @@ -259,6 +260,22 @@ const MIGRATIONS: readonly (readonly string[])[] = [ // It embeds a fixed Subsonic salt+token / Jellyfin api_key with an `__ASTRA_ART_ID__` // placeholder for the cover id; the password itself never leaves expo-secure-store. [`ALTER TABLE remote_sources ADD COLUMN art_auth TEXT`], + // v13 -> v14 — dynamic playlists and fresh aggregate play stats. Do not seed + // play_count / last_played_at from playback_history: existing recents remain for + // Home, while dynamic play-stat rules start fresh from this version forward. + [ + `ALTER TABLE tracks ADD COLUMN play_count INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE tracks ADD COLUMN last_played_at INTEGER`, + `ALTER TABLE tracks ADD COLUMN bpm REAL`, + `ALTER TABLE tracks ADD COLUMN musical_key TEXT`, + `ALTER TABLE playlists ADD COLUMN kind TEXT NOT NULL DEFAULT 'normal'`, + `ALTER TABLE playlists ADD COLUMN dynamic_rules_json TEXT`, + `UPDATE playlists SET kind = 'normal' WHERE kind IS NULL OR kind NOT IN ('normal', 'dynamic')`, + `UPDATE playlists SET dynamic_rules_json = NULL WHERE kind <> 'dynamic'`, + `CREATE INDEX IF NOT EXISTS idx_tracks_play_count ON tracks(play_count DESC)`, + `CREATE INDEX IF NOT EXISTS idx_tracks_last_played ON tracks(last_played_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_playlists_kind ON playlists(kind)`, + ], ]; export async function migrate(db: LibraryDatabase): Promise { diff --git a/src/library/folderTree.test.mts b/src/library/folderTree.test.mts index 6a23efb..2103296 100644 --- a/src/library/folderTree.test.mts +++ b/src/library/folderTree.test.mts @@ -42,6 +42,8 @@ function track(overrides: Partial = {}): DbTrack { bitrate: null, channels: null, codec: null, + bpm: null, + musical_key: null, source_type: 'local', source_id: null, source_track_id: null, @@ -52,6 +54,8 @@ function track(overrides: Partial = {}): DbTrack { mtime: 1, added_at: 1, modified_at: 1, + play_count: 0, + last_played_at: null, loudness_lufs: null, sample_peak: null, replay_gain_track_db: null, diff --git a/src/library/remoteSync.ts b/src/library/remoteSync.ts index 2092501..0f67349 100644 --- a/src/library/remoteSync.ts +++ b/src/library/remoteSync.ts @@ -64,6 +64,8 @@ function toUpsertRow(source: RemoteSourceRow, track: RemoteCatalogTrack): Remote bitrate: track.bitrate, channels: track.channels, codec: track.codec, + bpm: track.bpm, + musical_key: track.musical_key, }; } diff --git a/src/shared/playlists/dynamicPlaylist.test.mts b/src/shared/playlists/dynamicPlaylist.test.mts new file mode 100644 index 0000000..c67583c --- /dev/null +++ b/src/shared/playlists/dynamicPlaylist.test.mts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + DYNAMIC_PLAYLIST_PRESETS, + createDefaultDynamicPlaylistRules, + normalizeDynamicPlaylistRules, +} from './dynamicPlaylist.ts'; + +test('normalizes dynamic playlist defaults', () => { + assert.deepEqual(createDefaultDynamicPlaylistRules(), { + version: 1, + conditions: [], + sort: { field: 'title', direction: 'asc' }, + limit: null, + }); +}); + +test('normalizes supported condition kinds and trims text values', () => { + const rules = normalizeDynamicPlaylistRules({ + version: 1, + conditions: [ + { kind: 'text', field: 'artist', operator: 'contains', value: ' Jane ' }, + { kind: 'exact', field: 'favorite', operator: 'is', value: true }, + { kind: 'numeric', field: 'year', operator: 'gte', value: 2001.7 }, + { kind: 'date', field: 'last_played_at', operator: 'never' }, + ], + sort: { field: 'play_count', direction: 'desc' }, + limit: '25', + }); + + assert.deepEqual(rules, { + version: 1, + conditions: [ + { kind: 'text', field: 'artist', operator: 'contains', value: 'Jane' }, + { kind: 'exact', field: 'favorite', operator: 'is', value: true }, + { kind: 'numeric', field: 'year', operator: 'gte', value: 2001 }, + { kind: 'date', field: 'last_played_at', operator: 'never' }, + ], + sort: { field: 'play_count', direction: 'desc' }, + limit: 25, + }); +}); + +test('rejects incomplete and unsupported dynamic playlist rules', () => { + assert.throws( + () => + normalizeDynamicPlaylistRules({ + version: 1, + conditions: [{ kind: 'text', field: 'title', operator: 'contains', value: '' }], + }), + /Text value is required/ + ); + assert.throws( + () => + normalizeDynamicPlaylistRules({ + version: 2, + conditions: [], + }), + /version is not supported/ + ); + assert.throws( + () => + normalizeDynamicPlaylistRules({ + version: 1, + conditions: [], + limit: 5001, + }), + /5000 or less/ + ); +}); + +test('ships validated starter presets', () => { + const presetIds = DYNAMIC_PLAYLIST_PRESETS.map((preset) => preset.id); + assert.deepEqual(presetIds, [ + 'unplayed', + 'recently-added', + 'favorites', + 'most-played', + 'not-recently', + 'local-only', + ]); + + for (const preset of DYNAMIC_PLAYLIST_PRESETS) { + assert.equal(normalizeDynamicPlaylistRules(preset.rules).version, 1); + } +}); diff --git a/src/shared/playlists/dynamicPlaylist.ts b/src/shared/playlists/dynamicPlaylist.ts new file mode 100644 index 0000000..9dd910c --- /dev/null +++ b/src/shared/playlists/dynamicPlaylist.ts @@ -0,0 +1,391 @@ +export type PlaylistKind = 'normal' | 'dynamic'; + +export type DynamicPlaylistTextField = + | 'title' + | 'artist' + | 'album' + | 'album_artist' + | 'genre' + | 'format' + | 'musical_key'; + +export type DynamicPlaylistExactField = 'source_type' | 'favorite'; +export type DynamicPlaylistNumericField = 'play_count' | 'year' | 'duration_seconds' | 'bpm'; +export type DynamicPlaylistDateField = 'last_played_at' | 'added_at'; +export type DynamicPlaylistSortField = + | 'title' + | 'artist' + | 'album' + | 'added_at' + | 'last_played_at' + | 'play_count' + | 'year' + | 'duration_seconds' + | 'bpm'; + +export type DynamicPlaylistTextOperator = 'contains' | 'is' | 'is_not'; +export type DynamicPlaylistExactOperator = 'is' | 'is_not'; +export type DynamicPlaylistNumericOperator = 'eq' | 'gte' | 'lte'; +export type DynamicPlaylistLastPlayedOperator = 'never' | 'within_days' | 'not_within_days'; +export type DynamicPlaylistAddedAtOperator = 'within_days' | 'older_than_days'; +export type DynamicPlaylistSourceType = 'local' | 'subsonic' | 'jellyfin'; +export type DynamicPlaylistSortDirection = 'asc' | 'desc'; + +export interface DynamicPlaylistTextCondition { + kind: 'text'; + field: DynamicPlaylistTextField; + operator: DynamicPlaylistTextOperator; + value: string; +} + +export interface DynamicPlaylistSourceCondition { + kind: 'exact'; + field: 'source_type'; + operator: DynamicPlaylistExactOperator; + value: DynamicPlaylistSourceType; +} + +export interface DynamicPlaylistFavoriteCondition { + kind: 'exact'; + field: 'favorite'; + operator: DynamicPlaylistExactOperator; + value: boolean; +} + +export interface DynamicPlaylistNumericCondition { + kind: 'numeric'; + field: DynamicPlaylistNumericField; + operator: DynamicPlaylistNumericOperator; + value: number; +} + +export interface DynamicPlaylistLastPlayedCondition { + kind: 'date'; + field: 'last_played_at'; + operator: DynamicPlaylistLastPlayedOperator; + value?: number; +} + +export interface DynamicPlaylistAddedAtCondition { + kind: 'date'; + field: 'added_at'; + operator: DynamicPlaylistAddedAtOperator; + value: number; +} + +export type DynamicPlaylistCondition = + | DynamicPlaylistTextCondition + | DynamicPlaylistSourceCondition + | DynamicPlaylistFavoriteCondition + | DynamicPlaylistNumericCondition + | DynamicPlaylistLastPlayedCondition + | DynamicPlaylistAddedAtCondition; + +export interface DynamicPlaylistSort { + field: DynamicPlaylistSortField; + direction: DynamicPlaylistSortDirection; +} + +export interface DynamicPlaylistRulesV1 { + version: 1; + conditions: DynamicPlaylistCondition[]; + sort: DynamicPlaylistSort; + limit: number | null; +} + +export interface DynamicPlaylistPreview { + track_count: number; + tracks: { + path: string; + title: string; + artist: string; + album: string; + }[]; +} + +export interface DynamicPlaylistPreset { + id: string; + label: string; + rules: DynamicPlaylistRulesV1; +} + +export const DYNAMIC_PLAYLIST_TEXT_FIELDS: readonly DynamicPlaylistTextField[] = [ + 'title', + 'artist', + 'album', + 'album_artist', + 'genre', + 'format', + 'musical_key', +]; + +export const DYNAMIC_PLAYLIST_NUMERIC_FIELDS: readonly DynamicPlaylistNumericField[] = [ + 'play_count', + 'year', + 'duration_seconds', + 'bpm', +]; + +export const DYNAMIC_PLAYLIST_DATE_FIELDS: readonly DynamicPlaylistDateField[] = [ + 'last_played_at', + 'added_at', +]; + +export const DYNAMIC_PLAYLIST_EXACT_FIELDS: readonly DynamicPlaylistExactField[] = [ + 'source_type', + 'favorite', +]; + +export const DYNAMIC_PLAYLIST_SORT_FIELDS: readonly DynamicPlaylistSortField[] = [ + 'title', + 'artist', + 'album', + 'added_at', + 'last_played_at', + 'play_count', + 'year', + 'duration_seconds', + 'bpm', +]; + +export const DYNAMIC_PLAYLIST_SOURCE_TYPES: readonly DynamicPlaylistSourceType[] = [ + 'local', + 'subsonic', + 'jellyfin', +]; + +export const DEFAULT_DYNAMIC_PLAYLIST_SORT: DynamicPlaylistSort = { + field: 'title', + direction: 'asc', +}; + +export function createDefaultDynamicPlaylistRules(): DynamicPlaylistRulesV1 { + return { + version: 1, + conditions: [], + sort: { ...DEFAULT_DYNAMIC_PLAYLIST_SORT }, + limit: null, + }; +} + +export const DYNAMIC_PLAYLIST_PRESETS: readonly DynamicPlaylistPreset[] = [ + { + id: 'unplayed', + label: 'Unplayed', + rules: { + version: 1, + conditions: [{ kind: 'numeric', field: 'play_count', operator: 'eq', value: 0 }], + sort: { field: 'title', direction: 'asc' }, + limit: null, + }, + }, + { + id: 'recently-added', + label: 'Recently added', + rules: { + version: 1, + conditions: [{ kind: 'date', field: 'added_at', operator: 'within_days', value: 30 }], + sort: { field: 'added_at', direction: 'desc' }, + limit: null, + }, + }, + { + id: 'favorites', + label: 'Favorites', + rules: { + version: 1, + conditions: [{ kind: 'exact', field: 'favorite', operator: 'is', value: true }], + sort: { field: 'title', direction: 'asc' }, + limit: null, + }, + }, + { + id: 'most-played', + label: 'Most played', + rules: { + version: 1, + conditions: [{ kind: 'numeric', field: 'play_count', operator: 'gte', value: 1 }], + sort: { field: 'play_count', direction: 'desc' }, + limit: 100, + }, + }, + { + id: 'not-recently', + label: 'Not played recently', + rules: { + version: 1, + conditions: [{ kind: 'date', field: 'last_played_at', operator: 'not_within_days', value: 30 }], + sort: { field: 'last_played_at', direction: 'asc' }, + limit: null, + }, + }, + { + id: 'local-only', + label: 'Local only', + rules: { + version: 1, + conditions: [{ kind: 'exact', field: 'source_type', operator: 'is', value: 'local' }], + sort: { field: 'title', direction: 'asc' }, + limit: null, + }, + }, +]; + +const TEXT_OPERATORS: readonly DynamicPlaylistTextOperator[] = ['contains', 'is', 'is_not']; +const EXACT_OPERATORS: readonly DynamicPlaylistExactOperator[] = ['is', 'is_not']; +const NUMERIC_OPERATORS: readonly DynamicPlaylistNumericOperator[] = ['eq', 'gte', 'lte']; +const LAST_PLAYED_OPERATORS: readonly DynamicPlaylistLastPlayedOperator[] = [ + 'never', + 'within_days', + 'not_within_days', +]; +const ADDED_AT_OPERATORS: readonly DynamicPlaylistAddedAtOperator[] = [ + 'within_days', + 'older_than_days', +]; +const SORT_DIRECTIONS: readonly DynamicPlaylistSortDirection[] = ['asc', 'desc']; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireString(value: unknown, fieldName: string): string { + if (typeof value !== 'string') { + throw new Error(`${fieldName} must be a string.`); + } + return value; +} + +function requireArrayMember( + value: unknown, + allowed: readonly T[], + fieldName: string +): T { + if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) { + return value as T; + } + throw new Error(`${fieldName} is not supported.`); +} + +function normalizePositiveInteger(value: unknown, fieldName: string): number { + const numberValue = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numberValue) || numberValue < 1) { + throw new Error(`${fieldName} must be a positive number.`); + } + return Math.trunc(numberValue); +} + +function normalizeNumericValue(value: unknown, fieldName: DynamicPlaylistNumericField): number { + const numberValue = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numberValue)) { + throw new Error(`${fieldName} must be a number.`); + } + if (fieldName === 'year') return Math.trunc(numberValue); + return numberValue; +} + +function normalizeTextCondition(condition: Record): DynamicPlaylistTextCondition { + const field = requireArrayMember(condition.field, DYNAMIC_PLAYLIST_TEXT_FIELDS, 'Text field'); + const operator = requireArrayMember(condition.operator, TEXT_OPERATORS, 'Text operator'); + const value = requireString(condition.value, 'Text value').trim(); + if (!value) { + throw new Error('Text value is required.'); + } + return { kind: 'text', field, operator, value }; +} + +function normalizeExactCondition( + condition: Record +): DynamicPlaylistSourceCondition | DynamicPlaylistFavoriteCondition { + const field = requireArrayMember(condition.field, DYNAMIC_PLAYLIST_EXACT_FIELDS, 'Exact field'); + const operator = requireArrayMember(condition.operator, EXACT_OPERATORS, 'Exact operator'); + + if (field === 'source_type') { + const value = requireArrayMember(condition.value, DYNAMIC_PLAYLIST_SOURCE_TYPES, 'Source type'); + return { kind: 'exact', field, operator, value }; + } + + if (typeof condition.value !== 'boolean') { + throw new Error('Favorite value must be true or false.'); + } + return { kind: 'exact', field, operator, value: condition.value }; +} + +function normalizeNumericCondition(condition: Record): DynamicPlaylistNumericCondition { + const field = requireArrayMember(condition.field, DYNAMIC_PLAYLIST_NUMERIC_FIELDS, 'Numeric field'); + const operator = requireArrayMember(condition.operator, NUMERIC_OPERATORS, 'Numeric operator'); + const value = normalizeNumericValue(condition.value, field); + return { kind: 'numeric', field, operator, value }; +} + +function normalizeDateCondition( + condition: Record +): DynamicPlaylistLastPlayedCondition | DynamicPlaylistAddedAtCondition { + const field = requireArrayMember(condition.field, DYNAMIC_PLAYLIST_DATE_FIELDS, 'Date field'); + + if (field === 'last_played_at') { + const operator = requireArrayMember(condition.operator, LAST_PLAYED_OPERATORS, 'Last played operator'); + if (operator === 'never') { + return { kind: 'date', field, operator }; + } + return { + kind: 'date', + field, + operator, + value: normalizePositiveInteger(condition.value, 'Day value'), + }; + } + + const operator = requireArrayMember(condition.operator, ADDED_AT_OPERATORS, 'Added date operator'); + return { + kind: 'date', + field, + operator, + value: normalizePositiveInteger(condition.value, 'Day value'), + }; +} + +export function normalizeDynamicPlaylistCondition(value: unknown): DynamicPlaylistCondition { + if (!isRecord(value)) { + throw new Error('Dynamic playlist condition must be an object.'); + } + + const kind = requireArrayMember(value.kind, ['text', 'exact', 'numeric', 'date'] as const, 'Condition kind'); + if (kind === 'text') return normalizeTextCondition(value); + if (kind === 'exact') return normalizeExactCondition(value); + if (kind === 'numeric') return normalizeNumericCondition(value); + return normalizeDateCondition(value); +} + +export function normalizeDynamicPlaylistSort(value: unknown): DynamicPlaylistSort { + if (!isRecord(value)) return { ...DEFAULT_DYNAMIC_PLAYLIST_SORT }; + return { + field: requireArrayMember(value.field, DYNAMIC_PLAYLIST_SORT_FIELDS, 'Sort field'), + direction: requireArrayMember(value.direction, SORT_DIRECTIONS, 'Sort direction'), + }; +} + +export function normalizeDynamicPlaylistRules(value: unknown): DynamicPlaylistRulesV1 { + if (!isRecord(value)) { + throw new Error('Dynamic playlist rules must be an object.'); + } + if (value.version !== 1) { + throw new Error('Dynamic playlist rule version is not supported.'); + } + + const rawConditions = Array.isArray(value.conditions) ? value.conditions : []; + const limit = + value.limit === null || typeof value.limit === 'undefined' + ? null + : normalizePositiveInteger(value.limit, 'Result limit'); + if (limit !== null && limit > 5000) { + throw new Error('Result limit must be 5000 or less.'); + } + + return { + version: 1, + conditions: rawConditions.map(normalizeDynamicPlaylistCondition), + sort: normalizeDynamicPlaylistSort(value.sort), + limit, + }; +} diff --git a/src/stores/playlistStore.ts b/src/stores/playlistStore.ts index 5c55b7f..3a994ea 100644 --- a/src/stores/playlistStore.ts +++ b/src/stores/playlistStore.ts @@ -1,6 +1,10 @@ import { create } from 'zustand'; import type { DbTrack } from '@/types/library'; import type { Playlist, PlaylistTrackEntry } from '@/types/playlist'; +import type { + DynamicPlaylistPreview, + DynamicPlaylistRulesV1, +} from '@/shared/playlists/dynamicPlaylist'; import { openLibraryDb, type LibraryDatabase } from '@/db/database'; import { getAllTracks } from '@/db/queries'; import * as playlistDb from '@/db/playlistQueries'; @@ -41,6 +45,10 @@ interface PlaylistStore { openPlaylist: (id: number) => Promise; closePlaylist: () => void; createPlaylist: (name: string) => Promise; + createDynamicPlaylist: (name: string, rules: DynamicPlaylistRulesV1) => Promise; + getDynamicPlaylistRules: (id: number) => Promise; + updateDynamicPlaylistRules: (id: number, rules: DynamicPlaylistRulesV1) => Promise; + previewDynamicPlaylist: (rules: DynamicPlaylistRulesV1) => Promise; renamePlaylist: (id: number, name: string) => Promise; deletePlaylist: (id: number) => Promise; addTracksToPlaylist: (id: number, tracks: DbTrack[]) => Promise; @@ -117,6 +125,29 @@ export const usePlaylistStore = create((set, get) => { return playlist; }, + createDynamicPlaylist: async (name, rules) => { + const db = await openLibraryDb(); + const playlist = await playlistDb.createDynamicPlaylist(db, name, rules); + await refreshWith(db); + return playlist; + }, + + getDynamicPlaylistRules: async (id) => { + const db = await openLibraryDb(); + return playlistDb.getDynamicPlaylistRules(db, id); + }, + + updateDynamicPlaylistRules: async (id, rules) => { + const db = await openLibraryDb(); + await playlistDb.updateDynamicPlaylistRules(db, id, rules); + await refreshWith(db); + }, + + previewDynamicPlaylist: async (rules) => { + const db = await openLibraryDb(); + return playlistDb.previewDynamicPlaylist(db, rules); + }, + renamePlaylist: async (id, name) => { const db = await openLibraryDb(); await playlistDb.renamePlaylist(db, id, name); diff --git a/src/types/library.ts b/src/types/library.ts index 5b30439..1645f3f 100644 --- a/src/types/library.ts +++ b/src/types/library.ts @@ -24,6 +24,8 @@ export interface DbTrack { bitrate: number | null; channels: number | null; codec: string | null; + bpm: number | null; + musical_key: string | null; source_type: TrackSourceType; // Remote-source linkage (NULL for local tracks). source_id -> remote_sources.id; // source_track_id is the server's track id; artwork_source_id is its cover-art id. @@ -36,6 +38,8 @@ export interface DbTrack { mtime: number; added_at: number; modified_at: number; + play_count: number; + last_played_at: number | null; // M4 loudness facts (NULL until analyzed). loudness_lufs: integrated LUFS (dB, // negative); sample_peak: linear [0,1]; replay_gain_*: tag dB when present. loudness_lufs: number | null; diff --git a/src/types/playlist.ts b/src/types/playlist.ts index dd7d1cf..a7d93c0 100644 --- a/src/types/playlist.ts +++ b/src/types/playlist.ts @@ -2,10 +2,12 @@ // (astra src/main/services/library.ts, renderer playlistStore.ts). import type { DbTrack } from './library'; +import type { PlaylistKind } from '@/shared/playlists/dynamicPlaylist'; export interface Playlist { id: number; name: string; + kind: PlaylistKind; created_at: number; updated_at: number; last_played_at: number | null;