diff --git a/package-lock.json b/package-lock.json index 4f501d83..b490353e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,15 @@ { "name": "musicat", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "musicat", - "version": "0.11.0", + "version": "0.12.0", "dependencies": { + "@zokugun/dynopl": "^0.1.1", + "ajv": "^8.17.1", "chord-symbol": "^4.0.0", "is-dark-color": "^1.2.0", "mousetrap": "^1.6.5", @@ -16,7 +18,9 @@ "svelecte": "^4.5.1", "svelte-tiny-virtual-list": "^2.1.2", "svrollbar": "^0.12.0", - "wtf-plugin-html": "^1.0.0" + "uuid": "^11.1.0", + "wtf-plugin-html": "^1.0.0", + "yaml": "^2.7.0" }, "devDependencies": { "@alexanderolsen/libsamplerate-js": "^2.1.1", @@ -1471,6 +1475,12 @@ "simple-yenc": "^1.0.4" } }, + "node_modules/@zokugun/dynopl": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@zokugun/dynopl/-/dynopl-0.1.1.tgz", + "integrity": "sha512-TOaOBwdDGjvKMi2fpT7DkvahDMzoQrXZsRnNvvrIMQlUTGmFgFmXKp1i2TkB835I3RnlQtoCcXXLWVtzS1jQCQ==", + "license": "MIT" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -1508,6 +1518,22 @@ "node": ">= 8.0.0" } }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", @@ -2446,6 +2472,28 @@ "type": "^2.7.2" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/file-type": { "version": "16.5.4", "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", @@ -2955,6 +3003,12 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -3881,6 +3935,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -5684,6 +5747,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -6323,6 +6399,18 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "13.3.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", diff --git a/package.json b/package.json index 7402e2aa..4e727979 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,8 @@ "wtf_wikipedia": "^10.3.2" }, "dependencies": { + "@zokugun/dynopl": "^0.1.1", + "ajv": "^8.17.1", "chord-symbol": "^4.0.0", "is-dark-color": "^1.2.0", "mousetrap": "^1.6.5", @@ -82,6 +84,8 @@ "svelecte": "^4.5.1", "svelte-tiny-virtual-list": "^2.1.2", "svrollbar": "^0.12.0", - "wtf-plugin-html": "^1.0.0" + "uuid": "^11.1.0", + "wtf-plugin-html": "^1.0.0", + "yaml": "^2.7.0" } } diff --git a/src/App.d.ts b/src/App.d.ts index ec228cdd..701b150a 100644 --- a/src/App.d.ts +++ b/src/App.d.ts @@ -1,4 +1,5 @@ import type { UserQueryPart } from "./lib/smart-query/UserQueryPart"; +import type { Playlist as DynamicPlaylist } from "@zokugun/dynopl"; interface MetadataEntry { /** Musicat's cross-file tag identifier */ @@ -102,11 +103,18 @@ interface Playlist { tracks: string[]; } -interface PlaylistFile { +interface StaticPlaylistFile { path: string; title: string; // the filename } +interface DynamicPlaylistFile { + path: string; + title: string; // the filename + schema: DynamicPlaylist; + query?: SmartQuery; +} + /** * Represents a song/track that's in progress. */ diff --git a/src/data/FolderWatcher.ts b/src/data/FolderWatcher.ts index 14da6670..05706d89 100644 --- a/src/data/FolderWatcher.ts +++ b/src/data/FolderWatcher.ts @@ -17,7 +17,7 @@ import { loadArtistsFromSongbook, loadSongProjectsForArtist, } from "./ArtistsToolkitData"; -import { scanPlaylists } from "./M3UUtils"; +import { scanPlaylists } from "./PlaylistUtils"; // can also watch an array of paths export async function startWatchingLibraryFolders() { diff --git a/src/data/M3UUtils.ts b/src/data/M3UUtils.ts deleted file mode 100644 index 4254597f..00000000 --- a/src/data/M3UUtils.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { - exists, - mkdir, - readDir, - readTextFile, - rename, - writeTextFile, -} from "@tauri-apps/plugin-fs"; -import md5 from "md5"; -import { get } from "svelte/store"; -import type { PlaylistFile, Song } from "../App"; -import { db } from "./db"; -import { selectedPlaylistFile, userPlaylists, userSettings } from "./store"; -import { moveArrayElement } from "../utils/ArrayUtils"; -import { invoke } from "@tauri-apps/api/core"; -import { path } from "@tauri-apps/api"; - -interface M3UTrack { - duration: number; // Duration in seconds, -1 if unknown - title: string; // Track title and artist - path: string; // Path to the media file (local or URL) -} - -interface M3U { - tracks: M3UTrack[]; // List of tracks -} - -function parse(contents: string): M3U { - const lines = contents.split(/\r?\n/).filter((line) => line.trim() !== ""); - const tracks: M3UTrack[] = []; - let currentTrack: Partial = {}; - - for (const line of lines) { - if (line.trim().startsWith("#EXTINF:")) { - // Parse EXTINF line - const [, duration, title] = - line.match(/#EXTINF:([+-]?\d+(?:\.\d+)?),(.+)/) || []; - currentTrack = { - duration: parseInt(duration, 10), - title: title.trim(), - }; - } else if (!line.trim().startsWith("#")) { - // Parse media file path - if (currentTrack) { - currentTrack.path = line.trim(); - tracks.push(currentTrack as M3UTrack); - currentTrack = {}; // Reset for the next track - } - } - } - - return { tracks }; -} - -/** - * Converts an M3U object into an M3U playlist string. - * @param m3u The M3U object to convert. - * @returns The M3U playlist as a string. - */ -function write(m3u: M3U): string { - const lines: string[] = ["#EXTM3U"]; - - for (const track of m3u.tracks) { - lines.push(`#EXTINF:${track.duration},${track.title}`); - lines.push(track.path); - } - - return lines.join("\n"); -} - -export async function scanPlaylists() { - console.log("[M3U] Scanning playlists..."); - const playlistsLocation = get(userSettings).playlistsLocation; - // Read directory (or create if non existent) - const locationExists = await exists(playlistsLocation); - if (!locationExists) { - await mkdir(playlistsLocation); - } - const entries = await readDir(playlistsLocation); - const m3uFiles: PlaylistFile[] = []; - for (const entry of entries) { - if (entry.isFile && entry.name.endsWith(".m3u")) { - m3uFiles.push({ - title: entry.name.split(".m3u")[0], - path: await path.join(playlistsLocation, entry.name), - }); - } - } - userPlaylists.set( - m3uFiles.sort((a, b) => { - return a.title.localeCompare(b.title); - }), - ); - console.log("[M3U]: Playlists: ", m3uFiles); -} - -export async function parsePlaylist( - playlistFile: PlaylistFile, -): Promise { - const fileContents = await readTextFile(playlistFile.path); - let playlist: M3U; - try { - playlist = parse(fileContents); - } catch (e) { - console.error(e); - return []; - } - - console.log("M3U: parsed playlist", playlist); - - // Do a db query to get all those songs - const songIds = playlist.tracks.map((c) => md5(c.path)); - try { - const songs = await db.songs.bulkGet(songIds); - console.log("M3U: got songs", songs); - return songs; - } catch (e) { - console.error(e); - return []; - } -} - -export async function addSongsToPlaylist( - playlistFile: PlaylistFile, - songs: Song[], -) { - const existingSongs = await parsePlaylist(playlistFile); - await writePlaylist(playlistFile, [...existingSongs, ...songs]); -} - -export async function insertSongsToPlaylist( - playlistFile: PlaylistFile, - songs: Song[], - index: number, -) { - const playlist = await parsePlaylist(playlistFile); - const newPlaylist = [...playlist]; - newPlaylist.splice(index, 0, ...songs); - await writePlaylist(playlistFile, newPlaylist); -} - -export async function createNewPlaylistFile(title: string, songs: Song[] = []) { - await writePlaylist( - { - path: await path.join( - get(userSettings).playlistsLocation, - `${title}.m3u`, - ), - title, - }, - songs, - ); - await scanPlaylists(); -} - -export async function renamePlaylist( - playlistFile: PlaylistFile, - newTitle: string, -) { - console.log("renamePlaylist", playlistFile, newTitle); - const newPath = playlistFile.path.replace(playlistFile.title, newTitle); - console.log(`Renaming ${playlistFile.path} to ${newPath}`); - await rename(playlistFile.path, newPath); - await scanPlaylists(); - selectedPlaylistFile.set({ - path: newPath, - title: newTitle, - }); -} - -export async function deletePlaylistFile(playlistFile: PlaylistFile) { - await invoke("delete_files", { - event: { - files: [playlistFile.path], - }, - }); - await scanPlaylists(); -} - -export async function reorderSongsInPlaylist( - playlistFile: PlaylistFile, - fromIdx: number, - toIdx: number, -) { - let playlist = await parsePlaylist(playlistFile); - playlist = moveArrayElement(playlist, fromIdx, toIdx); - await writePlaylist(playlistFile, playlist); -} - -export async function deleteSongsFromPlaylist( - playlistFile: PlaylistFile, - songs: Song[], -): Promise { - const playlist = await parsePlaylist(playlistFile); - const newPlaylist = playlist.filter( - (ps) => !songs.find((s) => s.id === ps.id), - ); - await writePlaylist(playlistFile, newPlaylist); - return parsePlaylist(playlistFile); -} - -export async function writePlaylist(playlistFile: PlaylistFile, songs: Song[]) { - const playlistsLocation = get(userSettings).playlistsLocation; - - const playlist: M3U = { - tracks: songs.map((s) => ({ - title: `${s.artist} - ${s.title}`, - path: s.path, - duration: s.fileInfo.duration, - })), - }; - - const m3u = write(playlist); - - // Write to file using fs, overwrite - await writeTextFile(playlistFile.path, m3u); -} diff --git a/src/data/PlaylistUtils.ts b/src/data/PlaylistUtils.ts new file mode 100644 index 00000000..59e120e6 --- /dev/null +++ b/src/data/PlaylistUtils.ts @@ -0,0 +1,444 @@ +import { + exists, + mkdir, + readDir, + readTextFile, + rename, + writeTextFile, +} from "@tauri-apps/plugin-fs"; +import md5 from "md5"; +import { get } from "svelte/store"; +import type { StaticPlaylistFile, Song, DynamicPlaylistFile } from "../App"; +import { db } from "./db"; +import { + selectedPlaylistFile, + userDynamicPlaylists, + userStaticPlaylists, + userSettings, +} from "./store"; +import { moveArrayElement } from "../utils/ArrayUtils"; +import { invoke } from "@tauri-apps/api/core"; +import { path } from "@tauri-apps/api"; +import YAML from "yaml"; +import Ajv from "ajv"; +import dynoPLSchema from "@zokugun/dynopl/lib/dynopl.schema.json"; +import type { Playlist as DynamicPlaylist } from "@zokugun/dynopl"; +import SmartQuery from "../lib/smart-query/Query"; +import { kebabCase } from "lodash-es"; + +interface M3UTrack { + duration: number; // Duration in seconds, -1 if unknown + title: string; // Track title and artist + path: string; // Path to the media file (local or URL) +} + +interface M3U { + tracks: M3UTrack[]; // List of tracks +} + +const VALID_DYNOPL_OPERATORS = { + contains: ["genre", "tags", "title"], + inTheRange: ["year"], + is: ["artist", "albumArtist", "composer", "originCountry", "year"], + gt: ["duration", "year"], +}; + +const AJV = new Ajv({ + allErrors: true, + allowUnionTypes: true, + useDefaults: true, + removeAdditional: true, +}); +const validateDynoPLSchema = AJV.compile(dynoPLSchema); + +const NUMBER_FIELDS = ["duration", "trackNumber", "compilation", "year"]; + +export async function addSongsToStaticPlaylist( + playlistFile: StaticPlaylistFile, + songs: Song[], +) { + const existingSongs = await loadStaticPlaylist(playlistFile); + await writeStaticPlaylist(playlistFile, [...existingSongs, ...songs]); +} + +export function composeComparator( + key: string, + descending: boolean, + eqComparator, +) { + if (NUMBER_FIELDS.includes(key)) { + return (a, b) => { + const d = a[key] - b[key]; + + if (d === 0) { + return eqComparator(a, b); + } else if (descending) { + return -d; + } else { + return d; + } + }; + } else { + return (a, b) => { + const d = a[key].localeCompare(b[key]); + + if (d === 0) { + return eqComparator(a, b); + } else if (descending) { + return -d; + } else { + return d; + } + }; + } +} + +export async function createNewStaticPlaylistFile( + title: string, + songs: Song[] = [], +) { + await writeStaticPlaylist( + { + path: await path.join( + get(userSettings).playlistsLocation, + `${title}.m3u`, + ), + title, + }, + songs, + ); + await scanPlaylists(); +} + +export async function deletePlaylistFile(playlistFile: StaticPlaylistFile) { + await invoke("delete_files", { + event: { + files: [playlistFile.path], + }, + }); + await scanPlaylists(); +} + +export async function deleteSongsFromStaticPlaylist( + playlistFile: StaticPlaylistFile, + songs: Song[], +): Promise { + const playlist = await loadStaticPlaylist(playlistFile); + const newPlaylist = playlist.filter( + (ps) => !songs.find((s) => s.id === ps.id), + ); + await writeStaticPlaylist(playlistFile, newPlaylist); + return loadStaticPlaylist(playlistFile); +} + +export function getComparator(key: string, descending: boolean) { + if (NUMBER_FIELDS.includes(key)) { + if (descending) { + return (a, b) => b[key] - a[key]; + } else { + return (a, b) => a[key] - b[key]; + } + } else { + if (descending) { + return (a, b) => b[key].localeCompare(a[key]); + } else { + return (a, b) => a[key].localeCompare(b[key]); + } + } +} + +export async function insertSongsToStaticPlaylist( + playlistFile: StaticPlaylistFile, + songs: Song[], + index: number, +) { + const playlist = await loadStaticPlaylist(playlistFile); + const newPlaylist = [...playlist]; + newPlaylist.splice(index, 0, ...songs); + await writeStaticPlaylist(playlistFile, newPlaylist); +} + +export async function loadDynamicPlaylist( + filepath: string, +): Promise { + const ext = await path.extname(filepath); + + if (ext === "json" || ext === "jdp") { + const content = await readTextFile(filepath); + const parsed = JSON.parse(content); + + if (validateDynoPL(parsed)) { + let title = parsed.name; + if (typeof title !== "string" || title.length === 0) { + title = delExt(await path.basename(filepath)); + + if (ext === "json") { + title = delExt(title); + } + } + + return { + title, + path: filepath, + schema: parsed, + }; + } + } else if (ext === "yaml" || ext === "yml" || ext === "ydp") { + const content = await readTextFile(filepath); + const parsed = YAML.parse(content); + + if (validateDynoPL(parsed)) { + let title = parsed.name; + if (typeof title !== "string" || title.length === 0) { + title = delExt(await path.basename(filepath)); + + if (ext === "yaml" || ext === "yml") { + title = delExt(title); + } + } + + return { + title, + path: filepath, + schema: parsed, + }; + } + } + + return null; +} + +export async function loadStaticPlaylist( + playlistFile: StaticPlaylistFile, +): Promise { + const fileContents = await readTextFile(playlistFile.path); + let playlist: M3U; + try { + playlist = parseStaticPlaylist(fileContents); + } catch (e) { + console.error(e); + return []; + } + + console.log("M3U: parsed playlist", playlist); + + // Do a db query to get all those songs + const songIds = playlist.tracks.map((c) => md5(c.path)); + try { + const songs = await db.songs.bulkGet(songIds); + console.log("M3U: got songs", songs); + return songs; + } catch (e) { + console.error(e); + return []; + } +} + +export async function renameStaticPlaylist( + playlistFile: StaticPlaylistFile, + newTitle: string, +) { + console.log("renameStaticPlaylist", playlistFile, newTitle); + const newPath = playlistFile.path.replace(playlistFile.title, newTitle); + console.log(`Renaming ${playlistFile.path} to ${newPath}`); + await rename(playlistFile.path, newPath); + await scanPlaylists(); + selectedPlaylistFile.set({ + path: newPath, + title: newTitle, + }); +} + +export async function reorderSongsInStaticPlaylist( + playlistFile: StaticPlaylistFile, + fromIdx: number, + toIdx: number, +) { + let playlist = await loadStaticPlaylist(playlistFile); + playlist = moveArrayElement(playlist, fromIdx, toIdx); + await writeStaticPlaylist(playlistFile, playlist); +} + +export async function scanPlaylists() { + console.log("[M3U] Scanning playlists..."); + const playlistsLocation = get(userSettings).playlistsLocation; + // Read directory (or create if non existent) + const locationExists = await exists(playlistsLocation); + if (!locationExists) { + await mkdir(playlistsLocation); + } + + for (const smartQuery of await db.smartQueries.toArray()) { + const name = kebabCase(smartQuery.name); + const filepath = await path.join( + playlistsLocation, + `${name}.dynopl.yaml`, + ); + const schema = SmartQuery.toDynoPL(smartQuery); + const playlist: DynamicPlaylistFile = { + title: smartQuery.name, + path: filepath, + schema, + }; + + await writeDynamicPlaylist(playlist); + + await db.smartQueries.delete(smartQuery.id); + } + + const dynamicFiles: DynamicPlaylistFile[] = []; + const staticFiles: StaticPlaylistFile[] = []; + const entries = await readDir(playlistsLocation); + + for (const entry of entries) { + if (entry.isFile) { + if (entry.name.endsWith(".m3u")) { + staticFiles.push({ + title: delExt(entry.name), + path: await path.join(playlistsLocation, entry.name), + }); + } else if ( + entry.name.endsWith(".dynopl.yaml") || + entry.name.endsWith(".dynopl.yml") || + entry.name.endsWith(".dynopl.json") || + entry.name.endsWith(".ydp") || + entry.name.endsWith(".jdp") + ) { + const playlist = await loadDynamicPlaylist( + await path.join(playlistsLocation, entry.name), + ); + + if (playlist) { + dynamicFiles.push(playlist); + } + } + } + } + + userDynamicPlaylists.set( + dynamicFiles.sort((a, b) => a.title.localeCompare(b.title)), + ); + userStaticPlaylists.set( + staticFiles.sort((a, b) => a.title.localeCompare(b.title)), + ); + + console.log("[DynoPL]: Playlists: ", dynamicFiles); + console.log("[M3U]: Playlists: ", staticFiles); +} + +export async function writeDynamicPlaylist(playlistFile: DynamicPlaylistFile) { + playlistFile.schema.name = playlistFile.title; + + let content = null; + + const ext = await path.extname(playlistFile.path); + + if (ext === "json" || ext === "jdp") { + content = JSON.stringify(playlistFile.schema, null, "\t"); + } else if (ext === "yaml" || ext === "yml" || ext === "ydp") { + content = YAML.stringify(playlistFile.schema); + } + + if (content) { + // Write to file using fs, overwrite + await writeTextFile(playlistFile.path, content); + } +} + +export async function writeStaticPlaylist( + playlistFile: StaticPlaylistFile, + songs: Song[], +) { + const playlist: M3U = { + tracks: songs.map((s) => ({ + title: `${s.artist} - ${s.title}`, + path: s.path, + duration: s.fileInfo.duration, + })), + }; + + const m3u = toStaticPlaylist(playlist); + + // Write to file using fs, overwrite + await writeTextFile(playlistFile.path, m3u); +} + +function delExt(filename: string): string { + const index = filename.lastIndexOf("."); + if (index > 0) { + return filename.substring(0, index); + } else { + return filename; + } +} + +function parseStaticPlaylist(contents: string): M3U { + const lines = contents.split(/\r?\n/).filter((line) => line.trim() !== ""); + const tracks: M3UTrack[] = []; + let currentTrack: Partial = {}; + + for (const line of lines) { + if (line.trim().startsWith("#EXTINF:")) { + // Parse EXTINF line + const [, duration, title] = + line.match(/#EXTINF:([+-]?\d+(?:\.\d+)?),(.+)/) || []; + currentTrack = { + duration: parseInt(duration, 10), + title: title.trim(), + }; + } else if (!line.trim().startsWith("#")) { + // Parse media file path + if (currentTrack) { + currentTrack.path = line.trim(); + tracks.push(currentTrack as M3UTrack); + currentTrack = {}; // Reset for the next track + } + } + } + + return { tracks }; +} + +/** + * Converts an M3U object into an M3U playlist string. + * @param m3u The M3U object to convert. + * @returns The M3U playlist as a string. + */ +function toStaticPlaylist(m3u: M3U): string { + const lines: string[] = ["#EXTM3U"]; + + for (const track of m3u.tracks) { + lines.push(`#EXTINF:${track.duration},${track.title}`); + lines.push(track.path); + } + + return lines.join("\n"); +} + +function validateDynoPL(data): boolean { + // if (!validateDynoPLSchema(data)) { + // console.log(validateDynoPLSchema.errors); + // return false; + // } + + if (!data.all) { + return false; + } + + for (var rule of data.all) { + const operator = Object.keys(rule)[0]; + const keys = VALID_DYNOPL_OPERATORS[operator]; + + if (!keys) { + return false; + } + + for (const key of Object.keys(rule[operator])) { + if (!keys.includes(key)) { + return false; + } + } + } + + return true; +} diff --git a/src/data/store.ts b/src/data/store.ts index 01c0ac49..c7652d7c 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -22,7 +22,7 @@ import type { IAFile, IAItem, ImportStatus, - PlaylistFile, + StaticPlaylistFile, PopupType, UiView, Song, @@ -31,12 +31,13 @@ import type { UserSettings, WaveformPlayerState, PlayingSong, + DynamicPlaylistFile, } from "src/App"; import { derived, get, writable, type Writable } from "svelte/store"; import { locale } from "../i18n/i18n-svelte"; import { i18nString } from "../i18n/i18n-util"; import SmartQuery from "../lib/smart-query/Query"; -import { scanPlaylists } from "./M3UUtils"; +import { scanPlaylists } from "./PlaylistUtils"; import { liveQuery } from "dexie"; import { db } from "./db"; import { persistentWritable } from "./storeUtils"; @@ -159,7 +160,7 @@ export const playbackSpeed = writable(1.0); export const isFullScreenVisualiser = writable(false); // Playlists (populated from folder) -export const userPlaylists: Writable = writable([]); +export const userStaticPlaylists: Writable = writable([]); export const toDeletePlaylist = liveQuery(async () => { try { if (!db.hasBeenClosed()) { @@ -172,6 +173,9 @@ export const toDeletePlaylist = liveQuery(async () => { } return null; }); +export const userDynamicPlaylists: Writable = writable( + [], +); export const popupOpen: Writable = writable(null); export const uiView: Writable = writable("library"); @@ -232,7 +236,9 @@ export const isTagOrCondition = writable(false); // Playlists export type DragSource = "Library" | "Player" | "Queue" | "Sidebar"; export type SongOrigin = "Album" | "Playlist" | "SmartPlaylist"; -export const selectedPlaylistFile: Writable = writable(null); +export const selectedPlaylistFile: Writable< + StaticPlaylistFile | DynamicPlaylistFile +> = writable(null); export const draggedOrigin: Writable = writable(null); export const draggedSongs: Writable = writable([]); export const draggedSource: Writable = writable(null); diff --git a/src/data/storeHelper.ts b/src/data/storeHelper.ts index 997b11f7..fcf0ad16 100644 --- a/src/data/storeHelper.ts +++ b/src/data/storeHelper.ts @@ -1,5 +1,5 @@ import { remove } from "lodash-es"; -import type { Album, PlaylistFile, Song } from "src/App"; +import type { Album, StaticPlaylistFile, Song } from "src/App"; import type SmartQuery from "src/lib/smart-query/Query"; import { get } from "svelte/store"; import AudioPlayer from "../lib/player/AudioPlayer"; @@ -54,7 +54,7 @@ export function setDraggedAlbum( } export function setDraggedPlaylist( - playlist: PlaylistFile, + playlist: StaticPlaylistFile, songs: Song[], source: DragSource, ) { diff --git a/src/lib/library/CanvasLibrary.svelte b/src/lib/library/CanvasLibrary.svelte index 24233ff8..d422682c 100644 --- a/src/lib/library/CanvasLibrary.svelte +++ b/src/lib/library/CanvasLibrary.svelte @@ -27,10 +27,10 @@ import { Context } from "konva/lib/Context"; import toast from "svelte-french-toast"; import { - addSongsToPlaylist, - insertSongsToPlaylist, - reorderSongsInPlaylist, - } from "../../data/M3UUtils"; + addSongsToStaticPlaylist, + insertSongsToStaticPlaylist, + reorderSongsInStaticPlaylist, + } from "../../data/PlaylistUtils"; import { arrowFocus, current, @@ -1179,7 +1179,7 @@ resetDraggedSongs(); console.log("[Library] Adding to playlist: ", playlist); - await addSongsToPlaylist(playlist, songs); + await addSongsToStaticPlaylist(playlist, songs); toast.success( `${ songs.length > 1 ? songs.length + " songs" : songs[0].title @@ -1203,7 +1203,7 @@ resetDraggedSongs(); console.log("[Library] Insert to playlist: ", playlist); - await insertSongsToPlaylist(playlist, songs, idx); + await insertSongsToStaticPlaylist(playlist, songs, idx); toast.success( `${ songs.length > 1 ? songs.length + " songs" : songs[0].title @@ -1225,7 +1225,7 @@ toast.error($LL.library.orderDisabledHint()); } - await reorderSongsInPlaylist( + await reorderSongsInStaticPlaylist( $selectedPlaylistFile, draggingSongIdx, idx, diff --git a/src/lib/library/PlaylistHeader.svelte b/src/lib/library/PlaylistHeader.svelte index 6838d365..bd167c64 100644 --- a/src/lib/library/PlaylistHeader.svelte +++ b/src/lib/library/PlaylistHeader.svelte @@ -1,7 +1,10 @@ @@ -110,7 +148,9 @@ size={15} color={$uiView === "smart-query" ? "#45fffcf3" : "currentColor"} /> {selectedQuery?.name} + > {selectedQuery + ? selectedQuery.name + : $selectedPlaylistFile?.title} {/if} {#if durationText} @@ -122,7 +162,7 @@ {#if !$isSmartQueryBuilderOpen} - {#if $selectedSmartQuery?.startsWith("~usq:")} + {#if $selectedPlaylistFile} { - const queries = await db.smartQueries.toArray(); - return queries.sort((a, b) => a.name.localeCompare(b.name)); - }); - function onClickSmartPlaylist(e, smartQuery, reset) { $selectedPlaylistFile = null; $uiView = "smart-query"; @@ -656,12 +656,12 @@ $isSidebarFloating = false; } - function onClickSmartPlaylistOptions(e, query: SavedSmartQuery) { - menuX = e.clientX; - menuY = e.clientY; - smartPlaylistToEdit = query; - showSmartPlaylistMenu = !showSmartPlaylistMenu; - } + // function onClickSmartPlaylistOptions(e, query: SavedSmartQuery) { + // menuX = e.clientX; + // menuY = e.clientY; + // smartPlaylistToEdit = query; + // showSmartPlaylistMenu = !showSmartPlaylistMenu; + // } async function playSmartPlaylist(queryId: string) { const query = queryId.startsWith("~usq:") @@ -672,12 +672,12 @@ setQueue(songs, 0); } - async function onRenameSmartPlaylist(query: SavedSmartQuery) { - query.name = updatedSmartPlaylistName; - await db.smartQueries.put(query); - updatedSmartPlaylistName = ""; - isRenamingSmartPlaylist = false; - } + // async function onRenameSmartPlaylist(query: SavedSmartQuery) { + // query.name = updatedSmartPlaylistName; + // await db.smartQueries.put(query); + // updatedSmartPlaylistName = ""; + // isRenamingSmartPlaylist = false; + // } async function deleteSmartPlaylist() { if (!isConfirmingSmartPlaylistDelete) { @@ -731,6 +731,69 @@ activeSmartPlaylist = null; } + function onClickDynamicPlaylist(e, playlist: DynamicPlaylistFile) { + $uiView = "smart-query"; + // Opening a playlist will reset the query + $query = { + ...$query, + orderBy: "none", + reverse: false, + query: "", + }; + $selectedPlaylistFile = playlist; + $selectedSmartQuery = null; + $isSidebarFloating = false; + + activePlaylist = null; + } + + function onClickDynamicPlaylistOptions(e, playlist: DynamicPlaylistFile) { + menuX = e.clientX; + menuY = e.clientY; + playlistToEdit = playlist; + showPlaylistMenu = !showPlaylistMenu; + } + + function onMouseDownDynamicPlaylist(playlist: DynamicPlaylistFile) { + if (!$draggedSongs.length) { + activePlaylist = playlist; + } + } + + function onMouseEnterDynamicPlaylist(playlist: StaticPlaylistFile) { + hoveringOverPlaylistId = playlist?.path; + } + + async function onMouseLeaveDynamicPlaylist(e) { + draggingOverPlaylist = null; + hoveringOverPlaylistId = null; + + if (activePlaylist) { + // const songs = await loadStaticPlaylist(activePlaylist); + // setDraggedPlaylist(activePlaylist, songs, "Sidebar"); + // activePlaylist = null; + } + } + + async function onMouseUpDynamicPlaylist(playlist: DynamicPlaylistFile) { + if ($draggedOrigin === "Playlist" && $draggedTitle === playlist.title) { + resetDraggedSongs(); + } else if ($draggedSongs.length) { + resetDraggedSongs(); + } + + activePlaylist = null; + } + + async function onRenameDynamicPlaylist(playlist: DynamicPlaylistFile) { + updatedPlaylistName = ""; + isRenamingPlaylist = false; + } + + async function playDynamicPlaylist(playlist: DynamicPlaylistFile) { + console.log(playlist); + } + async function favouriteCurrentSong() { if (!$current.song) return; $current.song.isFavourite = !$current.song.isFavourite; @@ -1127,11 +1190,11 @@ class:selected={$uiView === "library" && !$selectedPlaylistFile} on:click={() => { + $uiView = "library"; $isSmartQueryBuilderOpen = false; $selectedPlaylistFile = null; $selectedSmartQuery = null; $query.orderBy = $query.libraryOrderBy; - $uiView = "library"; $isSidebarFloating = false; }} > @@ -1233,7 +1296,7 @@ {#if isPlaylistsExpanded}
- {#each $userPlaylists as playlist (playlist.path)} + {#each $userStaticPlaylists as playlist (playlist.path)}
- onClickPlaylistOptions(e, playlist)} + onClickStaticPlaylistOptions( + e, + playlist, + )} on:click={(e) => - onClickPlaylist(e, playlist)} + onClickStaticPlaylist(e, playlist)} on:dblclick={() => - playPlaylist(playlist)} + playStaticPlaylist(playlist)} on:mouseenter|preventDefault|stopPropagation={() => - onMouseEnterPlaylist(playlist)} - on:mouseleave|preventDefault|stopPropagation={onMouseLeavePlaylist} + onMouseEnterStaticPlaylist( + playlist, + )} + on:mouseleave|preventDefault|stopPropagation={onMouseLeaveStaticPlaylist} on:mousedown|preventDefault|stopPropagation={() => - onMouseDownPlaylist(playlist)} + onMouseDownStaticPlaylist(playlist)} on:mouseup|preventDefault|stopPropagation={() => - onMouseUpPlaylist(playlist)} + onMouseUpStaticPlaylist(playlist)} > {#if isRenamingPlaylist && playlistToEdit.title === playlist.title} { - onRenamePlaylist(playlist); + onRenameStaticPlaylist( + playlist, + ); }} fullWidth minimal @@ -1293,7 +1363,7 @@ color="#898989" size={14} onClick={(e) => - onClickPlaylistOptions( + onClickStaticPlaylistOptions( e, playlist, )} @@ -1306,7 +1376,7 @@ @@ -1371,47 +1441,44 @@

{smartQuery.name}

{/each} - {#each $savedSmartQueries as query (query.id)} + {#each $userDynamicPlaylists as playlist (playlist.path)}
- onClickSmartPlaylistOptions( + onClickDynamicPlaylistOptions( e, - query, + playlist, )} on:click={(e) => - onClickSmartPlaylist( - e, - `~usq:${query.id}`, - false, - )} + onClickDynamicPlaylist(e, playlist)} on:dblclick={() => - playSmartPlaylist( - `~usq:${query.id}`, - )} - on:mouseleave|preventDefault|stopPropagation={onMouseLeaveSmartPlaylist} + playDynamicPlaylist(playlist)} on:mouseenter|preventDefault|stopPropagation={() => - onMouseEnterSmartPlaylist(query.id)} + onMouseEnterDynamicPlaylist( + playlist, + )} + on:mouseleave|preventDefault|stopPropagation={onMouseLeaveDynamicPlaylist} on:mousedown|preventDefault|stopPropagation={() => - onMouseDownSmartPlaylist( - `~usq:${query.id}`, + onMouseDownDynamicPlaylist( + playlist, )} - on:mouseup|preventDefault|stopPropagation={onMouseUpSmartPlaylist} + on:mouseup|preventDefault|stopPropagation={() => + onMouseUpDynamicPlaylist(playlist)} > - {#if isRenamingSmartPlaylist && smartPlaylistToEdit.id === query.id} + {#if isRenamingPlaylist && playlistToEdit.title === playlist.title} { - onRenameSmartPlaylist( - query, + onRenameDynamicPlaylist( + playlist, ); }} fullWidth @@ -1419,46 +1486,37 @@ autoFocus /> {:else} -

{query.name}

+

{playlist.title}

{/if} - {#if isRenamingSmartPlaylist && smartPlaylistToEdit.id === query.id} + {#if isRenamingPlaylist && playlistToEdit.title === playlist.title} { e.stopPropagation(); - isRenamingSmartPlaylist = false; + isRenamingPlaylist = false; }} /> {:else}
- onClickSmartPlaylistOptions( + onClickDynamicPlaylistOptions( e, - query, + playlist, )} />
{/if}
{/each} - -
{/if} {#if $userSettings.isArtistsToolkitEnabled} @@ -1559,7 +1617,7 @@ > { - playPlaylist(playlistToEdit); + playStaticPlaylist(playlistToEdit); showPlaylistMenu = false; }} text="Play playlist" diff --git a/src/lib/smart-query/Query.ts b/src/lib/smart-query/Query.ts index 98acdc2d..9b4e4cbf 100644 --- a/src/lib/smart-query/Query.ts +++ b/src/lib/smart-query/Query.ts @@ -1,8 +1,16 @@ -import type { Song } from "src/App"; +import type { DynamicPlaylistFile, Song } from "src/App"; import { db } from "../../data/db"; import { isSmartQueryValid, smartQueryUpdater } from "../../data/store"; -import type { SavedSmartQuery } from "./QueryPart"; +import type { + FieldKey, + QueryPartStructWithValues, + SavedSmartQuery, +} from "./QueryPart"; import { UserQueryPart } from "./UserQueryPart"; +import { snakeCase, upperFirst } from "lodash-es"; +import type { Playlist as DynamicPlaylist } from "@zokugun/dynopl"; +import { v7 as uuidv7 } from "uuid"; + export default class SmartQuery { parts: UserQueryPart[] = []; @@ -10,6 +18,147 @@ export default class SmartQuery { name: string = null; userInput: string = ""; + static fromDynoPL(playlist: DynamicPlaylistFile): void { + const queryParts: QueryPartStructWithValues[] = []; + + for (const data of playlist.schema.all) { + if (data.contains) { + const key = Object.keys(data.gt)[0]; + const pathKey = + key === "title" + ? "titleContains" + : `contains${upperFirst(key)}`; + const valueKey = key === "title" ? "text" : key; + const part: QueryPartStructWithValues = { + dataType: "song", + fieldKey: key as FieldKey, + comparison: "contains", + description: `smartPlaylists.builder.parts.${pathKey}.title`, + example: `smartPlaylists.builder.parts.${pathKey}.example`, + prompt: + key === "title" + ? "title contains {text}" + : key === "genre" + ? "contains genre {genre}" + : "contains tag {tags}", + name: + key === "title" + ? "title-contains" + : key === "genre" + ? "contains-genre" + : "contains-tag", + inputRequired: { + [valueKey]: { + defaultVal: "", + isFieldKey: true, + isRequired: true, + type: "string", + }, + }, + values: { + [valueKey]: data.contains[key], + }, + }; + + queryParts.push(part); + } else if (data.inTheRange) { + const part: QueryPartStructWithValues = { + dataType: "song", + fieldKey: "year", + comparison: "is-between", + description: + "smartPlaylists.builder.parts.releasedBetween.title", + example: + "smartPlaylists.builder.parts.releasedBetween.example", + prompt: "released between {startYear} and {endYear}", + name: "released-between", + inputRequired: { + startYear: { + defaultVal: 1940, + isFieldKey: false, + isRequired: true, + type: "number", + }, + endYear: { + defaultVal: 1960, + isFieldKey: false, + isRequired: true, + type: "number", + }, + }, + values: { + startYear: data.inTheRange[0], + endYear: data.inTheRange[1], + }, + }; + + queryParts.push(part); + } else if (data.is) { + const key = Object.keys(data.is)[0]; + const ckey = key === "artistCountry" ? "originCountry" : key; + const byKey = `by${upperFirst(ckey)}`; + const part: QueryPartStructWithValues = { + comparison: "is-equal", + dataType: "song", + description: `smartPlaylists.builder.parts.${byKey}.title`, + example: `smartPlaylists.builder.parts.${byKey}.example`, + fieldKey: ckey as FieldKey, + inputRequired: { + [ckey]: { + defaultVal: "", + isFieldKey: true, + isRequired: true, + type: "string", + }, + }, + name: `by {${ckey}}`, + prompt: `by ${snakeCase(ckey).replaceAll("_", " ")} {${ckey}}`, + values: { + [ckey]: data.is[key], + }, + }; + + queryParts.push(part); + } else if (data.gt) { + const key = Object.keys(data.gt)[0]; + const pathKey = key === "year" ? "releasedAfter" : "longerThan"; + const valueKey = key === "year" ? "startYear" : "minutes"; + const part: QueryPartStructWithValues = { + dataType: "song", + fieldKey: key as FieldKey, + comparison: "is-greater-than", + description: `smartPlaylists.builder.parts.${pathKey}.title`, + example: `smartPlaylists.builder.parts.${pathKey}.example`, + prompt: + key === "year" + ? "released after {startYear}" + : "longer than {minutes}", + name: key === "year" ? "released-after" : "longer-than", + inputRequired: { + [valueKey]: { + defaultVal: key === "year" ? 1940 : 0, + isFieldKey: false, + isRequired: true, + type: "number", + }, + }, + values: { + [valueKey]: data.gt[key], + }, + }; + + queryParts.push(part); + } + } + + const query = new SmartQuery({ + name: playlist.title, + queryParts, + }); + + playlist.query = query; + } + static async loadWithUQI(queryId: `~usq:${string}`): Promise { // Run the query from the user-built blocks const queryName = Number(queryId.substring(5)); @@ -17,6 +166,12 @@ export default class SmartQuery { return new SmartQuery(savedQuery); } + static toDynoPL(savedQuery: SavedSmartQuery): DynamicPlaylist { + const query = new SmartQuery(savedQuery); + + return query.toDynoPL(); + } + constructor(savedQuery?: SavedSmartQuery) { if (savedQuery) { this.id = savedQuery.id; @@ -90,37 +245,55 @@ export default class SmartQuery { }, Promise.resolve(db.songs.toArray())); } - async save() { - if (this.id) { - await db.smartQueries.update(this.id, { - name: this.name, - queryParts: this.parts.map((p) => ({ - ...p.queryPart, - values: Object.entries(p.userInputs).reduce( - (obj, current) => { - obj[current[0]] = current[1].value; - return obj; - }, - {}, - ), - })), - }); + toDynoPL(playlist?: DynamicPlaylist): DynamicPlaylist { + if (!playlist) { + playlist = { + id: uuidv7(), + }; + } - return this.id; - } else { - return await db.smartQueries.put({ - name: this.name, - queryParts: this.parts.map((p) => ({ - ...p.queryPart, - values: Object.entries(p.userInputs).reduce( - (obj, current) => { - obj[current[0]] = current[1].value; - return obj; - }, - {}, - ), - })), - }); + playlist.all = []; + + for (const part of this.parts) { + const { fieldKey } = part.queryPart; + + if (part.queryPart.comparison === "contains") { + const inputKey = Object.keys(part.queryPart.inputRequired)[0]; + + playlist.all.push({ + contains: { + [fieldKey]: part.userInputs[inputKey].value, + }, + }); + } else if (part.queryPart.comparison === "is-between") { + playlist.all.push({ + inTheRange: { + [fieldKey]: [ + part.userInputs.startYear.value, + part.userInputs.endYear.value, + ], + }, + }); + } else if (part.queryPart.comparison === "is-equal") { + const operand = + fieldKey === "originCountry" ? "artistCountry" : fieldKey; + + playlist.all.push({ + is: { + [operand]: part.userInputs[fieldKey].value, + }, + }); + } else if (part.queryPart.comparison === "is-greater-than") { + const inputKey = Object.keys(part.queryPart.inputRequired)[0]; + + playlist.all.push({ + gt: { + [fieldKey]: part.userInputs[inputKey].value, + }, + }); + } } + + return playlist; } } diff --git a/src/lib/smart-query/QueryPart.ts b/src/lib/smart-query/QueryPart.ts index e5404730..59ceecd8 100644 --- a/src/lib/smart-query/QueryPart.ts +++ b/src/lib/smart-query/QueryPart.ts @@ -4,7 +4,7 @@ import type { Comparison, DataType } from "src/App"; * At the moment we just create query parts per field. * This can expand to more complex queries */ -type FieldKey = +export type FieldKey = | "title" | "artist" | "album" diff --git a/src/lib/views/CanvasLibraryView.svelte b/src/lib/views/CanvasLibraryView.svelte index 30978edb..77b377ca 100644 --- a/src/lib/views/CanvasLibraryView.svelte +++ b/src/lib/views/CanvasLibraryView.svelte @@ -1,9 +1,13 @@