Files

1020 lines
40 KiB
Python

import os
import sys
# --- Frozen binary dispatch (must be before all other imports) ---
# When the frozen binary spawns itself as a subprocess, it passes one of these
# markers as argv[1] so the child process runs in the correct mode and exits.
if len(sys.argv) >= 2:
if sys.argv[1] == '__cli__':
# Pipeline CLI mode: strip marker, hand off to playlist.main()
del sys.argv[1]
from playlist import main as _cli_main
_cli_main()
sys.exit(0)
elif sys.argv[1] == '__picker__':
# Folder picker mode: open native dialog, print path, exit
import tkinter as tk
from tkinter import filedialog
_root = tk.Tk()
_root.withdraw()
_root.attributes('-topmost', True)
print(filedialog.askdirectory() or '', end='')
sys.exit(0)
import json
import time
import subprocess
import threading
import socket
import re
from pathlib import Path
from flask import Flask, render_template_string, request, Response, jsonify
# --- Configuration & Palette ---
APP_NAME = "PLAYLIST PIRATE"
VERSION = "v2.0"
# Fire Orange Palette
PALETTE = {
"bg_void": "#04060b",
"text_warm": "#e8d5b8",
"text_muted": "#7a6f5e",
"fp": "#ff6600", # fire orange
"fb": "#ff8833", # fire bright
"fd": "#cc4400", # fire deep
"fg": "rgba(255,102,0,0.12)", # fire glow
}
# --- Flask App Setup ---
app = Flask(__name__)
current_proc = None
current_step = None
output_queue = []
def strip_ansi(text):
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
return ansi_escape.sub('', text)
PLAYLIST_PY = str(Path(__file__).parent / "playlist.py")
def get_free_port():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('', 0))
port = s.getsockname()[1]
s.close()
return port
# --- File/Folder Picker ---
def pick_folder_native():
"""Open a native folder picker via tkinter subprocess."""
if getattr(sys, 'frozen', False):
# Frozen: re-invoke this binary in __picker__ mode
cmd = [sys.executable, '__picker__']
else:
# From source: re-invoke this script in __picker__ mode
cmd = [sys.executable, str(Path(__file__).resolve()), '__picker__']
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
path = result.stdout.strip()
return path if path else None
except Exception as e:
print(f"Picker error: {e}")
return None
# --- Routes ---
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE, palette=PALETTE, version=VERSION)
@app.route('/pick-folder', methods=['POST'])
def pick_folder():
path = pick_folder_native()
if path:
return jsonify({"path": path})
return jsonify({"path": None}), 400
@app.route('/scan-dir', methods=['POST'])
def scan_dir():
data = request.json
path = data.get("path")
if not path or not os.path.exists(path):
return jsonify({"error": "Invalid path"}), 400
p = Path(path)
csvs = [f.name for f in p.glob("*.csv")]
mds = [f.name for f in p.glob("*-playlist.md")]
return jsonify({
"csvs": csvs,
"mds": mds
})
_TRACK_RE_NEW = re.compile(r'^- \[( |x|-)\] (.+?) \| (.+?) \| ISRC:[^ ]+ \| SP:[^ ]+ \| (.+)$')
_TRACK_RE_OLD = re.compile(r'^- \[( |x|-)\] (.+?) \| (.+?) \| ISRC:[^ ]+ \| (.+)$')
_pending_merges = [] # list of (temp_path, original_path)
@app.route('/read-tracks', methods=['POST'])
def read_tracks():
data = request.json
md_path = os.path.join(data.get('work_dir', ''), data.get('filename', ''))
if not os.path.exists(md_path):
return jsonify({'error': 'File not found'}), 404
tracks = []
with open(md_path, encoding='utf-8') as f:
for line in f:
m = _TRACK_RE_NEW.match(line.strip()) or _TRACK_RE_OLD.match(line.strip())
if m:
status, url = m.group(1), m.group(4)
tracks.append({
'title': m.group(2),
'artist': m.group(3),
'downloadable': status == ' ' and url not in ('?', 'NOT_FOUND', '-'),
})
return jsonify({'tracks': tracks})
def _create_combined_download_md(work_dir, track_filter):
"""Build one combined temp .md from all selected tracks across playlists.
Deduplicates by YouTube URL. Returns (temp_path, list_of_original_paths)."""
seen_urls = set()
lines_out = ['# Combined Download\n', '<!-- auto-generated -->\n', '\n']
originals = []
for md_filename, selected_titles in track_filter.items():
original = os.path.join(work_dir, md_filename)
if not os.path.exists(original):
continue
originals.append(original)
selected = set(selected_titles)
with open(original, encoding='utf-8') as f:
for line in f:
m = _TRACK_RE_NEW.match(line.strip()) or _TRACK_RE_OLD.match(line.strip())
if not m:
continue
if m.group(1) != ' ':
continue # already done / not found
title, url = m.group(2), m.group(4)
if title not in selected:
continue
if url not in ('?', 'NOT_FOUND', '-') and url in seen_urls:
continue # duplicate URL — skip
if url not in ('?', 'NOT_FOUND', '-'):
seen_urls.add(url)
lines_out.append(line if line.endswith('\n') else line + '\n')
if len(lines_out) <= 3:
return None, []
tmp = os.path.join(work_dir, '_download_queue.tmp.md')
with open(tmp, 'w', encoding='utf-8') as f:
f.writelines(lines_out)
return tmp, originals
def _merge_and_cleanup():
"""Copy DONE status from temp back to original .md files, then delete temp."""
for item in _pending_merges:
temp_path, originals = item
try:
done = set()
with open(temp_path, encoding='utf-8') as f:
for line in f:
m = _TRACK_RE_NEW.match(line.strip()) or _TRACK_RE_OLD.match(line.strip())
if m and m.group(1) == 'x':
done.add(m.group(2))
for original_path in (originals if isinstance(originals, list) else [originals]):
if not done or not os.path.exists(original_path):
continue
updated = []
with open(original_path, encoding='utf-8') as f:
for line in f:
m = _TRACK_RE_NEW.match(line.strip()) or _TRACK_RE_OLD.match(line.strip())
if m and m.group(1) == ' ' and m.group(2) in done:
line = line.replace('- [ ]', '- [x]', 1)
updated.append(line)
with open(original_path, 'w', encoding='utf-8') as f:
f.writelines(updated)
except Exception as e:
print(f'Merge error: {e}')
finally:
try: os.remove(temp_path)
except: pass
_pending_merges.clear()
@app.route('/run', methods=['POST'])
def run_command():
global current_proc, current_step, output_queue
if current_proc and current_proc.poll() is None:
return jsonify({"error": "A process is already running"}), 400
data = request.json
step = data.get("step")
args = data.get("args", [])
work_dir = data.get("work_dir", os.getcwd())
track_filter = data.get("track_filter") # {md_filename: [titles]} or None
# For download with per-track selection, build filtered temp files
if step == 'download' and track_filter:
# Build one combined temp file — deduped by URL, one track at a time
tmp_path, originals = _create_combined_download_md(work_dir, track_filter)
if not tmp_path:
return jsonify({"error": "No downloadable tracks selected"}), 400
_pending_merges.append((tmp_path, originals))
# Find --output and its value from args; keep everything after it
try:
out_idx = args.index('--output')
extra = args[out_idx:]
except ValueError:
extra = []
args = [os.path.basename(tmp_path)] + extra
if getattr(sys, 'frozen', False):
cmd = [sys.executable, '__cli__', step] + args
else:
cmd = [sys.executable, PLAYLIST_PY, step] + args
try:
current_proc = subprocess.Popen(
cmd, cwd=work_dir,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, env=os.environ.copy()
)
current_step = step
output_queue = []
return jsonify({"ok": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/stream')
def stream():
def generate():
global current_proc, current_step
if not current_proc:
yield "data: __DONE__\n\n"
return
while True:
line = current_proc.stdout.readline()
if not line:
if current_proc.poll() is not None:
break
time.sleep(0.1)
continue
clean = strip_ansi(line).rstrip()
if clean:
yield f"data: {json.dumps(clean)}\n\n"
completed_step = current_step
current_proc = None
current_step = None
if completed_step == 'download':
_merge_and_cleanup()
else:
# Discard any stale pending merges from a dropped connection
for item in _pending_merges:
try: os.remove(item[0])
except: pass
_pending_merges.clear()
yield "data: __DONE__\n\n"
return Response(generate(), mimetype='text/event-stream')
# --- Embedded Assets ---
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PLAYLIST PIRATE ☠</title>
<link href="https://fonts.googleapis.com/css2?family=Faculty+Glyphic&family=Rambla:wght@400;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
:root {
--bg-void: {{ palette.bg_void }};
--text-warm: {{ palette.text_warm }};
--text-muted: {{ palette.text_muted }};
--fp: {{ palette.fp }};
--fb: {{ palette.fb }};
--fd: {{ palette.fd }};
--fg: {{ palette.fg }};
}
/* ── CRT ───────────────────────────────────────────── */
body::before {
content: '';
position: fixed; inset: 0;
background: repeating-linear-gradient(
to bottom,
transparent 0px, transparent 1px,
rgba(0,0,0,0.07) 1px, rgba(0,0,0,0.07) 2px
);
pointer-events: none; z-index: 9999;
}
body::after {
content: '';
position: fixed; inset: 0;
background: radial-gradient(ellipse at 50% 50%, transparent 55%, rgba(0,0,0,0.45) 100%);
pointer-events: none; z-index: 9998;
}
@keyframes flicker {
0%,89%,91%,100% { opacity: 1; }
90% { opacity: 0.96; }
}
#app-layout { animation: flicker 12s infinite; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background-color: var(--bg-void);
color: var(--text-warm);
font-family: 'Rambla', sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* ── Header ─────────────────────────────────────────── */
header {
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--fd);
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(0,0,0,0.5);
flex-shrink: 0;
}
.logo-area {
display: flex;
align-items: center;
gap: 1.2rem;
}
.pixel-skull {
font-size: 1.1rem;
line-height: 1;
color: var(--fp);
filter: drop-shadow(0 0 6px var(--fp));
font-family: 'Share Tech Mono', monospace;
white-space: pre;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
font-size: 0.6rem;
letter-spacing: 0;
}
h1 {
font-family: 'Faculty Glyphic', sans-serif;
color: var(--fp);
font-size: 1.4rem;
letter-spacing: 0.08em;
text-transform: uppercase;
text-shadow: 0 0 8px var(--fp), 0 0 20px rgba(255,102,0,0.3);
}
.version {
font-family: 'Share Tech Mono', monospace;
color: var(--text-muted);
font-size: 0.75rem;
}
#status-badge {
font-family: 'Share Tech Mono', monospace;
font-size: 0.75rem;
letter-spacing: 0.1em;
}
/* ── Two-column layout ───────────────────────────────── */
#app-layout {
display: flex;
flex: 1;
overflow: hidden;
}
.pipeline-col {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
min-width: 0;
}
.log-col {
width: 420px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-left: 1px solid var(--fd);
}
@media (max-width: 860px) {
#app-layout { flex-direction: column; }
.log-col { width: auto; height: 260px; border-left: none; border-top: 1px solid var(--fd); }
}
/* ── Sections — open like chests (content above header) ─ */
.section {
display: flex;
flex-direction: column-reverse;
border-left: 1px solid var(--fd);
background: rgba(255,102,0,0.02);
transition: border-color 100ms ease, background 100ms ease;
}
.section.locked {
opacity: 0.4;
pointer-events: none;
filter: grayscale(1);
}
.section-header {
padding: 0.85rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
border-top: 1px solid var(--fd);
}
.section-header:hover { background: var(--fg); }
.section-title {
display: flex;
align-items: center;
gap: 1rem;
font-family: 'Faculty Glyphic', sans-serif;
font-size: 1.05rem;
}
.step-num {
color: var(--fp);
font-family: 'Share Tech Mono', monospace;
border: 1px solid var(--fp);
width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem;
box-shadow: 0 0 6px rgba(255,102,0,0.3);
}
.section-content {
padding: 1rem 1rem 0.5rem 3.5rem;
display: none;
border-bottom: 1px solid var(--fd);
}
.section.open .section-content { display: block; }
.section.open {
border-left: 2px solid var(--fp);
background: var(--fg);
}
.section.open .section-header { border-top-color: var(--fp); }
/* Controls */
.btn {
background: transparent;
border: 1px solid var(--fp);
color: var(--fp);
padding: 0.4rem 1rem;
font-family: 'Share Tech Mono', monospace;
cursor: pointer;
text-transform: uppercase;
font-size: 0.9rem;
transition: all 100ms ease;
}
.btn:hover:not(:disabled) { background: var(--fp); color: var(--bg-void); box-shadow: 0 0 10px var(--fp); }
.btn:disabled { border-color: var(--text-muted); color: var(--text-muted); cursor: not-allowed; }
.btn-run { font-weight: bold; padding: 0.4rem 1.5rem; }
.path-display {
font-family: 'Share Tech Mono', monospace;
color: var(--text-muted);
font-size: 0.85rem;
margin-top: 0.5rem;
word-break: break-all;
}
.file-list {
margin-top: 1rem;
font-family: 'Share Tech Mono', monospace;
font-size: 0.8rem;
max-height: 150px;
overflow-y: auto;
border: 1px solid var(--fd);
padding: 0.5rem;
}
.file-item { color: var(--text-muted); margin-bottom: 0.2rem; }
.file-item.highlight { color: var(--fb); }
/* ── Log panel ───────────────────────────────────────── */
#log-panel {
flex: 1;
background: #000;
display: flex;
flex-direction: column;
overflow: hidden;
}
.log-header {
padding: 0.5rem 1rem;
background: var(--fd);
display: flex;
justify-content: space-between;
align-items: center;
font-family: 'Share Tech Mono', monospace;
font-size: 0.75rem;
color: #000;
font-weight: bold;
flex-shrink: 0;
letter-spacing: 0.12em;
}
#log-content {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1rem;
font-family: 'Share Tech Mono', monospace;
font-size: 0.8rem;
line-height: 1.5;
color: var(--text-muted);
}
.log-line { margin-bottom: 0.15rem; white-space: pre-wrap; }
.log-line.cmd { color: var(--fp); font-weight: bold; text-shadow: 0 0 6px rgba(255,102,0,0.5); }
.log-line.ok { color: #00ff41; text-shadow: 0 0 5px rgba(0,255,65,0.4); }
.log-line.warn { color: #ffcf40; }
.log-line.err { color: #ff3300; text-shadow: 0 0 5px rgba(255,51,0,0.5); }
.log-line.highlight { color: var(--fb); }
/* Warnings */
.warning-box {
border: 1px solid #ff3300;
background: rgba(255, 51, 0, 0.05);
padding: 0.8rem;
margin: 1rem 0;
font-size: 0.9rem;
}
.warning-box strong { color: #ff3300; }
/* Workspace bar */
.workspace-bar {
display: flex; align-items: center; gap: 1rem;
padding: 0.75rem 1rem;
border-left: 2px solid var(--fp);
background: var(--fg);
margin-bottom: 0.25rem;
}
/* Form */
.form-row { display: flex; align-items: center; gap: 1rem; }
input[type="text"], input[type="number"] {
background: transparent;
border: 1px solid var(--fd);
color: var(--text-warm);
padding: 0.4rem;
font-family: 'Share Tech Mono', monospace;
}
/* ── Download two-panel ──────────────────────────────── */
.dl-panels {
display: flex; gap: 1rem; margin-top: 0.5rem;
}
.dl-panel {
flex: 1; min-width: 0;
}
.dl-panel-label {
font-family: 'Share Tech Mono', monospace;
font-size: 0.7rem; color: var(--text-muted);
text-transform: uppercase; letter-spacing: 0.1em;
margin-bottom: 0.4rem;
}
.track-unavailable { color: var(--text-muted); font-style: italic; }
/* Step descriptions */
.step-desc {
font-family: 'Rambla', sans-serif;
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.5;
margin-bottom: 0.75rem;
}
/* Checkboxes */
.check-item {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.2rem 0; cursor: pointer;
font-family: 'Share Tech Mono', monospace;
font-size: 0.8rem; color: var(--fb);
}
.check-item input[type="checkbox"] {
accent-color: var(--fp);
width: 14px; height: 14px; cursor: pointer; flex-shrink: 0;
}
.check-item:hover { color: var(--text-warm); }
.check-all-row {
display: flex; gap: 1rem; margin-bottom: 0.4rem;
font-family: 'Share Tech Mono', monospace; font-size: 0.75rem;
}
.check-all-row button {
background: none; border: none; color: var(--text-muted);
cursor: pointer; padding: 0; font-family: inherit; font-size: inherit;
text-decoration: underline;
}
.check-all-row button:hover { color: var(--fp); }
</style>
</head>
<body>
<header>
<div class="logo-area">
<div class="pixel-skull">&#x2620;</div>
<div>
<h1>PLAYLIST PIRATE</h1>
<div class="version">{{ version }} &nbsp;/&nbsp; CHART YER COURSE BELOW</div>
</div>
</div>
<div id="status-badge">&#x2693; AT ANCHOR</div>
</header>
<div id="app-layout">
<div class="pipeline-col">
<!-- Workspace -->
<div class="workspace-bar">
<button class="btn" onclick="browseFolder()">&#x1F5FA; CHART COURSE</button>
<div class="path-display" id="workspace-path">No port charted</div>
</div>
<!-- Step 1: Resolve -->
<section class="section open" id="sec-resolve">
<div class="section-header" onclick="toggleSection('sec-resolve')">
<div class="section-title">
<div class="step-num">I</div>
<span>PARSE THE MANIFEST</span>
</div>
<button class="btn btn-run" id="btn-resolve" onclick="runStep('resolve', event)">SAIL &#x25B6;</button>
</div>
<div class="section-content">
<p class="step-desc">Reads your Spotify CSV exports and creates a tracking file for each playlist. Select the CSVs you want to process — you can always come back and run others later.</p>
<div class="file-list" id="list-csv">
<div class="file-item">Chart a course to detect yer CSVs...</div>
</div>
</div>
</section>
<!-- Step 2: Search -->
<section class="section locked" id="sec-search">
<div class="section-header" onclick="toggleSection('sec-search')">
<div class="section-title">
<div class="step-num">II</div>
<span>CHART THE WATERS</span>
</div>
<button class="btn btn-run" id="btn-search" onclick="runStep('search', event)">SAIL &#x25B6;</button>
</div>
<div class="section-content">
<p class="step-desc">Searches YouTube for every track and saves the video URL. About 3&ndash;7 seconds per track &mdash; a full playlist takes a few minutes. If you interrupt it, it picks up where it left off next time.</p>
<div class="form-row" style="margin-bottom: 0.75rem;">
<label>Delay (sec):</label>
<input type="number" id="search-min" value="3" style="width: 50px;">
<span>to</span>
<input type="number" id="search-max" value="7" style="width: 50px;">
</div>
<div class="file-list" id="list-md-search"></div>
</div>
</section>
<!-- Step 3: Build -->
<section class="section locked" id="sec-build">
<div class="section-header" onclick="toggleSection('sec-build')">
<div class="section-title">
<div class="step-num">III</div>
<span>RAISE THE FLAG</span>
</div>
<button class="btn btn-run" id="btn-build" onclick="runStep('build', event)">SAIL &#x25B6;</button>
</div>
<div class="section-content">
<p class="step-desc">Generates a static HTML page for each playlist with embedded YouTube players, MusicBrainz recording links, artist pages, and Spotify links. Choose where to put them &mdash; they're ready to drop straight into a website.</p>
<div class="form-row" style="margin-bottom: 0.75rem;">
<label>Output Dir:</label>
<input type="text" id="build-out" placeholder="destination port" style="flex-grow: 1;">
<button class="btn" onclick="browseOutDir('build-out')">BROWSE</button>
</div>
<div class="file-list" id="list-md-build"></div>
</div>
</section>
<!-- Step 4: Download -->
<section class="section" id="sec-download">
<div class="section-header" onclick="toggleSection('sec-download')">
<div class="section-title">
<div class="step-num">IV</div>
<span>PLUNDER THE HOLD</span>
</div>
<button class="btn btn-run" id="btn-download" onclick="runStep('download', event)">SAIL &#x25B6;</button>
</div>
<div class="section-content">
<p class="step-desc">Downloads each track as a 192kbps MP3 with title, artist, album and ISRC tags embedded. Requires ffmpeg. This step is always opt-in &mdash; nothing downloads unless you run it.</p>
<div class="warning-box">
<strong>&#x26A0; PIRATE&#39;S OATH</strong><br>
You are responsible for ensuring you have the right to download this content in your jurisdiction.
</div>
<div class="form-row" style="margin-bottom: 0.75rem;">
<label>Output Dir:</label>
<input type="text" id="download-out" placeholder="where to stash the loot" style="flex-grow: 1;">
<button class="btn" onclick="browseOutDir('download-out')">BROWSE</button>
</div>
<div class="dl-panels">
<div class="dl-panel">
<div class="dl-panel-label">&#x1F4DC; Playlists</div>
<div class="file-list" id="list-md-download"></div>
</div>
<div class="dl-panel">
<div class="dl-panel-label">&#x1F3B5; Tracks</div>
<div class="file-list" id="list-tracks-download"><div class="file-item">Select a playlist to browse its tracks</div></div>
</div>
</div>
</div>
</section>
</div><!-- /pipeline-col -->
<div class="log-col">
<div id="log-panel">
<div class="log-header">
<span>&#x1F426; CROW&#39;S NEST</span>
<button class="btn" style="padding: 1px 10px; font-size: 0.7rem; color:#000; border-color:#000;" onclick="clearLog()">SWAB DECK</button>
</div>
<div id="log-content"></div>
</div>
</div><!-- /log-col -->
</div><!-- /app-layout -->
<script>
const state = {
workDir: null,
csvFiles: [],
mdFiles: [],
running: false
};
function toggleSection(id) {
const sec = document.getElementById(id);
if (sec.classList.contains('locked')) return;
sec.classList.toggle('open');
}
async function browseFolder() {
const res = await fetch('/pick-folder', { method: 'POST' });
const data = await res.json();
if (data.path) {
state.workDir = data.path;
document.getElementById('workspace-path').textContent = state.workDir;
updateDir();
}
}
async function browseOutDir(inputId) {
const res = await fetch('/pick-folder', { method: 'POST' });
const data = await res.json();
if (data.path) document.getElementById(inputId).value = data.path;
}
async function updateDir() {
if (!state.workDir) return;
const res = await fetch('/scan-dir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: state.workDir })
});
const data = await res.json();
state.csvFiles = data.csvs;
state.mdFiles = data.mds;
renderCheckList('list-csv', state.csvFiles, 'No CSVs detected');
renderCheckList('list-md-search', state.mdFiles, 'No tracking files — run Resolve first');
renderCheckList('list-md-build', state.mdFiles, 'No tracking files — run Resolve first');
await renderDownloadMdList(state.mdFiles);
document.getElementById('sec-search').classList.toggle('locked', state.mdFiles.length === 0);
document.getElementById('sec-build').classList.toggle('locked', state.mdFiles.length === 0);
}
let _refreshGen = 0;
async function renderDownloadMdList(files) {
const container = document.getElementById('list-md-download');
if (!files || files.length === 0) {
container.innerHTML = '<div class="file-item">No tracking files — run Resolve first</div>';
document.getElementById('list-tracks-download').innerHTML =
'<div class="file-item">Select a playlist to browse its tracks</div>';
return;
}
container.innerHTML =
`<div class="check-all-row">
<button onclick="dlMdToggleAll(true)">all</button>
<button onclick="dlMdToggleAll(false)">none</button>
</div>` +
files.map(f =>
`<label class="check-item">
<input type="checkbox" class="dl-md-cb" value="${f}" checked
onchange="refreshDownloadTracks()">
${f}
</label>`
).join('');
await refreshDownloadTracks();
}
function dlMdToggleAll(checked) {
document.querySelectorAll('.dl-md-cb').forEach(cb => cb.checked = checked);
refreshDownloadTracks();
}
async function refreshDownloadTracks() {
const gen = ++_refreshGen;
const pane = document.getElementById('list-tracks-download');
const checked = Array.from(document.querySelectorAll('.dl-md-cb:checked')).map(cb => cb.value);
if (!checked.length) {
pane.innerHTML = '<div class="file-item">No playlists selected</div>';
return;
}
pane.innerHTML = '<div class="file-item">Loading...</div>';
const allTracks = [];
for (const filename of checked) {
if (gen !== _refreshGen) return; // stale — a newer call has started
const res = await fetch('/read-tracks', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({work_dir: state.workDir, filename})
});
const data = await res.json();
if (data.tracks) {
data.tracks.forEach(t => allTracks.push({...t, md: filename}));
}
}
if (gen !== _refreshGen) return; // stale — newer call already rendering
if (!allTracks.length) { pane.innerHTML = '<div class="file-item">No tracks found</div>'; return; }
pane.innerHTML =
`<div class="check-all-row">
<button onclick="setAll('dl-tracks', true)">hoist all</button>
<button onclick="setAll('dl-tracks', false)">cut loose</button>
</div>` +
allTracks.map(t =>
t.downloadable
? `<label class="check-item">
<input type="checkbox" data-list="dl-tracks" data-md="${t.md}"
value="${t.title.replace(/"/g,'&quot;')}" checked>
${t.title}<span style="color:var(--text-muted);font-size:0.75rem"> — ${t.artist}</span>
</label>`
: `<div class="check-item track-unavailable">&mdash; ${t.title} <span style="font-size:0.75rem">(${t.artist})</span></div>`
).join('');
}
function renderCheckList(id, files, emptyMsg) {
const container = document.getElementById(id);
if (!files || files.length === 0) {
container.innerHTML = `<div class="file-item">${emptyMsg}</div>`;
return;
}
const checkboxes = files.map((f, i) =>
`<label class="check-item">
<input type="checkbox" data-list="${id}" value="${f}" checked>
${f}
</label>`
).join('');
container.innerHTML =
`<div class="check-all-row">
<button onclick="setAll('${id}', true)">all</button>
<button onclick="setAll('${id}', false)">none</button>
</div>` + checkboxes;
}
function setAll(listId, checked) {
document.querySelectorAll(`input[data-list="${listId}"]`)
.forEach(cb => cb.checked = checked);
}
function getChecked(listId) {
return Array.from(document.querySelectorAll(`input[data-list="${listId}"]:checked`))
.map(cb => cb.value);
}
function clearLog() {
document.getElementById('log-content').innerHTML = '';
}
function appendLog(line) {
const container = document.getElementById('log-content');
const div = document.createElement('div');
div.className = 'log-line';
if (line.startsWith('>')) div.classList.add('cmd');
else if (line.includes('')) div.classList.add('ok');
else if (line.includes('WARN') || line.includes('NOT FOUND')) div.classList.add('warn');
else if (line.includes('ERR') || line.includes('ERROR') || line.includes('[FATAL]')) div.classList.add('err');
div.textContent = line;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
async function runStep(step, event) {
if (event) event.stopPropagation();
if (state.running || !state.workDir) return;
let args = [];
const body = { step, args, work_dir: state.workDir };
if (step === 'resolve') {
const files = getChecked('list-csv');
if (!files.length) { appendLog('[WARN] No CSVs selected.'); return; }
args = files;
} else if (step === 'search') {
const files = getChecked('list-md-search');
if (!files.length) { appendLog('[WARN] No playlist files selected.'); return; }
const min = document.getElementById('search-min').value;
const max = document.getElementById('search-max').value;
args = files.concat(['--delay-min', min, '--delay-max', max]);
} else if (step === 'build') {
const files = getChecked('list-md-build');
if (!files.length) { appendLog('[WARN] No playlist files selected.'); return; }
const outDir = document.getElementById('build-out').value || state.workDir + '/dist';
args = files.concat(['--out', outDir]);
} else if (step === 'download') {
const mdFiles = Array.from(document.querySelectorAll('.dl-md-cb:checked')).map(cb => cb.value);
if (!mdFiles.length) { appendLog('[WARN] No playlist files selected.'); return; }
const outDir = document.getElementById('download-out').value || state.workDir + '/downloads';
// Collect track selections from right pane (keyed by source playlist)
const trackFilter = {};
const trackCbs = document.querySelectorAll('#list-tracks-download input[data-list="dl-tracks"]');
if (trackCbs.length > 0) {
trackCbs.forEach(cb => {
const md = cb.dataset.md;
if (!trackFilter[md]) trackFilter[md] = [];
if (cb.checked) trackFilter[md].push(cb.value);
});
body.track_filter = trackFilter;
}
args = mdFiles.concat(['--output', outDir]);
}
state.running = true;
updateLockState();
appendLog(`> STARTING: ${step.toUpperCase()}`);
body.args = args;
const res = await fetch('/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.ok) {
startStream();
} else {
appendLog(`[ERROR] ${data.error}`);
state.running = false;
updateLockState();
}
}
function startStream() {
const ev = new EventSource('/stream');
ev.onmessage = (e) => {
if (e.data === '__DONE__') {
ev.close();
state.running = false;
updateLockState();
appendLog('> PROCESS COMPLETE.');
updateDir();
return;
}
try { appendLog(JSON.parse(e.data)); } catch(err) { console.error(err); }
};
ev.onerror = () => {
ev.close();
state.running = false;
updateLockState();
};
}
function updateLockState() {
document.querySelectorAll('.btn-run').forEach(btn => btn.disabled = state.running);
const badge = document.getElementById('status-badge');
badge.textContent = state.running ? '\u2693 UNDERWAY' : '\u2693 AT ANCHOR';
badge.style.color = state.running ? 'var(--fb)' : 'var(--text-muted)';
badge.style.textShadow = state.running ? '0 0 8px var(--fb)' : 'none';
}
</script>
</body>
</html>
"""
# --- Entry Point ---
if __name__ == '__main__':
import webbrowser
port = get_free_port()
url = f"http://localhost:{port}"
def run_flask():
import logging
logging.getLogger('werkzeug').setLevel(logging.ERROR)
app.run(port=port, debug=False, use_reloader=False)
threading.Thread(target=run_flask, daemon=True).start()
time.sleep(0.5)
webbrowser.open(url)
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass