#893: support M3U/M3U8 in playlist import-from-file

The import-from-file tool only took CSV/TSV/TXT. Add M3U/M3U8 — the most common
file-playlist format, and the one SoulSync itself exports. Parses extended M3U
(#EXTINF artist/title/duration, #PLAYLIST name) and simple M3U (derives
artist/title from the file name), and keeps "# MISSING:" entries from our own
export (those are exactly the tracks a user imports to go match/download).
Frontend-only: the parser runs client-side and feeds the existing import path.
This commit is contained in:
BoulderBadgeDad 2026-06-21 19:43:59 -07:00
parent d58da7a3db
commit 3aaf19a357
4 changed files with 92 additions and 13 deletions

View file

@ -1917,8 +1917,8 @@
<div class="import-file-zone-icon">📄</div>
<div class="import-file-zone-title">Drop your file here</div>
<div class="import-file-zone-subtitle">or click to browse</div>
<div class="import-file-zone-formats">Supported: CSV, TSV, TXT</div>
<input type="file" id="import-file-input" accept=".csv,.tsv,.txt" style="display:none">
<div class="import-file-zone-formats">Supported: CSV, TSV, TXT, M3U, M3U8</div>
<input type="file" id="import-file-input" accept=".csv,.tsv,.txt,.m3u,.m3u8" style="display:none">
</div>
<div class="import-file-format-hints">
<div class="import-file-hint">

View file

@ -714,9 +714,10 @@ const DOCS_SECTIONS = [
</div>
<div class="docs-subsection" id="sync-import-file">
<h3 class="docs-subsection-title">Import from File</h3>
<p class="docs-text">Import track lists from <strong>CSV, TSV, or plain text files</strong>. Drag and drop a file or click to browse. SoulSync parses the file, lets you preview and map columns, then creates a mirrored playlist for discovery and download.</p>
<p class="docs-text">Import track lists from <strong>CSV, TSV, M3U/M3U8, or plain text files</strong>. Drag and drop a file or click to browse. SoulSync parses the file, lets you preview and map columns, then creates a mirrored playlist for discovery and download.</p>
<ul class="docs-list">
<li><strong>CSV/TSV</strong>: Auto-detects columns; map Artist, Title, and Album from dropdowns</li>
<li><strong>M3U/M3U8</strong>: Read automatically artist, title and duration come from <code>#EXTINF</code> lines (or the file name for simple playlists). Round-trips with SoulSync's own M3U export</li>
<li><strong>Text files</strong>: One track per line; choose Artist-Title or Title-Artist order and separator (dash, tab, pipe, etc.)</li>
<li>Preview parsed tracks before importing</li>
<li>Name your playlist and it becomes a mirrored playlist for sync</li>
@ -1308,7 +1309,7 @@ const DOCS_SECTIONS = [
</div>
<div class="docs-subsection" id="imp-textfile">
<h3 class="docs-subsection-title">Import from Text File</h3>
<p class="docs-text">Import track lists from <strong>CSV</strong>, <strong>TSV</strong>, or <strong>TXT</strong> files. Upload a file with columns for artist, album, and track title:</p>
<p class="docs-text">Import track lists from <strong>CSV</strong>, <strong>TSV</strong>, <strong>TXT</strong>, or <strong>M3U/M3U8</strong> files. Upload a file with columns for artist, album, and track title (M3U playlists are read automatically):</p>
<ol class="docs-steps">
<li>Click <strong>Import from File</strong> and select your text file</li>
<li>Choose the <strong>separator</strong> (comma, tab, or pipe)</li>

View file

@ -722,8 +722,8 @@ const HELPER_CONTENT = {
},
'.sync-tab-button[data-tab="import-file"]': {
title: 'Import from File',
description: 'Import track lists from CSV, TSV, or plain text files. Drag and drop or browse for a file, map columns, then create a playlist for sync.',
tips: ['Supports CSV, TSV, and plain text (one track per line)', 'Column mapping for CSV/TSV files', 'Creates a mirrored playlist for persistent state'],
description: 'Import track lists from CSV, TSV, M3U/M3U8, or plain text files. Drag and drop or browse for a file, map columns, then create a playlist for sync.',
tips: ['Supports CSV, TSV, M3U/M3U8, and plain text (one track per line)', 'M3U/M3U8 is read automatically (artist, title, duration from #EXTINF)', 'Column mapping for CSV/TSV files', 'Creates a mirrored playlist for persistent state'],
docsId: 'sync-import-file'
},
'.sync-tab-button[data-tab="mirrored"]': {

View file

@ -41,8 +41,8 @@ function _initImportFileTab() {
function _importFileRead(file) {
const ext = file.name.split('.').pop().toLowerCase();
if (!['csv', 'tsv', 'txt'].includes(ext)) {
showToast('Unsupported file type. Use CSV, TSV, or TXT.', 'error');
if (!['csv', 'tsv', 'txt', 'm3u', 'm3u8'].includes(ext)) {
showToast('Unsupported file type. Use CSV, TSV, TXT, M3U, or M3U8.', 'error');
return;
}
@ -50,7 +50,8 @@ function _importFileRead(file) {
reader.onload = (e) => {
_importFileState.rawText = e.target.result;
_importFileState.fileName = file.name;
_importFileState.fileType = (ext === 'txt') ? 'text' : 'csv';
_importFileState.fileType = (ext === 'm3u' || ext === 'm3u8') ? 'm3u'
: (ext === 'txt') ? 'text' : 'csv';
_importFileParseAndPreview();
};
reader.readAsText(file);
@ -103,6 +104,66 @@ function _importFileParseCsv(text, delimiter) {
return { headers, rows };
}
// Parse an M3U / M3U8 playlist into track objects. Handles both the simple form
// (one media path per line) and the extended form (#EXTINF:<secs>,<artist> - <title>
// followed by the path). SoulSync's own export is extended M3U and replaces the path
// of an un-located track with a "# MISSING: ..." comment — those are exactly the
// tracks a user imports to go match/download, so a pending #EXTINF is flushed even
// when no path line follows it. Returns { tracks, playlistName }.
function _importFileParseM3u(text) {
const lines = (text || '').split(/\r?\n/);
const tracks = [];
let playlistName = '';
let pending = null; // { duration_ms, artist, title } from the last #EXTINF
function splitArtistTitle(s) {
const i = s.indexOf(' - ');
return i !== -1
? { artist: s.slice(0, i).trim(), title: s.slice(i + 3).trim() }
: { artist: '', title: s.trim() };
}
function pushTrack(artist, title, duration_ms) {
if (!title && !artist) return;
tracks.push({ track_name: title || '', artist_name: artist || '', album_name: '', duration_ms: duration_ms || 0 });
}
function flushPending() {
if (pending) { pushTrack(pending.artist, pending.title, pending.duration_ms); pending = null; }
}
for (const raw of lines) {
const line = raw.trim();
if (!line) continue;
if (line.charAt(0) === '#') {
if (/^#EXTINF:/i.test(line)) {
flushPending(); // a prior #EXTINF whose path was missing still counts
const rest = line.slice(8); // after "#EXTINF:"
const comma = rest.indexOf(',');
const secs = parseFloat(comma === -1 ? rest : rest.slice(0, comma));
const meta = comma === -1 ? '' : rest.slice(comma + 1).trim();
const { artist, title } = splitArtistTitle(meta);
pending = { duration_ms: (!isNaN(secs) && secs > 0) ? Math.round(secs * 1000) : 0, artist, title };
} else if (/^#PLAYLIST:/i.test(line)) {
playlistName = line.slice(line.indexOf(':') + 1).trim();
}
// ignore #EXTM3U, #GENERATED, "# MISSING:", #EXTALB, and other directives
continue;
}
// A non-# line is a media path/URL — the entry for the pending #EXTINF, if any.
if (pending && (pending.title || pending.artist)) {
pushTrack(pending.artist, pending.title, pending.duration_ms);
pending = null;
} else {
// Simple M3U with no #EXTINF: derive artist/title from the file name.
pending = null;
const base = (line.split(/[\\/]/).pop() || line).replace(/\.[^.]+$/, '');
const { artist, title } = splitArtistTitle(base);
pushTrack(artist, title, 0);
}
}
flushPending(); // trailing #EXTINF with no following path
return { tracks, playlistName };
}
function _importFileAutoMapColumns(headers) {
const map = {};
const lowerHeaders = headers.map(h => h.toLowerCase().trim());
@ -139,7 +200,14 @@ function _importFileParseAndPreview() {
const state = _importFileState;
const text = state.rawText;
if (state.fileType === 'text') {
if (state.fileType === 'm3u') {
// M3U/M3U8 is self-describing — no column mapping or order/separator needed.
const { tracks, playlistName } = _importFileParseM3u(text);
state.rows = tracks; // already track objects
state.headers = [];
state.columnMap = {};
state.m3uPlaylistName = playlistName || '';
} else if (state.fileType === 'text') {
// Plain text: one track per line
const lines = text.split(/\r?\n/).filter(l => l.trim());
state.rows = lines;
@ -163,7 +231,15 @@ function _importFileBuildTracks() {
const state = _importFileState;
state.parsedTracks = [];
if (state.fileType === 'text') {
if (state.fileType === 'm3u') {
// Tracks were already parsed by _importFileParseM3u; just copy them through.
state.parsedTracks = state.rows.map(t => ({
track_name: t.track_name || '',
artist_name: t.artist_name || '',
album_name: t.album_name || '',
duration_ms: t.duration_ms || 0
}));
} else if (state.fileType === 'text') {
const orderEl = document.getElementById('import-file-text-order');
const sepEl = document.getElementById('import-file-text-separator');
const order = orderEl ? orderEl.value : 'artist-title';
@ -249,10 +325,12 @@ function _importFileRenderPreview() {
_importFileRenderColumnMapping();
}
// Pre-fill playlist name from filename (strip extension)
// Pre-fill playlist name: prefer the M3U's own #PLAYLIST: directive, else the filename.
const nameInput = document.getElementById('import-file-playlist-name');
if (nameInput && !nameInput.value) {
nameInput.value = state.fileName.replace(/\.[^.]+$/, '');
nameInput.value = (state.fileType === 'm3u' && state.m3uPlaylistName)
? state.m3uPlaylistName
: state.fileName.replace(/\.[^.]+$/, '');
}
// Update button state
const btn = document.getElementById('import-file-import-btn');