diff --git a/ANDROID.md b/ANDROID.md index 7c03de8..c73912e 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -349,14 +349,23 @@ describe / pin / duplicate / delete, `playlist_add_items`, desktop's commands already emit; the page re-reads its rows when the playlist's revision moves. - **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`). + 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`). - **The share sheet** (`crates/tauri-plugin-share`, `share.svelte.js`): Share → SoulStream from any app. The plugin's `ShareActivity` (an ACTION_SEND text/plain target, merged into the app's manifest from the @@ -367,9 +376,9 @@ describe / pin / duplicate / delete, `playlist_add_items`, anything else the pasted-tracklist door. The phone walk sends the intent itself (`adb shell am start -a android.intent.action.SEND …`). -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. ## Settings that reach the config @@ -569,8 +578,9 @@ a catalog record, its Save and the Transfers burst → Gaps → the playlist shelf and a page with every row kind (a local row, the stream row through the resolver, the filter, a removal and its undo, unpin, rename, a new playlist, Add to playlist from a record's sheet, delete) → the import doors -(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 library) → the artwork viewer → a queue row's sheet → a catalog song's, a catalog card's and the record page's sheets → the now-playing Save → a peer's sheet, a raw file's sheet, a user's shares browsed and a folder @@ -616,11 +626,12 @@ paused) without the walk. key, Who has it) — Freenet on the phone is a later experiment; the network side is in. Rooms and chat are built and mock-walked; a live room is Kyle's to join. -- 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). - Follows and the play history are per library folder, so the phone's own folder starts empty of both; they arrive with a shared library. - The artwork viewer: the cover at full size, pinch to zoom, one finger diff --git a/src-tauri/src/playlists.rs b/src-tauri/src/playlists.rs index 34baa44..f36a573 100644 --- a/src-tauri/src/playlists.rs +++ b/src-tauri/src/playlists.rs @@ -721,20 +721,31 @@ pub async fn playlist_import_probe( pub async fn playlist_import_folder( app: tauri::AppHandle, lib: State<'_, LibraryState>, + path: Option, ) -> 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 { + // 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); }; let root_path = PathBuf::from(&root); diff --git a/src/lib/api.js b/src/lib/api.js index c5f674d..21e10c4 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -278,7 +278,9 @@ export const playlistCoverSet = (id, path) => invoke("playlist_cover_set", { id, // Import: probe opens a file dialog and stages a reviewed draft; text stages // a pasted tracklist; commit writes the accepted draft in one transaction. export const playlistImportProbe = () => invoke("playlist_import_probe"); -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 }); export const playlistImportText = (text) => invoke("playlist_import_text", { text }); export const playlistImportYoutube = (url) => invoke("playlist_import_youtube", { url }); export const playlistImportYoutubePlaylist = (url) => diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js index 87e10e6..17a0db4 100644 --- a/src/lib/mockLibrary.js +++ b/src/lib/mockLibrary.js @@ -288,6 +288,45 @@ function mockImport(cmd, args) { }, 180); }); } + if (cmd === "playlist_import_track_links") { + const links = String(args?.text || "").split(/\r?\n/).map((l) => l.trim()) + .filter((l) => /^https?:\/\//.test(l) || /^spotify:track:/.test(l)); + if (!links.length) return Promise.reject(new Error("Nothing to import in that paste.")); + /* one row per link, in the paste's order — the lines a real lookup + would have read off each track's own public page. A page each is + slow, and the pace here is slow on purpose: the walk's still takes + long enough to miss a wait card that closes inside a second. */ + const named = ["Aphex Twin - Track 1", "Daft Punk - One More Time", "Bicep - Glue", "Four Tet - Track 2"]; + const entries = impParse(links.map((_, i) => named[i % named.length]).join("\n")); + return new Promise((r) => { + let done = 0; + const tick = setInterval(() => { + done++; + mockEmit("playlist:link-progress", { done, total: links.length, waiting: false }); + if (done >= links.length) { + clearInterval(tick); + setTimeout(() => r(impDraft(entries, args?.name || "Pasted track links", "links", args?.name ?? null, { + warnings: [entries.length + " track" + (entries.length === 1 ? "" : "s") + " read from " + links.length + " pasted link" + (links.length === 1 ? "" : "s") + "."], + })), 250); + } + }, 600); + }); + } + if (cmd === "playlist_import_folder") { + const root = String(args?.path || ""); + if (!root) return Promise.resolve(null); /* the desktop's dialog, dismissed */ + const name = root.split(/[\\/]/).filter(Boolean).pop() || "Folder"; + /* four files off a record the library already holds, as the backend's + walk hands them over: every row local, matched by path */ + const own = albums[0]; + const entries = tracksFor(own.id).slice(0, 4).map((t, i) => ({ + ordinal: i, title: t.title, artist: own.artist, album: own.title, + duration_secs: t.duration_secs, start_ms: null, raw: t.path, + resolution: "local", matched_by: "path", path: t.path, art_path: own.art_path, + track_id: null, art_url: null, isrc: null, + })); + return new Promise((r) => setTimeout(() => r(impDraft(entries, name, "folder", name)), 500)); + } if (cmd === "playlist_import_commit") { const name = String(args?.name || "").trim(); if (!name) return Promise.reject(new Error("The playlist needs a name")); diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte index 1e700ad..4502ffe 100644 --- a/src/mobile/HalfSheet.svelte +++ b/src/mobile/HalfSheet.svelte @@ -13,7 +13,7 @@ import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js"; import { catalog, yearOf } from "./catalog.svelte.js"; import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist, moveItem, coverFromPhoto, coverClear } from "./playlists.svelte.js"; - import { DOORS, start as startImport } from "./importer.svelte.js"; + import { DOORS, start as startImport, pickImportFolder, startFolder } from "./importer.svelte.js"; import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js"; import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js"; import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics, setAutoSync, openTapSync, tapProgress, reduceVocals, backToOriginal } from "./lyrics.svelte.js"; @@ -146,6 +146,17 @@ 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); } + } /* find a local match — the desktop's repair panel, on a sheet: the library searched by the row's name, a pick relinks the row (an unknown placeholder adopts the track's identity; the original line @@ -494,10 +505,10 @@
Import a playlist
{#each DOORS as [door, icon, t, m] (door)} - {@render row(icon, t, m, () => importPrompt(door))} + {@render row(icon, t, m, () => (door === "folder" ? folderDoor() : importPrompt(door)))} {/each}
-
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.
{:else if kind === "plmatch" && shown?.arg?.item} {@const p = shown.arg.playlist} {@const item = shown.arg.item} diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index 9a4c3eb..fbab6d8 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -47,7 +47,7 @@ import { resolver, streamSong, streamCatalogAlbum } from "./resolve.svelte.js"; import { notice, clearNote } from "./notice.svelte.js"; import * as pls from "./playlists.svelte.js"; - import { importer, bindImporter } from "./importer.svelte.js"; + import { importer, bindImporter, startFolder } from "./importer.svelte.js"; import { bindShare } from "./share.svelte.js"; import { downloadsAllowed, cellular } from "./network.svelte.js"; import { contacts, loadContacts, display } from "./contacts.svelte.js"; @@ -197,7 +197,7 @@ setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000); /* the probe handle — the desktop has __soulstream for the same reason: tests/mobile/smoke.mjs drives the real app through it */ - window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts }; + window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts }; return () => { if (unlisten) unlisten(); }; }); diff --git a/src/mobile/importer.svelte.js b/src/mobile/importer.svelte.js index d0b5921..6cb29bc 100644 --- a/src/mobile/importer.svelte.js +++ b/src/mobile/importer.svelte.js @@ -1,8 +1,9 @@ /* Importing a playlist on the phone — the desktop's PlaylistImportModal's - 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 (parsing and resolution against + 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 (parsing and resolution against the library); nothing is written until commit. While the review is up the stream rows are verified against the catalog — the desktop's verifyStreams, verbatim: the ISRC first, then the hardened name walk — @@ -13,7 +14,7 @@ import { findCatalogTrack } from "../lib/catalogMatch.js"; import { refreshPlaylists } from "./lib.svelte.js"; export const importer = $state({ - door: null, // "link" | "text" | "youtube" + door: null, // "link" | "text" | "youtube" | "folder" source: "", // what was pasted busy: false, error: "", @@ -23,29 +24,44 @@ export const importer = $state({ includeUnresolved: true, checks: {}, // ordinal → "checking" | { album, cover, anchored } | "none" committing: false, + links: false, // the paste held track links: each read off its own page }); export const FORMAT_LABEL = { m3u: "M3U", pls: "PLS", xspf: "XSPF", csv: "CSV", soulstream: "SoulStream backup", text: "Pasted text", folder: "Folder", youtube: "YouTube", "youtube-playlist": "YouTube playlist", spotify: "Spotify", "apple-music": "Apple Music", tidal: "TIDAL", deezer: "Deezer", + links: "Pasted links", }; -/** [door, icon, title, hint] — the desktop's Import menu, minus the file - * and folder doors (the files are on the desktop). */ + +/* Mirrors the backend's detector closely enough to CHOOSE a door; the + backend decides for real. A paste holding even one track link takes the + link path, so a list copied out of Spotify never parses as track names. */ +const TRACK_LINK = /(?:spotify:track:[A-Za-z0-9]{22})|(?:\/\/(?:open\.spotify\.com\/(?:[\w-]+\/)*track\/|(?:www\.)?deezer\.com\/(?:[\w-]+\/)*track\/|(?:[\w-]+\.)?tidal\.com\/(?:[\w-]+\/)*track\/|music\.apple\.com\/[^\s]*(?:\/song\/|[?&]i=)))/i; + +/** [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"], ]; let gen = 0; /** Open a door: the backend reads and resolves, the review shows the draft. */ export async function start(door, source) { const g = ++gen; - 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 }); try { const draft = door === "link" ? await api.playlistImportLink(source) : door === "youtube" ? await api.playlistImportYoutube(source) + : links ? await api.playlistImportTrackLinks(source, null) : await api.playlistImportText(source); if (g !== gen) return; importer.draft = draft; @@ -56,6 +72,36 @@ export async function start(door, source) { if (g === gen) { importer.error = String(e); importer.busy = false; } } } +/** The folder door picks FIRST — the phone's own document-tree picker — so + * the review only opens for a folder that was actually chosen. The tree has + * to be on this phone's storage: the backend walks a path, and a cloud + * provider's tree has none. Returns null when the picker was dismissed. */ +export async function pickImportFolder() { + let picked; + try { picked = await api.pickLibraryFolder(); } + catch (e) { if (/cancelled/.test(String(e))) return null; throw e; } + if (!picked?.path) throw new Error("That folder has no path the phone can read — pick one on this phone's storage."); + return picked.path; +} +/** Stage a folder of audio files. The backend walks the folder and its disc + * subfolders, orders by the files' own track numbers when every one carries + * a distinct one and by natural filename order otherwise, and matches each + * file against the library by path. Every row comes back local, so there is + * nothing for the catalog pass to verify. */ +export async function startFolder(path) { + const g = ++gen; + Object.assign(importer, { door: "folder", source: path, busy: true, error: "", progress: null, draft: null, name: "", includeUnresolved: true, checks: {}, committing: false, links: false }); + try { + const draft = await api.playlistImportFolder(path); + if (g !== gen) return; + importer.busy = false; + if (!draft) { importer.error = "Nothing came back from that folder."; return; } + importer.draft = draft; + importer.name = draft.name ?? ""; + } catch (e) { + if (g === gen) { importer.error = String(e); importer.busy = false; } + } +} /** The rows commit will send: streams are first-class; the switch only * gates the TRUE unknowns. */ export function kept() { diff --git a/src/mobile/screens/Import.svelte b/src/mobile/screens/Import.svelte index 9009f4a..e975b58 100644 --- a/src/mobile/screens/Import.svelte +++ b/src/mobile/screens/Import.svelte @@ -42,7 +42,11 @@ if (r) replace("playlist", { id: r.id }); } function leave() { cancel(); back(); } - 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…"); {#if importer.busy} diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index 88051f6..0b8df70 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -125,6 +125,8 @@ const PROBE = `(function () { impCounts: (document.querySelector('.imp-counts') || {}).textContent || '', impFmt: (document.querySelector('.imp-head .stats .s') || {}).textContent || '', 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('|'), impBtn: (document.querySelector('.imp-foot .btn-fill') || {}).textContent || '', keepOn: !!document.querySelector('.imp-form .sw.on'), bulkline: (document.querySelector('.bulkline') || {}).textContent || '', @@ -454,6 +456,19 @@ const MOCK_STEPS = [ { name: "import-link-review", wait: 3400 }, { do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-link-created", wait: 1500 }, { do: `history.back()`, name: "playlists-after-import", wait: 900 }, + /* pasted TRACK links: the paste door reads each one off its own page — + the wait line names the door, the warning counts the links */ + { do: `__click('.newpl', 'Import')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/track/aaaaaaaaaaaaaaaaaaaaaa\\nhttps://open.spotify.com/track/bbbbbbbbbbbbbbbbbbbbbb\\nhttps://open.spotify.com/track/cccccccccccccccccccccc'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-links-wait", wait: 500 }, + { name: "import-links-review", wait: 2600 }, + { do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 }, + /* the folder door: the phone's own folder picker, then the backend's walk */ + { do: `__click('.newpl', 'Import')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Folder of audio files')`, name: "import-folder-review", wait: 2200 }, + { do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-folder-created", wait: 1500 }, + { do: `history.back()`, wait: 900 }, /* a share from another app: the plugin hands the text over when the app comes to the front, and it lands in the import */ { do: `__ssMock.share = 'Check this out https://music.apple.com/us/playlist/late/pl.u-mock'; document.dispatchEvent(new Event('visibilitychange'))`, name: "share-import", wait: 3600 }, @@ -648,7 +663,7 @@ const MOCK_EXPECT = { "playlist-filled": (i) => /^New Playlist/.test(i.title) && i.plrows >= 5, "playlist-delete-confirm": (i) => i.half.includes("up") && /Delete/.test(i.halfText), "playlist-deleted": (i) => i.title.startsWith("Your library") && i.plcards === 3 && !/New Playlist/.test(i.plnames), - "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), "import-paste-prompt": (i) => i.half.includes("up") && /Paste a tracklist/.test(i.halfText) && i.promptVal === "", "import-review": (i) => i.title.startsWith("Import") && i.imp === 3 && /1 in library/.test(i.impCounts) && /tags/.test(i.impTags) && /unresolved/.test(i.impTags) && /catalog/.test(i.impTags) && i.keepOn && /Create playlist \(3 tracks\)/.test(i.impBtn), "import-keep-off": (i) => !i.keepOn && /Create playlist \(2 tracks\)/.test(i.impBtn), @@ -658,6 +673,10 @@ const MOCK_EXPECT = { "import-link-review": (i) => i.imp === 5 && /Spotify/.test(i.impFmt) && /catalog/.test(i.impTags) && !/matching/.test(i.impTags) && /Create playlist \(5 tracks\)/.test(i.impBtn), "import-link-created": (i) => i.title.startsWith("Late Night Mix") && i.plrows === 5, "playlists-after-import": (i) => i.title.startsWith("Your library") && i.plcards === 5, + "import-links-wait": (i) => i.impWait && /pasted links/.test(i.impWaitLine), + "import-links-review": (i) => i.imp === 3 && /Pasted links/.test(i.impFmt) && /read from 3 pasted links/.test(i.impWarn) && /Create playlist \(3 tracks\)/.test(i.impBtn), + "import-folder-review": (i) => i.title.startsWith("Import") && /Folder/.test(i.impFmt) && i.imp === 4 && i.impTags === "file|file|file|file" && /4 in library/.test(i.impCounts) && /Create playlist \(4 tracks\)/.test(i.impBtn), + "import-folder-created": (i) => i.title.startsWith("Music") && i.plrows === 4, "share-import": (i) => i.title.startsWith("Import") && /Apple Music/.test(i.impFmt) && i.imp === 5, "share-cancel": (i) => i.title.startsWith("Your library"), "cat-album-row": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live), @@ -743,6 +762,23 @@ const PHONE_STEPS = [ { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 }, { do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 }, { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "import-deleted", wait: 1500 }, + /* the pasted-links door on the real core: one track link, and the wait + line says which door read the paste; the review is left as it came */ + { do: `__click('.newpl', 'Import')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/track/0DiWJLRWCDgbxTYvvVjHOP'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-links-wait", wait: 700 }, + { name: "import-links-review", wait: 9000 }, + { do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 }, + /* the folder door on the real core, one record's own folder — the + argument the command grew, walked for real; nothing is created */ + { do: `(async function () { var a = __ss.lib.albums[0]; var ts = await __ss.api.albumTracks(a.id); var f = ts[0].path; var cut = Math.max(f.lastIndexOf('/'), f.lastIndexOf(String.fromCharCode(92))); __ss.push('import', {}); __ss.imp.startFolder(f.slice(0, cut)); return f.slice(0, cut); })()`, name: "import-folder-real", wait: 7000 }, + { do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 }, + /* the picker itself is a system sheet the walk cannot tap: open it, + dismiss it with BACK, and check the cancel path leaves nothing */ + { do: `__click('.newpl', 'Import')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Folder of audio files')`, name: "import-folder-picker", wait: 2600 }, + { adb: ["shell", "input", "keyevent", "4"], name: "import-folder-cancelled", wait: 2000 }, /* the share sheet: a Spotify playlist link sent by "another app" (an intent on the plugin's activity) lands in the import — the review is left without creating anything */ @@ -826,6 +862,14 @@ const PHONE_EXPECT = { "import-match-results": (i) => /Aerodynamic/.test(i.halfText), "import-matched": (i) => !i.half.includes("up") && !/unresolved/.test(i.pltags) && /Linked to/.test(i.notice) && !i.toast, "import-deleted": (i) => i.title.startsWith("Your library") && !/Imported playlist/.test(i.plnames) && !i.toast, + /* the real core can answer one track link faster than the probe lands, + so the wait card is allowed to be gone already — but then the review + it turned into has to be there. Either way the door did something. */ + "import-links-wait": (i) => i.title.startsWith("Import") && (i.impWait ? /pasted links/.test(i.impWaitLine) : i.imp > 0 || i.impErr), + "import-links-review": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr), + "import-folder-real": (i) => i.title.startsWith("Import") && /Folder/.test(i.impFmt) && i.imp > 0, + "import-folder-picker": (i) => !i.half.includes("up") && !i.toast, + "import-folder-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast, "share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr), "share-cancel": (i) => i.title.startsWith("Your library"), artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1,