Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions src/library/convert.ts
Original file line number Diff line number Diff line change
@@ -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 `<libraryDir>/mp3/`,
* swapping the extension to `.mp3`. A track at
* `<libraryDir>/YouTube/@nasa/My Playlist/song.opus`
* converts to
* `<libraryDir>/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 `<libraryDir>/mp3/<basename>.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 `<libraryDir>/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 `<libraryDir>/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<ConvertResult> {
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 };
}
3 changes: 3 additions & 0 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -66,6 +67,8 @@ function Content({ section }: { section: Section }) {
return <History />;
case "download":
return <Download />;
case "mp3":
return <Mp3 />;
case "settings":
return <Settings />;
}
Expand Down
1 change: 1 addition & 0 deletions src/ui/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

Expand Down
15 changes: 12 additions & 3 deletions src/ui/keymap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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" },
Expand Down Expand Up @@ -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,
];
}
}
82 changes: 76 additions & 6 deletions src/ui/sections/Library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -71,6 +72,10 @@ export function Library() {
// Pending track rename.
const [renamingTrackId, setRenamingTrackId] = useState<string | null>(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<string | null>(null);

const songs = useMemo(
// library.all() is already newest-first (addedAt desc); recompute on new
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand All @@ -335,7 +397,15 @@ export function Library() {
the list's height budget never moves. Hidden when compact + idle. */}
{showSearchRow ? (
<Box marginBottom={compact ? 0 : 1}>
{confirm ? (
{convertProgress ? (
<Text color={COLOR.accent} wrap="truncate-end">
{convertProgress}
</Text>
) : convertConfirm ? (
<Text color={COLOR.warn} wrap="truncate-end">
{`Convert ${convertConfirm.count} song${convertConfirm.count === 1 ? "" : "s"} to MP3? y Convert ${ICON.dot} esc Cancel`}
</Text>
) : confirm ? (
<Text color={COLOR.warn} wrap="truncate-end">
{`Delete '${cleanText(confirm.title)}'? y Delete ${ICON.dot} esc Keep`}
</Text>
Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading