1020 lines
40 KiB
Python
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">☠</div>
|
|
<div>
|
|
<h1>PLAYLIST PIRATE</h1>
|
|
<div class="version">{{ version }} / CHART YER COURSE BELOW</div>
|
|
</div>
|
|
</div>
|
|
<div id="status-badge">⚓ AT ANCHOR</div>
|
|
</header>
|
|
|
|
<div id="app-layout">
|
|
<div class="pipeline-col">
|
|
|
|
<!-- Workspace -->
|
|
<div class="workspace-bar">
|
|
<button class="btn" onclick="browseFolder()">🗺 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 ▶</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 ▶</button>
|
|
</div>
|
|
<div class="section-content">
|
|
<p class="step-desc">Searches YouTube for every track and saves the video URL. About 3–7 seconds per track — 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 ▶</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 — 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 ▶</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 — nothing downloads unless you run it.</p>
|
|
<div class="warning-box">
|
|
<strong>⚠ PIRATE'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">📜 Playlists</div>
|
|
<div class="file-list" id="list-md-download"></div>
|
|
</div>
|
|
<div class="dl-panel">
|
|
<div class="dl-panel-label">🎵 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>🐦 CROW'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,'"')}" checked>
|
|
${t.title}<span style="color:var(--text-muted);font-size:0.75rem"> — ${t.artist}</span>
|
|
</label>`
|
|
: `<div class="check-item track-unavailable">— ${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
|