// Round twenty-four: the last two playlist doors that need no new picker — // a paste of TRACK links (the desktop's own detector chooses the door) and // a folder of audio files off this phone. playlist_import_folder grows an // optional `path`, because on Android it had no dialog to open and always // answered nothing. A YouTube playlist link needs no door of its own: the // link door already dispatches it by host. const fs = require('fs'); const root = process.env.SS_ROOT || 'F:/Coding/SoulStream/'; const scratch = 'C:/Users/Kyle/AppData/Local/Temp/claude/F--Coding-SoulStream/5488514a-11e8-482a-b0a0-20cb511ee110/scratchpad/'; const ins = (name) => fs.readFileSync(scratch + name, 'utf8'); let p, s; const load = (file) => { p = file; s = fs.readFileSync(root + p, 'utf8'); }; const save = () => fs.writeFileSync(root + p, s); const once = (a) => { const i = s.indexOf(a); if (i < 0) throw new Error('missing in ' + p + ': ' + a.slice(0, 90)); if (s.indexOf(a, i + 1) >= 0) throw new Error('ambiguous in ' + p + ': ' + a.slice(0, 90)); return i; }; const rep = (a, b) => { once(a); s = s.replace(a, () => b); }; const NL = String.fromCharCode(10); // ── the Rust: the folder command takes a path the phone already picked ── load('src-tauri/src/playlists.rs'); rep(`pub async fn playlist_import_folder( app: tauri::AppHandle, lib: State<'_, LibraryState>, ) -> Result, String> { let (tx, rx) = tokio::sync::oneshot::channel::>(); #[cfg(desktop)] { use tauri_plugin_dialog::DialogExt; app.dialog().file().pick_folder(move |path| { let _ = tx.send(path.map(|p| p.to_string())); }); } #[cfg(mobile)] { let _ = tx.send(None); } let Some(root) = rx.await.unwrap_or(None) else { return Ok(None); };`, `pub async fn playlist_import_folder( app: tauri::AppHandle, lib: State<'_, LibraryState>, path: Option, ) -> Result, String> { // The phone has no dialog here, so it picks the folder itself (the // storage plugin's document-tree picker) and hands the resolved path // in; the desktop passes nothing and opens its own. let picked = match path { Some(p) if !p.trim().is_empty() => Some(p), _ => { let (tx, rx) = tokio::sync::oneshot::channel::>(); #[cfg(desktop)] { use tauri_plugin_dialog::DialogExt; app.dialog().file().pick_folder(move |path| { let _ = tx.send(path.map(|p| p.to_string())); }); } #[cfg(mobile)] { let _ = &app; let _ = tx.send(None); } rx.await.unwrap_or(None) } }; let Some(root) = picked else { return Ok(None); };`); save(); // ── the wrapper ── load('src/lib/api.js'); rep(`export const playlistImportFolder = () => invoke("playlist_import_folder");`, `// The desktop opens its own folder dialog (no path); the phone picks with // the storage plugin and hands the resolved path in. export const playlistImportFolder = (path = null) => invoke("playlist_import_folder", { path });`); save(); // ── the store: the links branch, the folder door ── load('src/mobile/importer.svelte.js'); rep(` rules, on the doors that need no tool and no file: a link (YouTube · Spotify · Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines, a timestamped setlist, or pasted track links), a YouTube DJ set's posted tracklist. The backend stages a draft`, ` rules, on the doors that need no file picker: a link (YouTube · Spotify · Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines, a timestamped setlist, or track links — a paste holding one is read as links), a YouTube DJ set's posted tracklist, and a folder of audio files on this phone. The backend stages a draft`); rep(` door: null, // "link" | "text" | "youtube"`, ` door: null, // "link" | "text" | "youtube" | "folder"`); rep(` committing: false, });`, ` committing: false, links: false, // the paste held track links: each read off its own page });`); rep(` spotify: "Spotify", "apple-music": "Apple Music", tidal: "TIDAL", deezer: "Deezer", };`, ins('ins-r24-importer-regex.txt').replace(/\n$/, '') + NL); rep(`/** [door, icon, title, hint] — the desktop's Import menu, minus the file * and folder doors (the files are on the desktop). */ export const DOORS = [ ["link", "link", "Playlist from a link…", "YouTube · Spotify · Apple Music · TIDAL · Deezer"], ["text", "pencil", "Paste a tracklist…", "plain lines, a timestamped setlist, or track links"], ["youtube", "brand-youtube", "YouTube DJ set…", "tracks from chapters, description or comments"], ];`, `/** [door, icon, title, hint] — the desktop's Import menu, minus the * playlist-file door (that one still wants a file picker). A YouTube * playlist link goes through "link": the backend dispatches it. */ export const DOORS = [ ["link", "link", "Playlist from a link…", "YouTube · Spotify · Apple Music · TIDAL · Deezer"], ["text", "pencil", "Paste a tracklist…", "plain lines, a timestamped setlist, or track links"], ["youtube", "brand-youtube", "YouTube DJ set…", "tracks from chapters, description or comments"], ["folder", "folder-open", "Folder of audio files…", "tag or natural order"], ];`); rep(` Object.assign(importer, { door, source, busy: true, error: "", progress: null, draft: null, name: "", includeUnresolved: true, checks: {}, committing: false });`, ` /* the paste reads itself: one track link in it and every line is looked up on its own public page — the desktop's rule, and the way past Spotify's 100-track public ceiling */ const links = door === "text" && TRACK_LINK.test(source || ""); Object.assign(importer, { door, source, busy: true, error: "", progress: null, draft: null, name: "", includeUnresolved: true, checks: {}, committing: false, links });`); rep(` const draft = door === "link" ? await api.playlistImportLink(source) : door === "youtube" ? await api.playlistImportYoutube(source) : await api.playlistImportText(source);`, ` const draft = door === "link" ? await api.playlistImportLink(source) : door === "youtube" ? await api.playlistImportYoutube(source) : links ? await api.playlistImportTrackLinks(source, null) : await api.playlistImportText(source);`); rep(`/** The rows commit will send:`, ins('ins-r24-importer-folder.txt') + `/** The rows commit will send:`); save(); // ── the sheet: the folder row picks before the review opens ── load('src/mobile/HalfSheet.svelte'); rep(` import { DOORS, start as startImport } from "./importer.svelte.js";`, ` import { DOORS, start as startImport, pickImportFolder, startFolder } from "./importer.svelte.js";`); rep(` onsubmit: (v) => { push("import", {}); void startImport(door, v); }, }); }`, ` onsubmit: (v) => { push("import", {}); void startImport(door, v); }, }); } /* the folder door picks FIRST: the review only opens for a folder that was chosen, and a tree with no path on this phone cannot be walked */ async function folderDoor() { closeHalf(); try { const path = await pickImportFolder(); if (!path) return; push("import", {}); void startFolder(path); } catch (e) { oops(e); } }`); rep(` {@render row(icon, t, m, () => importPrompt(door))}`, ` {@render row(icon, t, m, () => (door === "folder" ? folderDoor() : importPrompt(door)))}`); rep(`
A playlist file or a folder of audio files imports on the desktop, where the files are.
`, `
A playlist file — M3U, XSPF, PLS or CSV — imports on the desktop; the phone has no file picker for one yet.
`); save(); // ── the review: the wait line names the door that read the paste ── load('src/mobile/screens/Import.svelte'); rep(` const waitLine = $derived(importer.door === "link" ? "Reading the link…" : importer.door === "youtube" ? "Reading the set's tracklist…" : "Reading the tracklist…");`, ` const waitLine = $derived(importer.door === "link" ? "Reading the link…" : importer.door === "youtube" ? "Reading the set's tracklist…" : importer.door === "folder" ? "Reading the folder…" : importer.links ? "Reading the pasted links…" : "Reading the tracklist…");`); save(); // ── the harness handle: the walk starts the folder door itself ── load('src/mobile/Mobile.svelte'); rep(` import { importer, bindImporter } from "./importer.svelte.js";`, ` import { importer, bindImporter, startFolder } from "./importer.svelte.js";`); rep(`importer, wifi: { downloadsAllowed, cellular }, next,`, `importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next,`); save(); // ── the mocks ── load('src/lib/mockLibrary.js'); rep(` if (cmd === "playlist_import_commit") {`, ins('ins-r24-mock.txt') + ` if (cmd === "playlist_import_commit") {`); save(); // ── the walks ── load('tests/mobile/smoke.mjs'); rep(` impWait: !!document.querySelector('.imp-wait'),`, ` impWait: !!document.querySelector('.imp-wait'), impWaitLine: (document.querySelector('.imp-wait b') || {}).textContent || '', impWarn: [].map.call(document.querySelectorAll('.imp-warn div'), function (d) { return d.textContent.trim(); }).join('|'),`); { const a = ` /* a share from another app: the plugin hands the text over when the`; rep(a, ins('ins-r24-smoke-mock.txt') + a); } rep(` "import-sheet": (i) => i.half.includes("up") && /Playlist from a link/.test(i.halfText) && /Paste a tracklist/.test(i.halfText),`, ` "import-sheet": (i) => i.half.includes("up") && /Playlist from a link/.test(i.halfText) && /Paste a tracklist/.test(i.halfText) && /Folder of audio files/.test(i.halfText),`); { const a = ` "playlists-after-import": (i) => i.title.startsWith("Your library") && i.plcards === 5,`; rep(a, a + NL + ins('ins-r24-smoke-expect.txt').replace(/\n$/, '')); } { const a = ` /* the share sheet: a Spotify playlist link sent by "another app" (an`; rep(a, ins('ins-r24-smoke-phone.txt') + a); } { const a = ` "share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),`; rep(a, ins('ins-r24-smoke-phone-expect.txt') + a); } save(); // ── the doc ── load('ANDROID.md'); rep(`- **Importing** (\`importer.svelte.js\`, \`screens/Import.svelte\`): the shelf's Import opens the doors that need no file — a link (YouTube · Spotify · Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines, a timestamped setlist, or track links), a YouTube DJ set's posted tracklist. The backend stages the draft over plain HTTP (the same commands as the desktop, no yt-dlp involved), the review screen shows every row's verdict, the stream rows are verified against the catalog while it is up (the desktop's pass, verbatim), and Create swaps the review for the playlist it made (\`nav.replace\`).`, `- **Importing** (\`importer.svelte.js\`, \`screens/Import.svelte\`): the shelf's Import opens four doors — a link (YouTube · Spotify · Apple Music · TIDAL · Deezer; a YouTube playlist link rides this one too, the backend dispatches it by host), a pasted tracklist (plain lines, a timestamped setlist, or track links), a YouTube DJ set's posted tracklist, and a folder of audio files on this phone. A paste holding even one track link takes the link path (\`playlist_import_track_links\`, the desktop's own detector verbatim): each line is read off its own public page — the way past Spotify's 100-track public ceiling — and the wait screen counts them as they come. The folder door picks with the storage plugin's document-tree picker and hands the resolved path to \`playlist_import_folder\`, which grew a \`path\` argument for it: on the phone that command had no dialog to open and always answered nothing. The backend stages the draft over plain HTTP (the same commands as the desktop, no yt-dlp involved), the review screen shows every row's verdict, the stream rows are verified against the catalog while it is up (the desktop's pass, verbatim), and Create swaps the review for the playlist it made (\`nav.replace\`).`); rep(`Not on the phone yet: the file and folder import doors (the files are on the desktop), the listener door, drag reorder, the cover picker, export, Share on SoulStream.`, `Not on the phone yet: the playlist-file door and export — both want a file picker the storage plugin has still to grow — the listener door (it needs yt-dlp, a desktop tool), drag reorder, and Share on SoulStream.`); rep(`- The desktop-only doors and tools: the file and folder playlist imports, the listener door and the lyric alignment models (a desktop runtime), playlist export, drag reorder (Move up / down / to the top stand in), catalog track previews, the full discography page, the artist-artwork upscaler, updates and the account section (sign-in lives on Community).`, `- The desktop-only doors and tools: the playlist-file import and export (a file picker the phone's storage plugin has still to grow), the listener door and the lyric alignment models (a desktop runtime), drag reorder (Move up / down / to the top stand in), catalog track previews, the full discography page, the artist-artwork upscaler, updates and the account section (sign-in lives on Community).`); rep(`(a pasted tracklist, a link with its catalog verification, a share from another app) → the playlist repair (an unknown row relinked from the`, `(a pasted tracklist, a link with its catalog verification, pasted track links read a page each, a folder of audio files off the phone, a share from another app) → the playlist repair (an unknown row relinked from the`); save(); console.log('round twenty-four wired');