mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
initial port for dynamic playlists
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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'}`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
Reference in New Issue
Block a user