initial port for dynamic playlists

This commit is contained in:
Boof2015
2026-07-04 13:25:41 -04:00
parent 61e6147d3e
commit f9ef845800
20 changed files with 2226 additions and 36 deletions
@@ -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<TrackRow> =
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<TrackRow> =
queryTracks(
@@ -304,10 +308,29 @@ class AstraCarCatalog(private val context: Context) {
}
}
private fun getPlaylists(db: SQLiteDatabase): List<PlaylistRow> =
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<PlaylistRow> {
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<TrackRow> {
val rules = parseDynamicRules(rawRules)
val conditions = rules.optJSONArray("conditions")
val joins = StringBuilder()
val where = mutableListOf<String>()
val args = mutableListOf<String>()
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<String>,
args: MutableList<String>,
) {
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<String>,
args: MutableList<String>,
) {
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<String>,
args: MutableList<String>,
) {
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<String>,
args: MutableList<String>,
) {
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<String>,
args: MutableList<String>,
) {
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,
+1
View File
@@ -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"
},
+36 -3
View File
@@ -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={<Text variant="label">{meta}</Text>}
heroExtra={
isDynamic && playlistId != null ? (
<Pressable
style={styles.editRules}
onPress={() =>
router.push({
pathname: '/library/playlist/edit-dynamic' as never,
params: { id: String(playlistId) },
})
}
accessibilityRole="button"
>
<Ionicons name="sparkles" size={14} color={colors.accent} />
<Text variant="label" color={colors.accent}>
Edit rules
</Text>
</Pressable>
) : 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 ? (
<AppSheetItem
label="Remove from playlist"
icon="remove-circle-outline"
@@ -282,4 +303,16 @@ const styles = StyleSheet.create({
flex: 1,
gap: 2,
},
editRules: {
minHeight: 32,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 999,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
marginTop: spacing.sm,
},
});
@@ -0,0 +1,849 @@
import {
useEffect,
useMemo,
useState,
} from 'react';
import {
Alert,
Pressable,
ScrollView,
StyleSheet,
TextInput,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import {
AppSheet,
AppSheetItem,
AppSheetTitle,
} from '@/components/sheets/AppSheet';
import { colors, fonts, fontSize, radius, spacing } from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import {
DYNAMIC_PLAYLIST_PRESETS,
createDefaultDynamicPlaylistRules,
normalizeDynamicPlaylistRules,
type DynamicPlaylistAddedAtCondition,
type DynamicPlaylistCondition,
type DynamicPlaylistDateField,
type DynamicPlaylistExactField,
type DynamicPlaylistFavoriteCondition,
type DynamicPlaylistLastPlayedCondition,
type DynamicPlaylistNumericCondition,
type DynamicPlaylistNumericField,
type DynamicPlaylistPreview,
type DynamicPlaylistRulesV1,
type DynamicPlaylistSortField,
type DynamicPlaylistSourceCondition,
type DynamicPlaylistTextCondition,
type DynamicPlaylistTextField,
} from '@/shared/playlists/dynamicPlaylist';
type ConditionFieldKey =
`${DynamicPlaylistCondition['kind']}:${DynamicPlaylistTextField | DynamicPlaylistNumericField | DynamicPlaylistDateField | DynamicPlaylistExactField}`;
interface FieldOption {
key: ConditionFieldKey;
label: string;
}
type Picker = { kind: 'field'; index: number } | { kind: 'sort' } | null;
const FIELD_OPTIONS: readonly FieldOption[] = [
{ key: 'text:title', label: 'Title' },
{ key: 'text:artist', label: 'Artist' },
{ key: 'text:album', label: 'Album' },
{ key: 'text:album_artist', label: 'Album artist' },
{ key: 'text:genre', label: 'Genre' },
{ key: 'text:format', label: 'Format' },
{ key: 'text:musical_key', label: 'Key' },
{ key: 'numeric:play_count', label: 'Play count' },
{ key: 'numeric:year', label: 'Year' },
{ key: 'numeric:duration_seconds', label: 'Duration' },
{ key: 'numeric:bpm', label: 'BPM' },
{ key: 'date:last_played_at', label: 'Last played' },
{ key: 'date:added_at', label: 'Added' },
{ key: 'exact:favorite', label: 'Favorite' },
{ key: 'exact:source_type', label: 'Source' },
];
const SORT_LABELS: Record<DynamicPlaylistSortField, string> = {
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 (
<Pressable
style={[styles.chip, selected && styles.chipSelected]}
onPress={onPress}
accessibilityRole="button"
>
<Text variant="label" color={selected ? colors.accentTextStrong : colors.textSecondary}>
{label}
</Text>
</Pressable>
);
}
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 (
<View style={styles.chipRow}>
{options.map(([operator, label]) => (
<Chip
key={operator}
label={label}
selected={condition.operator === operator}
onPress={() => onChange(updateTextOperator(condition, operator))}
/>
))}
</View>
);
}
if (condition.kind === 'numeric') {
const options: [DynamicPlaylistNumericCondition['operator'], string][] = [
['eq', 'Is'],
['gte', 'At least'],
['lte', 'At most'],
];
return (
<View style={styles.chipRow}>
{options.map(([operator, label]) => (
<Chip
key={operator}
label={label}
selected={condition.operator === operator}
onPress={() => onChange(updateNumericOperator(condition, operator))}
/>
))}
</View>
);
}
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 (
<View style={styles.chipRow}>
{options.map(([operator, label]) => (
<Chip
key={operator}
label={label}
selected={condition.operator === operator}
onPress={() => onChange(updateDateOperator(condition, operator))}
/>
))}
</View>
);
}
const options: [DynamicPlaylistSourceCondition['operator'], string][] = [
['is', 'Is'],
['is_not', 'Is not'],
];
return (
<View style={styles.chipRow}>
{options.map(([operator, label]) => (
<Chip
key={operator}
label={label}
selected={condition.operator === operator}
onPress={() => onChange(updateExactOperator(condition, operator))}
/>
))}
</View>
);
}
function ConditionValue({
condition,
onChange,
}: {
condition: DynamicPlaylistCondition;
onChange: (condition: DynamicPlaylistCondition) => void;
}) {
if (condition.kind === 'text') {
return (
<TextInput
style={styles.input}
value={condition.value}
onChangeText={(value) => onChange({ ...condition, value })}
placeholder="Value"
placeholderTextColor={colors.textTertiary}
selectionColor={colors.accent}
/>
);
}
if (condition.kind === 'numeric') {
return (
<TextInput
style={styles.input}
value={Number.isFinite(condition.value) ? String(condition.value) : ''}
onChangeText={(value) => 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 (
<TextInput
style={styles.input}
value={String(condition.value ?? 30)}
onChangeText={(value) => 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 (
<View style={styles.chipRow}>
{options.map((value) => (
<Chip
key={value}
label={value === 'local' ? 'Local' : value === 'subsonic' ? 'Subsonic' : 'Jellyfin'}
selected={condition.value === value}
onPress={() => onChange({ ...condition, value })}
/>
))}
</View>
);
}
return (
<View style={styles.chipRow}>
<Chip
label="Yes"
selected={condition.value}
onPress={() => onChange({ ...condition, value: true })}
/>
<Chip
label="No"
selected={!condition.value}
onPress={() => onChange({ ...condition, value: false })}
/>
</View>
);
}
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<DynamicPlaylistRulesV1>(() => createDefaultDynamicPlaylistRules());
const [picker, setPicker] = useState<Picker>(null);
const [isLoadingRules, setIsLoadingRules] = useState(isEditing);
const [isSaving, setIsSaving] = useState(false);
const [preview, setPreview] = useState<DynamicPlaylistPreview | null>(null);
const [previewError, setPreviewError] = useState<string | null>(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 (
<AppSheet onClose={() => setPicker(null)}>
<AppSheetTitle title="Sort by" />
{Object.entries(SORT_LABELS).map(([field, label]) => (
<AppSheetItem
key={field}
label={label}
selected={rules.sort.field === field}
onPress={() => {
setRules((current) => ({
...current,
sort: { ...current.sort, field: field as DynamicPlaylistSortField },
}));
setPicker(null);
}}
/>
))}
</AppSheet>
);
}
return (
<AppSheet onClose={() => setPicker(null)}>
<AppSheetTitle title="Filter field" />
{FIELD_OPTIONS.map((option) => (
<AppSheetItem
key={option.key}
label={option.label}
selected={getConditionFieldKey(rules.conditions[picker.index]) === option.key}
onPress={() => {
setRules((current) => updateCondition(current, picker.index, createDefaultCondition(option.key)));
setPicker(null);
}}
/>
))}
</AppSheet>
);
};
return (
<Screen padded={false} style={styles.screen}>
<View style={styles.header}>
<Pressable
onPress={() => router.back()}
hitSlop={8}
style={styles.headerButton}
accessibilityRole="button"
accessibilityLabel="Back"
>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text variant="heading" numberOfLines={1} style={styles.headerTitle}>
{isEditing ? 'Edit dynamic playlist' : 'New dynamic playlist'}
</Text>
<Pressable
onPress={save}
disabled={saveDisabled}
hitSlop={8}
style={[styles.headerSave, saveDisabled && styles.disabled]}
accessibilityRole="button"
>
<Text variant="body" color={colors.accent}>
{isSaving ? 'Saving' : 'Save'}
</Text>
</Pressable>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={styles.content}
>
<View style={styles.section}>
<Text variant="caption" style={styles.sectionLabel}>
NAME
</Text>
<TextInput
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Playlist name"
placeholderTextColor={colors.textTertiary}
selectionColor={colors.accent}
/>
</View>
<View style={styles.section}>
<Text variant="caption" style={styles.sectionLabel}>
STARTERS
</Text>
<View style={styles.chipRow}>
{DYNAMIC_PLAYLIST_PRESETS.map((preset) => (
<Chip key={preset.id} label={preset.label} onPress={() => setRules(preset.rules)} />
))}
</View>
</View>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text variant="caption" style={styles.sectionLabel}>
FILTERS
</Text>
<View style={styles.inlineActions}>
<Pressable onPress={() => setRules(createDefaultDynamicPlaylistRules())} accessibilityRole="button">
<Text variant="label" color={colors.textSecondary}>
Reset
</Text>
</Pressable>
<Pressable onPress={addCondition} accessibilityRole="button">
<Text variant="label" color={colors.accent}>
Add
</Text>
</Pressable>
</View>
</View>
{rules.conditions.length === 0 ? (
<Text variant="label" color={colors.textTertiary} style={styles.emptyLine}>
No filters
</Text>
) : null}
{rules.conditions.map((condition, index) => (
<View key={index} style={styles.condition}>
<View style={styles.conditionHeader}>
<Pressable
style={styles.fieldButton}
onPress={() => setPicker({ kind: 'field', index })}
accessibilityRole="button"
>
<Text variant="body" color={colors.textPrimary}>
{fieldLabel(condition)}
</Text>
<Ionicons name="chevron-down" size={16} color={colors.textTertiary} />
</Pressable>
<Pressable
onPress={() => setRules((current) => removeCondition(current, index))}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Remove filter"
>
<Ionicons name="close" size={20} color={colors.textTertiary} />
</Pressable>
</View>
<OperatorChips
condition={condition}
onChange={(next) => setRules((current) => updateCondition(current, index, next))}
/>
<ConditionValue
condition={condition}
onChange={(next) => setRules((current) => updateCondition(current, index, next))}
/>
</View>
))}
</View>
<View style={styles.section}>
<Text variant="caption" style={styles.sectionLabel}>
SORT
</Text>
<Pressable style={styles.fieldButton} onPress={() => setPicker({ kind: 'sort' })} accessibilityRole="button">
<Text variant="body">{SORT_LABELS[rules.sort.field]}</Text>
<Ionicons name="chevron-down" size={16} color={colors.textTertiary} />
</Pressable>
<View style={styles.chipRow}>
<Chip
label="Ascending"
selected={rules.sort.direction === 'asc'}
onPress={() => setRules((current) => ({ ...current, sort: { ...current.sort, direction: 'asc' } }))}
/>
<Chip
label="Descending"
selected={rules.sort.direction === 'desc'}
onPress={() => setRules((current) => ({ ...current, sort: { ...current.sort, direction: 'desc' } }))}
/>
</View>
<TextInput
style={styles.input}
value={rules.limit === null ? '' : String(rules.limit)}
onChangeText={(value) =>
setRules((current) => ({
...current,
limit: value.trim() ? Number(value) : null,
}))
}
keyboardType="numeric"
placeholder="Limit"
placeholderTextColor={colors.textTertiary}
selectionColor={colors.accent}
/>
</View>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text variant="caption" style={styles.sectionLabel}>
PREVIEW
</Text>
<Text variant="label" color={colors.textSecondary}>
{isPreviewLoading ? 'Loading' : `${preview?.track_count ?? 0} tracks`}
</Text>
</View>
{previewError || normalizedRulesError ? (
<Text variant="label" color={colors.warning} style={styles.errorText}>
{previewError ?? normalizedRulesError}
</Text>
) : preview?.tracks.length ? (
<View style={styles.previewList}>
{preview.tracks.slice(0, 8).map((track) => (
<View key={track.path} style={styles.previewRow}>
<Text variant="body" numberOfLines={1}>
{track.title}
</Text>
<Text variant="label" numberOfLines={1} color={colors.textSecondary}>
{[track.artist, track.album].filter(Boolean).join(' / ')}
</Text>
</View>
))}
</View>
) : (
<Text variant="label" color={colors.textTertiary} style={styles.emptyLine}>
No matches
</Text>
)}
</View>
</ScrollView>
{picker !== null ? renderPicker() : null}
</Screen>
);
}
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,
},
});
@@ -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}
</Text>
{heroMeta}
{heroExtra}
<Animated.View style={[styles.actionRow, heroButtonsStyle]}>
<Pressable
style={[styles.actionButton, styles.primaryAction, disabled && styles.disabledAction]}
+4
View File
@@ -20,6 +20,7 @@ export function PlaylistRow({
coverHash,
pinned = false,
remote = false,
dynamic = false,
onPress,
onLongPress,
}: {
@@ -31,6 +32,8 @@ export function PlaylistRow({
pinned?: boolean;
/** Synced from a remote server — shows a cloud marker. */
remote?: boolean;
/** Rule-owned playlist — shows a spark marker. */
dynamic?: boolean;
onPress: () => void;
onLongPress?: () => void;
}) {
@@ -63,6 +66,7 @@ export function PlaylistRow({
{name}
</Text>
{remote ? <Ionicons name="cloud" size={12} color={colors.accent} /> : null}
{dynamic ? <Ionicons name="sparkles" size={12} color={colors.accent} /> : null}
</View>
<Text variant="label" numberOfLines={1}>
{`${trackCount} ${trackCount === 1 ? 'track' : 'tracks'}`}
+29
View File
@@ -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
</Text>
</Pressable>
<Pressable
style={styles.action}
onPress={() => router.push('/library/playlist/edit-dynamic' as never)}
accessibilityRole="button"
>
<Ionicons name="sparkles-outline" size={16} color={colors.accent} />
<Text variant="body" color={colors.accent}>
New dynamic
</Text>
</Pressable>
<Pressable
style={styles.action}
onPress={() => void handleImport()}
@@ -240,6 +268,7 @@ const styles = StyleSheet.create({
},
actions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.md,
marginTop: spacing.lg,
},
@@ -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 ? (
<AppSheetItem label="Track actions" icon="arrow-back" onPress={onBackToMenu} />
) : null}
{playlists.length === 0 ? (
{targetPlaylists.length === 0 ? (
<Text variant="caption" color={colors.textTertiary} style={styles.empty}>
No playlists yet.
</Text>
) : null}
{playlists.map((playlist) => (
{targetPlaylists.map((playlist) => (
<AppSheetItem
key={playlist.id}
label={playlist.name}
+77
View File
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildDynamicPlaylistOrderByClause,
buildDynamicPlaylistWhereClause,
} from './dynamicPlaylistSql.ts';
import type { DynamicPlaylistRulesV1 } from '../shared/playlists/dynamicPlaylist.ts';
const NOW = 1_800_000_000_000;
function rules(overrides: Partial<DynamicPlaylistRulesV1>): 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'
);
});
+165
View File
@@ -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<DynamicPlaylistTextField, string> = {
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<DynamicPlaylistNumericField, string> = {
play_count: 'COALESCE(t.play_count, 0)',
year: 't.year',
duration_seconds: 't.duration',
bpm: 't.bpm',
};
const DYNAMIC_DATE_FIELD_SQL: Record<DynamicPlaylistDateField, string> = {
last_played_at: 't.last_played_at',
added_at: 't.added_at',
};
export const DYNAMIC_SORT_FIELD_SQL: Record<DynamicPlaylistSortField, DynamicPlaylistOrderField> = {
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<DynamicPlaylistCondition, { kind: 'text' }>,
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<DynamicPlaylistCondition, { kind: 'exact' }>,
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<DynamicPlaylistCondition, { kind: 'numeric' }>,
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<DynamicPlaylistCondition, { kind: 'date' }>,
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`;
}
+237 -4
View File
@@ -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<Playlist[]> {
return db.all<Playlist>(`
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<PlaylistRuleRow | null> {
if (!Number.isInteger(playlistId) || playlistId <= 0) return null;
const row = await db.get<PlaylistRuleRow>(
'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<void> {
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<DynamicPlaylistRulesV1> {
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<DbTrack[]> {
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<DbTrack>(
`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<Playlist> {
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<Playlist[]> {
const rows = await db.all<PlaylistSummaryRow>(`
${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<Playlist | undefined> {
return db.get<Playlist>(`${PLAYLIST_SELECT} WHERE p.id = ?`, [id]);
const row = await db.get<PlaylistSummaryRow>(`${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<Playlist> {
@@ -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<Playlist> {
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<DynamicPlaylistRulesV1> {
return requireDynamicPlaylistRulesForId(db, playlistId);
}
export async function updateDynamicPlaylistRules(
db: LibraryDatabase,
playlistId: number,
rules: DynamicPlaylistRulesV1
): Promise<void> {
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<DynamicPlaylistPreview> {
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<void> {
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<PlaylistTrackEntry[]> {
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<EntryRow>(
`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<number> {
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<void> {
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<void> {
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 = ?',
+28 -12
View File
@@ -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<number> {
* samples / external paths are ignored unless they are actually in the library.
*/
export async function markTrackPlayed(db: LibraryDatabase, path: string): Promise<boolean> {
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(
+19 -2
View File
@@ -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<void> {
+4
View File
@@ -42,6 +42,8 @@ function track(overrides: Partial<DbTrack> = {}): 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> = {}): 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,
+2
View File
@@ -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,
};
}
@@ -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);
}
});
+391
View File
@@ -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<string, unknown> {
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<T extends string>(
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<string, unknown>): 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<string, unknown>
): 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<string, unknown>): 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<string, unknown>
): 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,
};
}
+31
View File
@@ -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<void>;
closePlaylist: () => void;
createPlaylist: (name: string) => Promise<Playlist>;
createDynamicPlaylist: (name: string, rules: DynamicPlaylistRulesV1) => Promise<Playlist>;
getDynamicPlaylistRules: (id: number) => Promise<DynamicPlaylistRulesV1>;
updateDynamicPlaylistRules: (id: number, rules: DynamicPlaylistRulesV1) => Promise<void>;
previewDynamicPlaylist: (rules: DynamicPlaylistRulesV1) => Promise<DynamicPlaylistPreview>;
renamePlaylist: (id: number, name: string) => Promise<void>;
deletePlaylist: (id: number) => Promise<void>;
addTracksToPlaylist: (id: number, tracks: DbTrack[]) => Promise<number>;
@@ -117,6 +125,29 @@ export const usePlaylistStore = create<PlaylistStore>((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);
+4
View File
@@ -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;
+2
View File
@@ -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;