Compare commits

..
4 Commits
Author SHA1 Message Date
HoneyandClaude Sonnet 4.6 28428aa362 feat: add reorganize-music.sh to migrate genre folders to Artist/Album structure
Reads ffprobe metadata from every audio file and places it into:
  {dest}/{AlbumArtist}/{Album}/{NN - Artist - Title.ext}
Exactly mirrors the sanitize(), folder and filename rules in
backend/lib/ytdlp.js. Dry-run by default; --move to execute, --clean
to remove empty source dirs afterwards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 22:37:00 +02:00
HoneyandClaude Sonnet 4.6 d777301c20 feat: batch fix artist sort tags + rename folder when override is added
- edit-metadata: use albumArtistSort (when provided) for artist folder naming
  instead of auto-converted romajiAlbumArtist — ensures manual overrides sent
  by the download pipeline also place files in the correct folder
- Add POST /api/fix-artist-sort endpoint: searches Navidrome for all songs
  where artist or albumArtist matches the kanji, re-writes ARTISTSORT +
  ALBUMARTISTSORT via ffmpeg (-map_metadata 0 preserves all other existing
  tags), moves each file to the correct artist folder, cleans up empty old
  dirs, triggers Navidrome rescan — returns a job ID for progress tracking
- addRomajiOverrideUI: after saving a new override, calls fix-artist-sort and
  shows a job card with live progress

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 22:33:12 +02:00
HoneyandClaude Sonnet 4.6 0a2736e893 fix: build error in nextSong + apply romaji overrides to downloads
- Fix missing closing brace for `if (ni >= 0...)` in nextSong — the removed
  `else { _nsBridge('next') }` left the inner if-block unclosed, causing
  vite/rollup to reject player.js as invalid JS
- Apply romaji override map to iTunes (executeDownload) and Spotify
  (executeSpotifyDownload) download payloads — sends artistSort/albumArtistSort/
  titleSort/albumSort fields so the backend writes correct sort tags to the
  downloaded file and can organize into the right artist folder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:33:49 +02:00
HoneyandClaude Sonnet 4.6 8c3a23ad14 feat: romaji override list — manual kanji→romaji corrections for sort tags
Adds a maintained list in Settings where you can map kanji artist/album/title
strings to their correct romaji reading (e.g. 照井順政 → Terui Yoshimasa).
Overrides are applied last (highest priority) when the edit-metadata modal
opens, so saving the song writes the correct sort tags permanently to the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:17:13 +02:00
7 changed files with 417 additions and 7 deletions

No files matched your search

+125 -1
View File
@@ -602,6 +602,130 @@ app.post('/api/download', async (req, res) => {
}); });
}); });
// POST /api/fix-artist-sort — batch-update sort tags + rename artist folder for all songs by a kanji artist
app.post('/api/fix-artist-sort', async (req, res) => {
const { ndUrl, user, pass, artistKanji, artistSort, jobId: clientJobId } = req.body;
if (!ndUrl || !user || !pass || !artistKanji || !artistSort)
return res.status(400).json({ error: 'ndUrl, user, pass, artistKanji, artistSort required' });
const jobId = clientJobId || ('job_' + Date.now());
jobs.set(jobId, {
id: jobId, type: 'fix-sort',
artist: artistKanji, title: `Romaji-Fix: ${artistSort}`, album: '',
status: 'queued', progress: 0, message: 'Suche Songs…',
log: [], createdAt: Date.now(), updatedAt: Date.now(),
});
res.json({ jobId, status: 'queued' });
(async () => {
const mkTok = () => {
const s = Math.random().toString(36).slice(2, 10);
return { s, t: crypto.createHash('md5').update(pass + s).digest('hex') };
};
// Search Navidrome for all songs by this artist
let songs = [];
try {
const { s, t } = mkTok();
const r = await fetch(
`${ndUrl}/rest/search3?query=${encodeURIComponent(artistKanji)}&artistCount=0&albumCount=0&songCount=500&u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json`,
{ signal: AbortSignal.timeout(15000) }
);
const d = await r.json();
const all = d?.['subsonic-response']?.searchResult3?.song || [];
songs = all.filter(sng => sng.artist === artistKanji || sng.albumArtist === artistKanji);
} catch (e) {
updateJob(jobId, { status: 'error', message: `Navidrome-Suche fehlgeschlagen: ${e.message}` });
return;
}
if (!songs.length) {
updateJob(jobId, { status: 'done', progress: 100, message: 'Keine Songs gefunden' });
return;
}
log(jobId, `🔍 ${songs.length} Songs gefunden für "${artistKanji}"`);
updateJob(jobId, { status: 'running', progress: 10, message: `${songs.length} Songs gefunden…` });
let done = 0, errors = 0;
const safeArtistDir = sanitize(artistSort.split(/[,;&]/)[0].trim());
for (const ndSong of songs) {
try {
// Resolve file path
let filePath = ndSong.path;
if (!filePath) { errors++; continue; }
if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath);
if (!fs.existsSync(filePath)) {
const fileName = path.basename(filePath);
const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase();
const found = _rebasePath(filePath, CONFIG.musicPath)
|| _findFileRecursive(CONFIG.musicPath, fileName)
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem);
if (found) filePath = found;
else { log(jobId, `⚠ Nicht gefunden: ${path.basename(ndSong.path || '')}`); errors++; continue; }
}
const fileExt = path.extname(filePath);
const tmpFile = filePath.slice(0, -fileExt.length) + '.sorttmp' + fileExt;
// -map_metadata 0 preserves all existing tags; individual -metadata flags override only sort ones
await new Promise((resolve, reject) => {
const ff = spawn('ffmpeg', [
'-i', filePath,
'-map', '0:a', '-map', '0:v?',
'-c', 'copy', '-map_metadata', '0',
'-metadata', `ARTISTSORT=${artistSort}`,
'-metadata', `ALBUMARTISTSORT=${artistSort}`,
tmpFile, '-y', '-loglevel', 'error',
]);
let ffErr = '';
ff.stderr.on('data', d => ffErr += d.toString());
ff.on('error', reject);
ff.on('close', code => code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}: ${ffErr.slice(-200)}`)));
});
fs.renameSync(tmpFile, filePath);
// Move to correct artist/album folder: {musicPath}/{safeArtistDir}/{albumName}/
const oldAlbumDir = path.dirname(filePath);
const oldArtistDir = path.dirname(oldAlbumDir);
const albumName = path.basename(oldAlbumDir);
const newAlbumDir = path.join(CONFIG.musicPath, safeArtistDir, albumName);
if (newAlbumDir !== oldAlbumDir) {
fs.mkdirSync(newAlbumDir, { recursive: true });
const destFile = path.join(newAlbumDir, path.basename(filePath));
if (!fs.existsSync(destFile)) fs.renameSync(filePath, destFile);
// Clean up empty old dirs
try {
if (fs.readdirSync(oldAlbumDir).filter(f => !f.startsWith('.')).length === 0) {
fs.rmdirSync(oldAlbumDir);
if (fs.readdirSync(oldArtistDir).filter(f => !f.startsWith('.')).length === 0)
fs.rmdirSync(oldArtistDir);
}
} catch {}
}
done++;
updateJob(jobId, { progress: 10 + Math.round((done / songs.length) * 80), message: `${done}/${songs.length} Songs aktualisiert…` });
log(jobId, `✅ ${ndSong.title}`);
} catch (e) {
log(jobId, `❌ ${ndSong.title}: ${e.message}`);
errors++;
}
}
// Trigger Navidrome rescan
try {
const { s, t } = mkTok();
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(5000) });
} catch {}
const msg = errors > 0 ? `${done} aktualisiert, ${errors} Fehler` : `${done} Songs aktualisiert`;
updateJob(jobId, { status: 'done', progress: 100, message: msg });
})().catch(e => updateJob(jobId, { status: 'error', message: e.message }));
});
app.get('/api/status/:jobId', (req, res) => { app.get('/api/status/:jobId', (req, res) => {
const job = jobs.get(req.params.jobId); const job = jobs.get(req.params.jobId);
if (!job) return res.status(404).json({ error: 'Job not found' }); if (!job) return res.status(404).json({ error: 'Job not found' });
@@ -978,7 +1102,7 @@ app.post('/api/edit-metadata', async (req, res) => {
const oldAlbumDir = path.dirname(currentFile); const oldAlbumDir = path.dirname(currentFile);
const oldArtistDir = path.dirname(oldAlbumDir); const oldArtistDir = path.dirname(oldAlbumDir);
const newAlbumName = romajiAlbum ? sanitize(romajiAlbum) : path.basename(oldAlbumDir); const newAlbumName = romajiAlbum ? sanitize(romajiAlbum) : path.basename(oldAlbumDir);
const newArtistName = romajiAlbumArtist ? sanitize(romajiAlbumArtist.split(/[,;&]/)[0].trim()) : path.basename(oldArtistDir); const newArtistName = (albumArtistSort || romajiAlbumArtist) ? sanitize((albumArtistSort || romajiAlbumArtist).split(/[,;&]/)[0].trim()) : path.basename(oldArtistDir);
const newDir = path.join(CONFIG.musicPath, newArtistName, newAlbumName); const newDir = path.join(CONFIG.musicPath, newArtistName, newAlbumName);
if (newDir !== oldAlbumDir) { if (newDir !== oldAlbumDir) {
+14
View File
@@ -657,6 +657,20 @@
<div id="s-show-romaji" class="toggle" onclick="this.classList.toggle('on')"></div> <div id="s-show-romaji" class="toggle" onclick="this.classList.toggle('on')"></div>
</div> </div>
</div> </div>
<div class="settings-section">
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="width:24px;height:24px;border-radius:6px;background:var(--accent-dim);display:inline-flex;align-items:center;justify-content:center;font-size:12px">㊙</span>
Romaji Korrekturen
</h3>
<div class="hint" style="margin-bottom:10px">Kanji → korrektes Romaji. Wird in Sort-Tags automatisch übernommen.</div>
<div id="romaji-overrides-list" style="display:flex;flex-direction:column;gap:5px;margin-bottom:10px"></div>
<div style="display:flex;gap:6px;align-items:center">
<input id="ro-kanji" placeholder="照井順政" style="flex:1;padding:8px 10px;background:var(--bg2);border:1px solid var(--border2);border-radius:8px;color:var(--text);font-family:var(--font);font-size:13px;outline:none" onkeydown="if(event.key==='Enter')addRomajiOverrideUI()">
<span style="color:var(--text2);flex-shrink:0">→</span>
<input id="ro-romaji" placeholder="Terui Yoshimasa" style="flex:1.2;padding:8px 10px;background:var(--bg2);border:1px solid var(--border2);border-radius:8px;color:var(--text);font-family:var(--font);font-size:13px;outline:none" onkeydown="if(event.key==='Enter')addRomajiOverrideUI()">
<button onclick="addRomajiOverrideUI()" style="flex-shrink:0;padding:8px 12px;border-radius:8px;border:none;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#000;font-family:var(--font);font-size:14px;font-weight:700;cursor:pointer">+</button>
</div>
</div>
<div class="settings-section"> <div class="settings-section">
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px"> <h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">🎨</span> <span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">🎨</span>
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env bash
# reorganize-music.sh
#
# Reads metadata tags from audio files and reorganises them into the
# NovaSonic folder/filename convention:
#
# <dest>/<AlbumArtist>/<Album>/<NN - Artist - Title.ext>
#
# The sanitize(), folder and filename rules exactly mirror
# backend/lib/ytdlp.js and backend/lib/metadata.js.
#
# Usage:
# ./reorganize-music.sh <source_dir> <dest_dir> [--move] [--clean]
#
# <source_dir> Root of the old genre-folder tree (searched recursively)
# <dest_dir> Music root to write into (can be the same as source)
# --move Actually move files (default: dry-run preview only)
# --clean After moving, remove empty directories under <source_dir>
#
# Requires: ffprobe (part of ffmpeg)
set -uo pipefail
# ── Args ──────────────────────────────────────────────────────────────────────
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <source_dir> <dest_dir> [--move] [--clean]"
exit 1
fi
SOURCE="$(realpath "$1")"
DEST="$(realpath "$2")"
DO_MOVE=false
DO_CLEAN=false
for arg in "${@:3}"; do
[[ "$arg" == "--move" ]] && DO_MOVE=true
[[ "$arg" == "--clean" ]] && DO_CLEAN=true
done
MOVED=0; SKIPPED=0; ERRORS=0
# ── sanitize(s): mirrors backend/lib/metadata.js sanitize() ──────────────────
# Replace filesystem-illegal chars with _, trim whitespace, cap at 100 chars.
sanitize() {
local s="${1:-Unknown}"
s="$(printf '%s' "$s" | sed 's/[\\/:*?"<>|]/_/g' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
printf '%s' "${s:0:100}"
}
# ── Read a single ffprobe tag (returns empty string if absent) ────────────────
get_tag() {
ffprobe -v quiet \
-show_entries "format_tags=$2" \
-of "default=noprint_wrappers=1:nokey=1" \
"$1" 2>/dev/null | head -1
}
# ── First segment before any artist separator (, ; &) ────────────────────────
# Used for the artist *folder* name (album artist, first name only).
first_artist() {
printf '%s' "$1" | sed 's/[,;&].*//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}
# ── Normalise all artists for the filename (; → ,) ───────────────────────────
# Mirrors: artistRomaji.replace(/;/g, ',')
artists_for_file() {
printf '%s' "$1" | sed 's/;[[:space:]]*/\, /g' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}
# ── Print header ──────────────────────────────────────────────────────────────
echo "NovaSonic Music Reorganiser"
echo "Source : $SOURCE"
echo "Dest : $DEST"
if $DO_MOVE; then
echo "Mode : MOVE (files will be moved)"
else
echo "Mode : DRY-RUN (nothing will change — add --move to execute)"
fi
echo "──────────────────────────────────────────────────────────────────────"
# ── Main loop: every audio file under SOURCE ──────────────────────────────────
while IFS= read -r -d '' file; do
# ── Extension (lowercase) ───────────────────────────────────────────────
filename_base="$(basename "$file")"
ext_lower="$(printf '%s' "${filename_base##*.}" | tr '[:upper:]' '[:lower:]')"
# ── Read tags via ffprobe ────────────────────────────────────────────────
raw_title=$(get_tag "$file" "title")
raw_artist=$(get_tag "$file" "artist")
raw_album_artist=$(get_tag "$file" "album_artist")
raw_album=$(get_tag "$file" "album")
raw_track=$(get_tag "$file" "track")
# ── Fallbacks when tags are missing ─────────────────────────────────────
# Title: use filename stem (strip extension + leading "NN - " prefix)
if [[ -z "$raw_title" ]]; then
raw_title="$(printf '%s' "${filename_base%.*}" | sed 's/^[0-9][0-9][[:space:]]*-[[:space:]]*//')"
raw_title="$(printf '%s' "$raw_title" | sed 's/[[:space:]]*-[[:space:]]*.*//')" # strip "Artist - " prefix if present
[[ -z "$raw_title" ]] && raw_title="Unknown Title"
fi
artist="${raw_artist:-Unknown Artist}"
# Album artist preferred for folder; fall back to track artist
main_artist="${raw_album_artist:-$artist}"
album="${raw_album:-Singles}"
# ── Track number: "5/12" or "05" → zero-padded "05 - " ──────────────────
track_num="$(printf '%s' "$raw_track" | cut -d'/' -f1 | tr -dc '0-9')"
if [[ -n "$track_num" && "$track_num" -gt 0 ]] 2>/dev/null; then
track_prefix="$(printf '%02d - ' "$track_num")"
else
track_prefix=""
fi
# ── Build folder path ────────────────────────────────────────────────────
# Convention: {dest}/{first_albumArtist}/{album}/
dir_artist="$(first_artist "$main_artist")"
safe_artist_dir="$(sanitize "$dir_artist")"
safe_album_dir="$(sanitize "$album")"
target_dir="$DEST/$safe_artist_dir/$safe_album_dir"
# ── Build filename ───────────────────────────────────────────────────────
# Convention: {NN - }Artist1, Artist2 - Title.ext
file_artists="$(artists_for_file "$artist")"
safe_artist_file="$(sanitize "$file_artists")"
safe_title="$(sanitize "$raw_title")"
new_filename="${track_prefix}${safe_artist_file} - ${safe_title}.${ext_lower}"
target="$target_dir/$new_filename"
# ── Already in the right place? ──────────────────────────────────────────
if [[ "$file" == "$target" ]]; then
printf ' ✓ already correct %s\n' "$safe_artist_dir/$safe_album_dir/$new_filename"
SKIPPED=$((SKIPPED + 1))
continue
fi
# ── Conflict: target already exists (don't overwrite) ────────────────────
if [[ -e "$target" ]]; then
printf ' ⚠ SKIP (exists) %s\n' "$target"
SKIPPED=$((SKIPPED + 1))
continue
fi
# ── Report what we will / would do ───────────────────────────────────────
printf ' → %s/%s/%s\n' "$safe_artist_dir" "$safe_album_dir" "$new_filename"
if $DO_MOVE; then
if mkdir -p "$target_dir" && mv -- "$file" "$target"; then
MOVED=$((MOVED + 1))
else
printf ' ERROR moving: %s\n' "$file" >&2
ERRORS=$((ERRORS + 1))
fi
else
MOVED=$((MOVED + 1))
fi
done < <(find "$SOURCE" -type f \( \
-iname "*.mp3" -o -iname "*.flac" -o \
-iname "*.m4a" -o -iname "*.opus" -o \
-iname "*.ogg" -o -iname "*.wav" \
\) -print0 | sort -z)
# ── Remove empty source dirs (optional) ──────────────────────────────────────
if $DO_MOVE && $DO_CLEAN; then
echo ""
echo "Cleaning empty directories under $SOURCE …"
find "$SOURCE" -mindepth 1 -type d -empty -delete 2>/dev/null && \
echo " Done." || echo " (nothing to clean or permission denied)"
fi
# ── Summary ──────────────────────────────────────────────────────────────────
echo "──────────────────────────────────────────────────────────────────────"
if $DO_MOVE; then
echo "Done: $MOVED moved, $SKIPPED skipped, $ERRORS errors"
else
echo "Dry-run: $MOVED would be moved, $SKIPPED already correct or conflicting"
echo ""
echo "Re-run with --move to execute. Add --clean to also remove empty source dirs."
fi
+16
View File
@@ -98,6 +98,22 @@ export async function fetchRomajiIndex() {
export function invalidateRomajiIndex() { _romajiIndex = null; _romajiMap = null; _artistRomajiMap = null; } export function invalidateRomajiIndex() { _romajiIndex = null; _romajiMap = null; _artistRomajiMap = null; }
// ─── ROMAJI OVERRIDES ───
// Manual kanji → romaji corrections stored in localStorage. Always take priority over auto-conversion.
export function getRomajiOverrides() {
try { return JSON.parse(localStorage.getItem('ns_romaji_overrides') || '{}'); } catch { return {}; }
}
export function setRomajiOverride(kanji, romaji) {
const o = getRomajiOverrides();
if (romaji?.trim()) o[kanji.trim()] = romaji.trim(); else delete o[kanji.trim()];
localStorage.setItem('ns_romaji_overrides', JSON.stringify(o));
}
export function removeRomajiOverride(kanji) {
const o = getRomajiOverrides();
delete o[kanji];
localStorage.setItem('ns_romaji_overrides', JSON.stringify(o));
}
export function dispArtistById(id, name) { export function dispArtistById(id, name) {
if (!S.cfg.showRomaji || !_artistRomajiMap) return name || ''; if (!S.cfg.showRomaji || !_artistRomajiMap) return name || '';
return _artistRomajiMap.get(String(id)) || name || ''; return _artistRomajiMap.get(String(id)) || name || '';
+2
View File
@@ -46,6 +46,7 @@ import {
toggleNotifPanel, deleteNotif, clearNotifs, initNotifBell, _notifNavigate, toggleNotifPanel, deleteNotif, clearNotifs, initNotifBell, _notifNavigate,
_pickTheme, _pickTheme,
toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong,
addRomajiOverrideUI, removeRomajiOverrideUI,
} from './ui.js'; } from './ui.js';
// ─── CONNECT ─── // ─── CONNECT ───
@@ -323,6 +324,7 @@ Object.assign(window, {
editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel, editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel,
toggleMobileSearch, closeMobileSearch, switchSetupTab, toggleMobileSearch, closeMobileSearch, switchSetupTab,
toggleNotifPanel, deleteNotif, clearNotifs, _notifNavigate, _pickTheme, toggleNotifPanel, deleteNotif, clearNotifs, _notifNavigate, _pickTheme,
addRomajiOverrideUI, removeRomajiOverrideUI,
// App // App
doConnect, disconnect, doConnect, disconnect,
// Audiobookshelf // Audiobookshelf
+1
View File
@@ -716,6 +716,7 @@ export function nextSong() {
// Explicit index prevents desync when JS and native queue pointers diverge. // Explicit index prevents desync when JS and native queue pointers diverge.
_nsBridge('seekToIndex', ni, 0); _nsBridge('seekToIndex', ni, 0);
_nsBridge('play'); _nsBridge('play');
}
// else: ni = -1 means end of queue with no repeat — let ExoPlayer stop naturally. // else: ni = -1 means end of queue with no repeat — let ExoPlayer stop naturally.
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue(); if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
return; return;
+79 -6
View File
@@ -1,6 +1,6 @@
import { S } from './state.js'; import { S } from './state.js';
import { esc, formatDur, toast } from './utils.js'; import { esc, formatDur, toast } from './utils.js';
import { api, coverUrl, ndAuth, fetchRomajiIndex, romajiConvert, romajiLookup, invalidateRomajiIndex, invalidateCoverCache } from './api.js'; import { api, coverUrl, ndAuth, fetchRomajiIndex, romajiConvert, romajiLookup, invalidateRomajiIndex, invalidateCoverCache, getRomajiOverrides, setRomajiOverride, removeRomajiOverride } from './api.js';
import { THEMES, applyTheme, getCurrentThemeId } from './themes.js'; import { THEMES, applyTheme, getCurrentThemeId } from './themes.js';
// Circular imports — safe (only used inside function bodies) // Circular imports — safe (only used inside function bodies)
import { playQueue, dlSongLocal, toggleStar, togglePlay, renderQueue, syncNpStar, updateStarBtn, npToggleStar, playCurrent, _syncNpDirect, setPlayIcon, nativeInsertAt, refreshDisplayMode, updateNpColorMode } from './player.js'; import { playQueue, dlSongLocal, toggleStar, togglePlay, renderQueue, syncNpStar, updateStarBtn, npToggleStar, playCurrent, _syncNpDirect, setPlayIcon, nativeInsertAt, refreshDisplayMode, updateNpColorMode } from './player.js';
@@ -51,6 +51,7 @@ export function openSettings() {
document.getElementById('s-show-romaji').classList.toggle('on', !!S.cfg.showRomaji); document.getElementById('s-show-romaji').classList.toggle('on', !!S.cfg.showRomaji);
document.getElementById('s-cover-color-bg').classList.toggle('on', !!S.cfg.coverColorBg); document.getElementById('s-cover-color-bg').classList.toggle('on', !!S.cfg.coverColorBg);
_renderThemePicker(); _renderThemePicker();
_renderRomajiOverrides();
document.getElementById('settings-modal').classList.add('open'); document.getElementById('settings-modal').classList.add('open');
checkBackend(); checkBackend();
} }
@@ -70,6 +71,52 @@ export function _pickTheme(id) {
applyTheme(id); applyTheme(id);
_renderThemePicker(); _renderThemePicker();
} }
function _renderRomajiOverrides() {
const list = document.getElementById('romaji-overrides-list');
if (!list) return;
const entries = Object.entries(getRomajiOverrides());
if (!entries.length) {
list.innerHTML = '<div style="color:var(--text2);font-size:13px;padding:4px 0">Keine Korrekturen vorhanden</div>';
return;
}
list.innerHTML = entries.map(([k, v]) =>
`<div style="display:flex;align-items:center;gap:6px;padding:7px 10px;background:var(--bg2);border-radius:8px">
<span style="flex:1;font-size:13px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(k)}</span>
<span style="color:var(--text2);flex-shrink:0">→</span>
<span style="flex:1.2;font-size:13px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(v)}</span>
<button onclick="removeRomajiOverrideUI(${JSON.stringify(k)})" style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:18px;line-height:1;padding:0 2px;flex-shrink:0">×</button>
</div>`
).join('');
}
export async function addRomajiOverrideUI() {
const k = (document.getElementById('ro-kanji')?.value || '').trim();
const v = (document.getElementById('ro-romaji')?.value || '').trim();
if (!k || !v) { toast('⚠ Kanji und Romaji eingeben'); return; }
setRomajiOverride(k, v);
document.getElementById('ro-kanji').value = '';
document.getElementById('ro-romaji').value = '';
_renderRomajiOverrides();
// Batch-fix existing songs in the library
if (!S.cfg.backendUrl || !S.cfg.url || !S.cfg.user || !S.cfg.pass) return;
try {
const jobId = 'job_' + Date.now();
addJob(jobId, `Romaji-Fix: ${v}`, k, 'fix-sort');
const res = await fetch(`${S.cfg.backendUrl}/api/fix-artist-sort`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, artistKanji: k, artistSort: v, jobId }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
pollJob(jobId);
} catch (e) {
toast('⚠ Fix-Artist-Sort: ' + e.message);
}
}
export function removeRomajiOverrideUI(kanji) {
removeRomajiOverride(kanji);
_renderRomajiOverrides();
}
export function closeSettings() { document.getElementById('settings-modal').classList.remove('open'); } export function closeSettings() { document.getElementById('settings-modal').classList.remove('open'); }
export async function confirmDisconnect() { export async function confirmDisconnect() {
const ok = await showConfirm('Abmelden', 'Wirklich abmelden? Alle lokalen Einstellungen werden gelöscht.', 'Abmelden'); const ok = await showConfirm('Abmelden', 'Wirklich abmelden? Alle lokalen Einstellungen werden gelöscht.', 'Abmelden');
@@ -369,7 +416,7 @@ export function toggleInfoEdit() {
const p = new URLSearchParams({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, songId: _sId }); const p = new URLSearchParams({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, songId: _sId });
return fetch(`${S.cfg.backendUrl}/api/song-sorttags?${p}`).then(r => r.ok ? r.json() : null).catch(() => null); return fetch(`${S.cfg.backendUrl}/api/song-sorttags?${p}`).then(r => r.ok ? r.json() : null).catch(() => null);
}).then(t => { }).then(t => {
if (!t) return; if (t) {
const _jp = /[ぁ-んァ-ン一-龯]/; const _jp = /[ぁ-んァ-ン一-龯]/;
// Only use file sort tags if they are non-Japanese (romaji/ASCII) — Japanese sort tags // Only use file sort tags if they are non-Japanese (romaji/ASCII) — Japanese sort tags
// in the file are incorrect and should be ignored in favour of the romaji index value. // in the file are incorrect and should be ignored in favour of the romaji index value.
@@ -377,6 +424,13 @@ export function toggleInfoEdit() {
if (t.artistSort && !_jp.test(t.artistSort)) document.getElementById('ie-artistsort').value = t.artistSort; if (t.artistSort && !_jp.test(t.artistSort)) document.getElementById('ie-artistsort').value = t.artistSort;
if (t.albumArtistSort && !_jp.test(t.albumArtistSort)) document.getElementById('ie-albumartistsort').value = t.albumArtistSort; if (t.albumArtistSort && !_jp.test(t.albumArtistSort)) document.getElementById('ie-albumartistsort').value = t.albumArtistSort;
if (t.albumSort && !_jp.test(t.albumSort)) document.getElementById('ie-albumsort').value = t.albumSort; if (t.albumSort && !_jp.test(t.albumSort)) document.getElementById('ie-albumsort').value = t.albumSort;
}
// Manual overrides — highest priority, always applied last.
const ov = getRomajiOverrides();
if (s.artist && ov[s.artist]) { document.getElementById('ie-artistsort').value = ov[s.artist]; document.getElementById('ie-albumartistsort').value = ov[s.artist]; }
if (s.albumArtist && ov[s.albumArtist]) document.getElementById('ie-albumartistsort').value = ov[s.albumArtist];
if (s.title && ov[s.title]) document.getElementById('ie-titlesort').value = ov[s.title];
if (s.album && ov[s.album]) document.getElementById('ie-albumsort').value = ov[s.album];
}).catch(() => {}); }).catch(() => {});
// Art preview — use existing cover // Art preview — use existing cover
@@ -570,11 +624,15 @@ export async function executeDownload() {
if(!S.downloadTarget || !S.cfg.backendUrl) return; if(!S.downloadTarget || !S.cfg.backendUrl) return;
closeDlModal(); closeDlModal();
const jobId = 'job_' + Date.now(); const jobId = 'job_' + Date.now();
const ov = getRomajiOverrides();
const _artist = S.downloadTarget.artistName || 'Unknown Artist';
const _title = S.downloadTarget.trackName || S.downloadTarget.collectionName || 'Unknown';
const _album = S.downloadTarget.collectionName || '';
const payload = { const payload = {
type: S.dlType, type: S.dlType,
artist: S.downloadTarget.artistName || 'Unknown Artist', artist: _artist,
title: S.downloadTarget.trackName || S.downloadTarget.collectionName || 'Unknown', title: _title,
album: S.downloadTarget.collectionName || '', album: _album,
artworkUrl: S.downloadTarget.artworkUrl100 || S.downloadTarget.artworkUrl60 || '', artworkUrl: S.downloadTarget.artworkUrl100 || S.downloadTarget.artworkUrl60 || '',
itunesId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null, itunesId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null,
collectionId: S.downloadTarget.collectionId || null, collectionId: S.downloadTarget.collectionId || null,
@@ -584,6 +642,9 @@ export async function executeDownload() {
discNum: S.downloadTarget.discNumber || undefined, discNum: S.downloadTarget.discNumber || undefined,
discTotal: S.downloadTarget.discCount || undefined, discTotal: S.downloadTarget.discCount || undefined,
durationMs: S.downloadTarget.trackTimeMillis || undefined, durationMs: S.downloadTarget.trackTimeMillis || undefined,
...(ov[_artist] && { artistSort: ov[_artist], albumArtistSort: ov[_artist] }),
...(ov[_title] && { titleSort: ov[_title] }),
...(ov[_album] && { albumSort: ov[_album] }),
}; };
addJob(jobId, payload.title, payload.artist, payload.type, payload.album); addJob(jobId, payload.title, payload.artist, payload.type, payload.album);
try { try {
@@ -604,11 +665,23 @@ export async function executeSpotifyDownload({ type, title, artist, artworkUrl,
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; } if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
const jobId = 'job_' + Date.now(); const jobId = 'job_' + Date.now();
addJob(jobId, title, artist, type); addJob(jobId, title, artist, type);
const ov = getRomajiOverrides();
const enrichedTracks = (tracks || []).map(t => {
const a = t.artistName || '';
const n = t.trackName || '';
const al = t.collectionName || '';
return {
...t,
...(ov[a] && { artistSort: ov[a], albumArtistSort: ov[a] }),
...(ov[n] && { titleSort: ov[n] }),
...(ov[al] && { albumSort: ov[al] }),
};
});
try { try {
const res = await fetch(`${S.cfg.backendUrl}/api/download`, { const res = await fetch(`${S.cfg.backendUrl}/api/download`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobId, type, title, artist, artworkUrl, tracks }), body: JSON.stringify({ jobId, type, title, artist, artworkUrl, tracks: enrichedTracks }),
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
pollJob(jobId); pollJob(jobId);