diff --git a/src/library/convert.ts b/src/library/convert.ts new file mode 100644 index 0000000..1b55afd --- /dev/null +++ b/src/library/convert.ts @@ -0,0 +1,100 @@ +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { execa } from "execa"; +import { resolvedFfmpegPath } from "../bin/ffmpeg-fetch"; +import type { Track } from "./types"; + +/** + * Mirror a track's library-relative folder structure under `/mp3/`, + * swapping the extension to `.mp3`. A track at + * `/YouTube/@nasa/My Playlist/song.opus` + * converts to + * `/mp3/YouTube/@nasa/My Playlist/song.mp3`. + * + * This keeps the per-source / per-owner / per-playlist grouping intact, so + * converted files sit beside their originals' layout instead of being + * flattened (which collided same-named files from different playlists and + * threw away the source/owner hierarchy). Tracks whose `filePath` isn't + * actually under `libraryDir` (e.g. adopted local files elsewhere) fall back + * to `/mp3/.mp3` so we never write outside the library. + */ +export function mp3MirroredPath(libraryDir: string, track: Track): string { + const rel = path.relative(libraryDir, track.filePath); + // `..` segments mean the file lives outside the library: don't mirror a + // path that would escape `/mp3/`. Drop to a flat basename. + const safe = rel && !rel.startsWith("..") && !path.isAbsolute(rel) + ? rel + : path.basename(track.filePath); + const withoutExt = safe.replace(/\.[^./\\]+$/, ""); + return path.join(libraryDir, "mp3", `${withoutExt}.mp3`); +} + +export interface ConvertProgress { + /** 1-based index of the track being processed (includes skips). */ + done: number; + /** Total non-mp3 tracks in this batch. */ + total: number; + track: Track; +} + +export interface ConvertResult { + /** Newly converted + already-present skips (matches the old UI counter). */ + converted: number; + /** Already present on disk, left as-is. */ + skipped: number; + /** ffmpeg failed for these. */ + failed: number; +} + +/** + * Convert a batch of tracks to MP3 (192k libmp3lame), mirroring each track's + * library folder structure under `/mp3/`. Already-mp3 sources are + * skipped up front; existing mp3 outputs are left untouched (counted as + * skipped). The caller drives the progress UI through `onProgress`. + */ +export async function convertTracksToMp3( + libraryDir: string, + tracks: Track[], + onProgress?: (p: ConvertProgress) => void, +): Promise { + const toConvert = tracks.filter((t) => !t.filePath.endsWith(".mp3")); + let converted = 0; + let skipped = 0; + let failed = 0; + + for (const track of toConvert) { + const mp3Path = mp3MirroredPath(libraryDir, track); + + // Skip if the mp3 already exists. + try { + await fs.access(mp3Path); + skipped++; + converted++; + onProgress?.({ done: converted, total: toConvert.length, track }); + continue; + } catch { + // not present, proceed + } + + onProgress?.({ done: converted + 1, total: toConvert.length, track }); + + try { + await fs.mkdir(path.dirname(mp3Path), { recursive: true }); + await execa(resolvedFfmpegPath(), [ + "-i", + track.filePath, + "-codec:a", + "libmp3lame", + "-b:a", + "192k", + mp3Path, + ]); + converted++; + } catch (e) { + failed++; + console.error(`Failed to convert ${track.title}:`, e); + } + } + + return { converted, skipped, failed }; +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 85347f9..ddc354a 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -44,6 +44,7 @@ import { Library as LibrarySection } from "./sections/Library"; import { Playlists } from "./sections/Playlists"; import { History } from "./sections/History"; import { Download } from "./sections/Download"; +import { Mp3 } from "./sections/Mp3"; import { Settings } from "./sections/Settings"; import { Welcome } from "./views/Welcome"; import { useMouseWheel } from "./hooks/useMouseWheel"; @@ -66,6 +67,8 @@ function Content({ section }: { section: Section }) { return ; case "download": return ; + case "mp3": + return ; case "settings": return ; } diff --git a/src/ui/components/Sidebar.tsx b/src/ui/components/Sidebar.tsx index 20d8f31..40e5046 100644 --- a/src/ui/components/Sidebar.tsx +++ b/src/ui/components/Sidebar.tsx @@ -13,6 +13,7 @@ const NAV: NavItem[] = [ { key: "playlists", label: "Playlists" }, { key: "history", label: "History" }, { key: "download", label: "Download" }, + { key: "mp3", label: "MP3" }, { key: "settings", label: "Settings" }, ]; diff --git a/src/ui/keymap.ts b/src/ui/keymap.ts index 68b7f0e..7fdea4f 100644 --- a/src/ui/keymap.ts +++ b/src/ui/keymap.ts @@ -21,12 +21,13 @@ const SECTION_ORDER: Section[] = [ "playlists", "history", "download", + "mp3", "settings", ]; -/** Map "1".."5" to its section (the sidebar's display order); null otherwise. */ +/** Map "1".."6" to its section (the sidebar's display order); null otherwise. */ export function sectionForDigit(input: string): Section | null { - if (!/^[1-5]$/.test(input)) return null; + if (!/^[1-6]$/.test(input)) return null; return SECTION_ORDER[Number(input) - 1] ?? null; } @@ -38,7 +39,7 @@ export const HELP_GROUPS: HelpGroup[] = [ { keys: "↑ ↓", label: "Move" }, { keys: "PgUp PgDn", label: "Jump a page" }, { keys: "↵", label: "Open / play" }, - { keys: "1-5", label: "Jump section" }, + { keys: "1-6", label: "Jump section" }, { keys: "/", label: "Search" }, { keys: "d", label: "Delete" }, { keys: "t", label: "Rename" }, @@ -151,5 +152,13 @@ export function footerHints( PANE, ALWAYS, ]; + case "mp3": + return [ + { keys: "↵", label: "Play" }, + { keys: "d", label: "Delete" }, + { keys: "o", label: "Open folder" }, + PANE, + ALWAYS, + ]; } } diff --git a/src/ui/sections/Library.tsx b/src/ui/sections/Library.tsx index 912e939..e53425b 100644 --- a/src/ui/sections/Library.tsx +++ b/src/ui/sections/Library.tsx @@ -16,6 +16,7 @@ import { cleanText, formatDuration } from "../../util/format"; import { deleteTracks } from "../../library/delete"; import { displaySource } from "../../library/drift"; import { renameTrack } from "../../library/rename"; +import { convertTracksToMp3 } from "../../library/convert"; import { SOURCE_LABELS, type SourceId, type Track } from "../../library/types"; const SOURCE_ORDER: SourceId[] = [ @@ -71,6 +72,10 @@ export function Library() { // Pending track rename. const [renamingTrackId, setRenamingTrackId] = useState(null); const [newTrackTitle, setNewTrackTitle] = useState(""); + // Pending MP3 conversion. + const [convertConfirm, setConvertConfirm] = useState<{ count: number } | null>(null); + const [converting, setConverting] = useState(false); + const [convertProgress, setConvertProgress] = useState(null); const songs = useMemo( // library.all() is already newest-first (addedAt desc); recompute on new @@ -178,18 +183,19 @@ export function Library() { // Take over the keyboard only while typing in the search box; a pending // delete confirm owns esc so the global one doesn't bounce to the sidebar. const renaming = focused && renamingTrackId !== null; + const convertingConfirm = focused && convertConfirm !== null; useEffect(() => { setCaptureMode( focused && editing ? "text" - : focused && confirm + : focused && (confirm !== null || convertingConfirm) ? "esc" : renaming ? "text" : "none", ); return () => setCaptureMode("none"); - }, [focused, editing, confirm, renaming, setCaptureMode]); + }, [focused, editing, confirm, convertingConfirm, renaming, setCaptureMode]); // Consume the global "/" intent: arrive with the search box already open. useEffect(() => { @@ -202,19 +208,27 @@ export function Library() { // Browsing keys: // "/" opens search // "[" / "]" step the source tabs + // "c" converts visible tracks to MP3 useInput( (input) => { if (input === "/") { setEditing(true); return; } + if (input === "c" && !converting) { + const tracksToConvert = visible.filter((t) => !t.filePath.endsWith(".mp3")); + if (tracksToConvert.length > 0) { + setConvertConfirm({ count: tracksToConvert.length }); + } + return; + } if (input === "[" || input === "]") { const dir = input === "]" ? 1 : -1; const i = tabs.indexOf(filter); setFilter(tabs[(i + dir + tabs.length) % tabs.length]!); } }, - { isActive: focused && !editing && !confirm && !renaming }, + { isActive: focused && !editing && !confirm && !renaming && !convertConfirm }, ); // esc closes the search box (back to browsing), without leaving the section. @@ -248,6 +262,47 @@ export function Library() { setNewTrackTitle(""); }; + const convertVisibleToMp3 = async () => { + setConverting(true); + setConvertProgress("Starting conversion…"); + + try { + const tracksToConvert = visible.filter((t) => !t.filePath.endsWith(".mp3")); + const { converted } = await convertTracksToMp3( + config.libraryDir, + tracksToConvert, + (p) => + setConvertProgress( + `Converting ${p.done}/${p.total}: ${p.track.title}`, + ), + ); + + setConvertProgress( + `Conversion complete: ${converted}/${tracksToConvert.length} songs converted` + ); + setTimeout(() => setConvertProgress(null), 3000); + } catch (e) { + setConvertProgress("Conversion failed"); + console.error("Conversion error:", e); + setTimeout(() => setConvertProgress(null), 3000); + } finally { + setConverting(false); + } + }; + + // esc cancels convert confirm, y confirms it. + useInput( + (input, key) => { + if (key.escape) { + setConvertConfirm(null); + } else if (input === "y" && convertConfirm) { + setConvertConfirm(null); + void convertVisibleToMp3(); + } + }, + { isActive: convertingConfirm }, + ); + // y commits the pending delete, esc keeps the song. Playback stops first // when it's the one playing: the player holds the file handle open and // Windows refuses to unlink it. @@ -321,7 +376,14 @@ export function Library() { // delete, or showing an active query; when compact and idle, drop it so the // list gets the row back. const showSearchRow = - !compact || editing || confirm !== null || searching || renaming; + !compact || + editing || + confirm !== null || + convertConfirm !== null || + converting || + convertProgress !== null || + searching || + renaming; // Rows above the list beyond the standard header (which listRows already // accounts for): tabs (1) + the search row when shown (2 normally, 1 compact // since its margin goes too). @@ -335,7 +397,15 @@ export function Library() { the list's height budget never moves. Hidden when compact + idle. */} {showSearchRow ? ( - {confirm ? ( + {convertProgress ? ( + + {convertProgress} + + ) : convertConfirm ? ( + + {`Convert ${convertConfirm.count} song${convertConfirm.count === 1 ? "" : "s"} to MP3? y Convert ${ICON.dot} esc Cancel`} + + ) : confirm ? ( {`Delete '${cleanText(confirm.title)}'? y Delete ${ICON.dot} esc Keep`} @@ -373,7 +443,7 @@ export function Library() { groups={groups} action={action} playingId={playingId} - focused={focused && !editing && !confirm && !renaming} + focused={focused && !editing && !confirm && !renaming && !convertConfirm && !converting} reserveRows={reserveRows} deleteTargetsPlaying onDelete={handleDelete} diff --git a/src/ui/sections/Mp3.tsx b/src/ui/sections/Mp3.tsx new file mode 100644 index 0000000..f82725c --- /dev/null +++ b/src/ui/sections/Mp3.tsx @@ -0,0 +1,210 @@ +import { useEffect, useMemo, useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { useStore, useLibrary, usePlayback } from "../store"; +import { Header } from "../components/Header"; +import { SongList, type SongGroup } from "../components/SongList"; +import { COLOR, ICON } from "../theme"; +import { formatDuration } from "../../util/format"; +import { deleteTracks } from "../../library/delete"; +import { type Track } from "../../library/types"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { execa } from "execa"; + +interface Mp3File { + path: string; + name: string; + size: number; +} + +export function Mp3() { + const { + library, + config, + playTrack, + region, + setSection, + setCaptureMode, + queue, + playback, + compact, + } = useStore(); + useLibrary(library); + const playbackState = usePlayback(playback); + const playingId = playbackState.track?.id; + const focused = region === "content"; + + const [mp3Files, setMp3Files] = useState([]); + const [currentDir, setCurrentDir] = useState(""); + const [confirm, setConfirm] = useState<{ path: string; name: string } | null>(null); + + useEffect(() => { + loadMp3Files(); + // Reload when the library dir moves, and whenever this section gains + // focus: a conversion run from Library/Playlists writes new files while + // the MP3 section is hidden, and the only signal we get is the focus flip. + }, [config.libraryDir, focused]); + + const loadMp3Files = async () => { + try { + const mp3Dir = path.join(config.libraryDir, "mp3"); + + // Check if mp3 directory exists + try { + await fs.access(mp3Dir); + } catch { + setMp3Files([]); + setCurrentDir(mp3Dir); + return; + } + + // Recursively walk mp3/ so the mirrored layout + // mp3////.mp3 + // shows up alongside the older flat / one-level forms. + const files: Mp3File[] = []; + const stack: string[] = [mp3Dir]; + while (stack.length > 0) { + const dir = stack.pop()!; + let entries: import("node:fs").Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile() && entry.name.endsWith(".mp3")) { + try { + const stats = await fs.stat(fullPath); + files.push({ path: fullPath, name: entry.name, size: stats.size }); + } catch { + // vanished between readdir and stat: skip + } + } + } + } + + // Newest first is more useful than readdir's arbitrary order. + files.sort((a, b) => b.path.localeCompare(a.path)); + + setMp3Files(files); + setCurrentDir(mp3Dir); + } catch (e) { + console.error("Failed to load MP3 files:", e); + setMp3Files([]); + } + }; + + useEffect(() => { + setCaptureMode(confirm ? "esc" : "none"); + return () => setCaptureMode("none"); + }, [confirm, setCaptureMode]); + + useInput( + (input, key) => { + if (key.escape) setConfirm(null); + else if (input === "y" && confirm) { + setConfirm(null); + void (async () => { + try { + await fs.unlink(confirm.path); + await loadMp3Files(); + } catch (e) { + console.error("Failed to delete MP3:", e); + } + })(); + } else if (input === "o" && !confirm) { + handleOpenFolder(); + } + }, + { isActive: focused }, + ); + + const handleOpenFolder = () => { + const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open"; + execa(command, [currentDir]).catch((e) => console.error("Failed to open folder:", e)); + }; + + const handleDelete = (path: string, name: string) => { + setConfirm({ path, name }); + }; + + const toItem = (f: Mp3File) => ({ + value: f.path, + title: f.name.replace(/\.mp3$/, ""), + meta: `${(f.size / 1024 / 1024).toFixed(1)} MB`, + }); + + // Group by the immediate parent folder (the playlist dir in the mirrored + // layout). Files sitting directly under mp3/ with no folder fall into a + // headerless lead group so the flat-convert case still renders cleanly. + const groups: SongGroup[] = useMemo(() => { + const byGroup = new Map(); + const flat: Mp3File[] = []; + for (const f of mp3Files) { + const parent = path.basename(path.dirname(f.path)); + if (parent === "mp3") { + flat.push(f); + } else { + const list = byGroup.get(parent); + if (list) list.push(f); + else byGroup.set(parent, [f]); + } + } + const sortedGroups = [...byGroup.entries()].sort(([a], [b]) => + a.localeCompare(b), + ); + const out: SongGroup[] = []; + if (flat.length > 0) out.push({ items: flat.map(toItem) }); + for (const [title, files] of sortedGroups) { + out.push({ title, items: files.map(toItem) }); + } + return out; + }, [mp3Files]); + + const subtitle = `${mp3Files.length} file${mp3Files.length === 1 ? "" : "s"}`; + + if (mp3Files.length === 0) { + return ( + +
+ No MP3 files found. + Convert tracks from Library or Playlists to create MP3s. + + ); + } + + return ( + +
+ + {confirm ? ( + + {`Delete '${confirm.name}'? y Delete ${ICON.dot} esc Keep`} + + ) : ( + + {currentDir} {ICON.dot} Click to open folder + + )} + + { + const file = mp3Files.find((f) => f.path === value); + if (file) handleDelete(file.path, file.name); + }} + onSelect={(value) => { + // For now, just open the folder on select + // TODO: Add actual playback support for MP3 files + handleOpenFolder(); + }} + /> + + ); +} diff --git a/src/ui/sections/Playlists.tsx b/src/ui/sections/Playlists.tsx index 8bdb00a..dccfce5 100644 --- a/src/ui/sections/Playlists.tsx +++ b/src/ui/sections/Playlists.tsx @@ -17,6 +17,7 @@ import { fuzzyFilter } from "../../util/fuzzy"; import { deleteTracks } from "../../library/delete"; import { displaySource, setFolderKey } from "../../library/drift"; import { renamePlaylist, renameTrack } from "../../library/rename"; +import { convertTracksToMp3 } from "../../library/convert"; import { SOURCE_LABELS, type SourceId, type Track } from "../../library/types"; import { shuffledOrder } from "../../player/order"; @@ -41,7 +42,8 @@ type View = { kind: "sets" } | { kind: "songs"; setKey: string }; /** Pending delete: one song, or a whole set with everything in it. */ type Confirm = | { kind: "song"; id: string; label: string } - | { kind: "set"; key: string; label: string; count: number }; + | { kind: "set"; key: string; label: string; count: number } + | { kind: "convert"; setKey: string; label: string; count: number }; // Module scope: pure row helpers, so the memoized groups and callbacks below // never need them as deps. @@ -85,6 +87,8 @@ export function Playlists() { const [newPlaylistName, setNewPlaylistName] = useState(""); const [renamingTrackId, setRenamingTrackId] = useState(null); const [newTrackTitle, setNewTrackTitle] = useState(""); + const [converting, setConverting] = useState(false); + const [convertProgress, setConvertProgress] = useState(null); // Search inside the open set, mirroring the Library search box. const [songQ, setSongQ] = useState(""); const [songFiltering, setSongFiltering] = useState(false); @@ -265,10 +269,41 @@ export function Playlists() { setFiltering(true); return; } + if (input === "c" && !filtering && !confirm && !renamingSet && !converting) { + if (inSets) { + const selectedSet = visibleSets.length > 0 ? visibleSets[0] : null; + if (selectedSet) { + const tracksToConvert = selectedSet.tracks.filter( + (t) => !t.filePath.endsWith(".mp3") + ); + if (tracksToConvert.length > 0) { + setConfirm({ + kind: "convert", + setKey: selectedSet.key, + label: selectedSet.name, + count: tracksToConvert.length, + }); + } + } + } else if (inSongs && active) { + const tracksToConvert = active.tracks.filter( + (t) => !t.filePath.endsWith(".mp3") + ); + if (tracksToConvert.length > 0) { + setConfirm({ + kind: "convert", + setKey: active.key, + label: active.name, + count: tracksToConvert.length, + }); + } + } + return; + } if (input === "[") stepSourceTab(-1); else if (input === "]") stepSourceTab(1); }, - { isActive: focused && !confirm && !filtering && !renamingSet && inSets }, + { isActive: focused && !confirm && !filtering && !renamingSet && !renamingTrack }, ); useInput( @@ -305,6 +340,36 @@ export function Playlists() { setNewPlaylistName(""); }; + const convertPlaylistToMp3 = async (setInfo: SetInfo) => { + setConverting(true); + setConvertProgress("Starting conversion…"); + + try { + const tracksToConvert = setInfo.tracks.filter( + (t) => !t.filePath.endsWith(".mp3") + ); + const { converted } = await convertTracksToMp3( + config.libraryDir, + tracksToConvert, + (p) => + setConvertProgress( + `Converting ${p.done}/${p.total}: ${p.track.title}` + ), + ); + + setConvertProgress( + `Conversion complete: ${converted}/${tracksToConvert.length} songs converted` + ); + setTimeout(() => setConvertProgress(null), 3000); + } catch (e) { + setConvertProgress("Conversion failed"); + console.error("Conversion error:", e); + setTimeout(() => setConvertProgress(null), 3000); + } finally { + setConverting(false); + } + }; + // y commits the pending delete (one song, or a whole set and its folder), // esc keeps it. Playback stops first when the playing song is a victim: // the player holds the file handle open and Windows refuses the unlink. @@ -312,6 +377,14 @@ export function Playlists() { (input, key) => { if (key.escape) setConfirm(null); else if (input === "y" && confirm) { + if (confirm.kind === "convert") { + setConfirm(null); + const targetSet = sets.find((s) => s.key === confirm.setKey); + if (targetSet) { + void convertPlaylistToMp3(targetSet); + } + return; + } const victims = confirm.kind === "set" ? (sets.find((s) => s.key === confirm.key)?.tracks ?? []) @@ -329,6 +402,11 @@ export function Playlists() { function confirmText(): string { if (!confirm) return ""; + if (confirm.kind === "convert") { + return `Convert '${cleanText(confirm.label)}' to MP3 ${ICON.dot} ${confirm.count} song${ + confirm.count === 1 ? "" : "s" + }? y Convert ${ICON.dot} esc Cancel`; + } return confirm.kind === "set" ? `Delete '${cleanText(confirm.label)}' ${ICON.dot} ${confirm.count} song${ confirm.count === 1 ? "" : "s" @@ -490,7 +568,13 @@ export function Playlists() { return (
- {confirm ? ( + {convertProgress ? ( + + + {convertProgress} + + + ) : confirm ? ( {confirmText()} @@ -555,7 +639,13 @@ export function Playlists() { // The filter/hint row carries content only while typing, confirming a // delete, or showing an active query; when compact and idle, drop it. const showSearchRow = - !compact || filtering || confirm !== null || searching || renamingSet; + !compact || + filtering || + confirm !== null || + converting || + convertProgress !== null || + searching || + renamingSet; // Rows above the list beyond the header: tabs (1) + the filter row when shown // (2 normally, 1 compact since its margin goes too). const reserveRows = 1 + (showSearchRow ? (compact ? 1 : 2) : 0); @@ -566,7 +656,11 @@ export function Playlists() { {showSearchRow ? ( - {confirm ? ( + {convertProgress ? ( + + {convertProgress} + + ) : confirm ? ( {confirmText()} diff --git a/src/ui/store.ts b/src/ui/store.ts index 40fc4b8..885ba74 100644 --- a/src/ui/store.ts +++ b/src/ui/store.ts @@ -13,6 +13,7 @@ export type Section = | "playlists" | "history" | "download" + | "mp3" | "settings"; /** diff --git a/test/convert.test.ts b/test/convert.test.ts new file mode 100644 index 0000000..32aa4f9 --- /dev/null +++ b/test/convert.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { mp3MirroredPath } from "../src/library/convert"; +import type { Track } from "../src/library/types"; + +function track(filePath: string): Track { + return { + id: "youtube:test", + source: "youtube", + sourceTrackId: "test", + title: "Test", + filePath, + addedAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("mp3MirroredPath", () => { + it("mirrors the source/owner/playlist folder structure under mp3/", () => { + const lib = path.posix.join("/lib"); + const t = track( + path.posix.join(lib, "YouTube", "@nasa", "My Playlist", "Song.opus"), + ); + expect(mp3MirroredPath(lib, t)).toBe( + path.posix.join(lib, "mp3", "YouTube", "@nasa", "My Playlist", "Song.mp3"), + ); + }); + + it("mirrors a Singles track without a playlist folder", () => { + const lib = path.posix.join("/lib"); + const t = track(path.posix.join(lib, "SoundCloud", "lumen", "Singles", "Track.m4a")); + expect(mp3MirroredPath(lib, t)).toBe( + path.posix.join(lib, "mp3", "SoundCloud", "lumen", "Singles", "Track.mp3"), + ); + }); + + it("falls back to a flat basename when the file is outside the library", () => { + const lib = path.posix.join("/lib"); + const t = track(path.posix.join("/elsewhere", "Song.opus")); + expect(mp3MirroredPath(lib, t)).toBe( + path.posix.join(lib, "mp3", "Song.mp3"), + ); + }); + + it("swaps any extension to .mp3", () => { + const lib = path.posix.join("/lib"); + const t = track(path.posix.join(lib, "YouTube", "Singles", "Song.webm")); + expect(mp3MirroredPath(lib, t).endsWith("Song.mp3")).toBe(true); + }); +}); diff --git a/test/keymap.test.ts b/test/keymap.test.ts index 24cbce9..0ae92cd 100644 --- a/test/keymap.test.ts +++ b/test/keymap.test.ts @@ -2,17 +2,18 @@ import { describe, it, expect } from "vitest"; import { footerHints, sectionForDigit } from "../src/ui/keymap"; describe("sectionForDigit", () => { - it("maps 1-5 to the sidebar's display order", () => { + it("maps 1-6 to the sidebar's display order", () => { expect(sectionForDigit("1")).toBe("library"); expect(sectionForDigit("2")).toBe("playlists"); expect(sectionForDigit("3")).toBe("history"); expect(sectionForDigit("4")).toBe("download"); - expect(sectionForDigit("5")).toBe("settings"); + expect(sectionForDigit("5")).toBe("mp3"); + expect(sectionForDigit("6")).toBe("settings"); }); it("ignores everything else", () => { expect(sectionForDigit("0")).toBeNull(); - expect(sectionForDigit("6")).toBeNull(); + expect(sectionForDigit("7")).toBeNull(); expect(sectionForDigit("a")).toBeNull(); expect(sectionForDigit("")).toBeNull(); expect(sectionForDigit("12")).toBeNull();