615 lines
27 KiB
HTML
615 lines
27 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>furumi-fd</title>
|
||
<link rel="icon" href="data:,">
|
||
<style>
|
||
:root {
|
||
--bg: #14161a; --panel: #1d2026; --border: #2c313a;
|
||
--text: #e6e8ec; --muted: #8b93a1; --accent: #6aa5ff; --danger: #e06c75;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; background: var(--bg); color: var(--text);
|
||
font: 14px/1.5 -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
}
|
||
header {
|
||
display: flex; align-items: baseline; gap: 16px;
|
||
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||
}
|
||
header h1 { margin: 0; font-size: 18px; }
|
||
header span { color: var(--muted); font-size: 12px; }
|
||
nav { display: flex; gap: 4px; padding: 10px 20px 0; }
|
||
nav button {
|
||
background: none; border: 1px solid transparent; border-bottom: none;
|
||
color: var(--muted); padding: 8px 16px; cursor: pointer;
|
||
border-radius: 8px 8px 0 0; font-size: 14px;
|
||
}
|
||
nav button.active {
|
||
background: var(--panel); border-color: var(--border); color: var(--text);
|
||
}
|
||
main { padding: 16px 20px 40px; }
|
||
section { display: none; }
|
||
section.active { display: block; }
|
||
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||
input, select {
|
||
background: var(--panel); border: 1px solid var(--border); color: var(--text);
|
||
border-radius: 6px; padding: 7px 10px; font-size: 14px;
|
||
}
|
||
input:focus, select:focus { outline: 1px solid var(--accent); }
|
||
button.btn {
|
||
background: var(--accent); border: none; color: #0d1117; font-weight: 600;
|
||
border-radius: 6px; padding: 7px 14px; cursor: pointer; font-size: 14px;
|
||
}
|
||
button.btn.secondary { background: var(--panel); color: var(--text); border: 1px solid var(--border); font-weight: 400; }
|
||
button.btn.danger { background: none; color: var(--danger); border: 1px solid transparent; padding: 3px 8px; }
|
||
button.btn.danger:hover { border-color: var(--danger); }
|
||
button.btn.small { padding: 3px 8px; font-weight: 400; }
|
||
form.add {
|
||
display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end;
|
||
background: var(--panel); border: 1px solid var(--border);
|
||
border-radius: 8px; padding: 12px; margin-bottom: 16px;
|
||
}
|
||
form.add label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: var(--muted); }
|
||
form.add select[multiple] { min-width: 160px; min-height: 64px; }
|
||
table { border-collapse: collapse; width: 100%; }
|
||
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); }
|
||
th { color: var(--muted); font-weight: 500; font-size: 12px; }
|
||
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||
td.actions { text-align: right; white-space: nowrap; }
|
||
td .muted { color: var(--muted); }
|
||
#toast {
|
||
position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%);
|
||
background: var(--danger); color: #fff; padding: 10px 18px; border-radius: 8px;
|
||
display: none; max-width: 80vw; z-index: 10;
|
||
}
|
||
#player {
|
||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 9;
|
||
display: flex; align-items: center; gap: 14px;
|
||
padding: 8px 20px; background: var(--panel); border-top: 1px solid var(--border);
|
||
}
|
||
#player-title { min-width: 200px; max-width: 40vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
#player audio { flex: 1; max-width: 640px; height: 36px; }
|
||
main { padding-bottom: 90px; }
|
||
#toast.ok { background: #3f9d5f; }
|
||
dialog {
|
||
background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||
border-radius: 10px; max-width: min(700px, 90vw); max-height: 80vh;
|
||
}
|
||
dialog::backdrop { background: rgba(0,0,0,.5); }
|
||
dialog pre { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
|
||
.empty { color: var(--muted); padding: 20px 0; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header><h1>furumi-fd</h1><span>менеджер музыкальной библиотеки (индекс + прослушивание; файлы никогда не изменяются)</span></header>
|
||
<nav id="tabs">
|
||
<button data-tab="artists" class="active">Артисты</button>
|
||
<button data-tab="releases">Релизы</button>
|
||
<button data-tab="tracks">Треки</button>
|
||
<button data-tab="files">Файлы</button>
|
||
<button data-tab="federation">Федерация</button>
|
||
</nav>
|
||
<main>
|
||
<section id="tab-artists" class="active">
|
||
<form class="add" id="artist-form">
|
||
<label>Имя <input name="name" required placeholder="Boards of Canada"></label>
|
||
<button class="btn" type="submit">Добавить артиста</button>
|
||
</form>
|
||
<div class="toolbar">
|
||
<input id="artists-q" placeholder="Поиск…">
|
||
</div>
|
||
<table><thead><tr>
|
||
<th class="num">ID</th><th>Имя</th><th>Создан</th><th></th>
|
||
</tr></thead><tbody id="artists-body"></tbody></table>
|
||
<div class="empty" id="artists-empty" hidden>Пусто</div>
|
||
</section>
|
||
|
||
<section id="tab-releases">
|
||
<form class="add" id="release-form">
|
||
<label>Название <input name="title" required placeholder="Music Has the Right…"></label>
|
||
<label>Тип <select name="release_type">
|
||
<option value="album">Альбом</option><option value="single">Сингл</option>
|
||
<option value="ep">EP</option><option value="compilation">Сборник</option>
|
||
<option value="mixtape">Микстейп</option><option value="live">Концерт</option>
|
||
<option value="soundtrack">Саундтрек</option><option value="remix">Ремикс</option>
|
||
<option value="demo">Демо</option>
|
||
</select></label>
|
||
<label>Год <input name="year" type="number" min="0" max="3000" style="width:90px"></label>
|
||
<label>Артисты <select name="artist_ids" multiple id="release-artists-select"></select></label>
|
||
<button class="btn" type="submit">Добавить релиз</button>
|
||
</form>
|
||
<div class="toolbar">
|
||
<input id="releases-q" placeholder="Поиск…">
|
||
</div>
|
||
<table><thead><tr>
|
||
<th class="num">ID</th><th>Название</th><th>Тип</th><th class="num">Год</th><th>Артисты</th><th></th>
|
||
</tr></thead><tbody id="releases-body"></tbody></table>
|
||
<div class="empty" id="releases-empty" hidden>Пусто</div>
|
||
</section>
|
||
|
||
<section id="tab-tracks">
|
||
<form class="add" id="track-form">
|
||
<label>Название <input name="title" required placeholder="Wildlife Analysis"></label>
|
||
<label>Релиз <select name="release_id" required id="track-release-select"></select></label>
|
||
<label>Аудиофайл <select name="audio_file_id" required id="track-audio-select"></select></label>
|
||
<label># <input name="track_number" type="number" min="0" style="width:60px"></label>
|
||
<label>Длит., c <input name="duration_seconds" type="number" min="0" step="0.1" style="width:90px"></label>
|
||
<label>Артисты <select name="artists" multiple id="track-artists-select"></select></label>
|
||
<button class="btn" type="submit">Добавить трек</button>
|
||
</form>
|
||
<div class="toolbar">
|
||
<input id="tracks-q" placeholder="Поиск…">
|
||
</div>
|
||
<table><thead><tr>
|
||
<th class="num">ID</th><th>Название</th><th>Релиз</th><th class="num">#</th><th class="num">Длит.</th><th></th>
|
||
</tr></thead><tbody id="tracks-body"></tbody></table>
|
||
<div class="empty" id="tracks-empty" hidden>Пусто</div>
|
||
</section>
|
||
|
||
<section id="tab-files">
|
||
<form class="add" id="file-form">
|
||
<label>Тип <select name="file_type">
|
||
<option value="audio">audio</option><option value="cover_art">cover_art</option>
|
||
</select></label>
|
||
<label>Путь (от media root) <input name="file_path" required style="width:340px" placeholder="Artist/Album/01 Track.flac"></label>
|
||
<label>MIME <input name="mime_type" placeholder="audio/flac" style="width:130px"></label>
|
||
<button class="btn" type="submit">Добавить файл</button>
|
||
</form>
|
||
<div class="toolbar">
|
||
<input id="files-q" placeholder="Поиск по пути…">
|
||
<select id="files-type">
|
||
<option value="">все типы</option>
|
||
<option value="audio">audio</option>
|
||
<option value="cover_art">cover_art</option>
|
||
</select>
|
||
</div>
|
||
<table><thead><tr>
|
||
<th class="num">ID</th><th>Тип</th><th>Путь</th><th>MIME</th><th></th>
|
||
</tr></thead><tbody id="files-body"></tbody></table>
|
||
<div class="empty" id="files-empty" hidden>Пусто</div>
|
||
</section>
|
||
<section id="tab-federation">
|
||
<form class="add" id="federation-form">
|
||
<label style="flex-direction:row;align-items:center;gap:8px;font-size:14px;color:var(--text)">
|
||
<input type="checkbox" name="enabled" id="fed-enabled" style="width:auto"> Включить федерацию
|
||
</label>
|
||
<label>ID сети (общий секрет участников)
|
||
<input name="network_id" id="fed-network" placeholder="my-music-network-x7f3" style="width:260px">
|
||
</label>
|
||
<button class="btn" type="submit">Сохранить</button>
|
||
<button class="btn secondary" type="button" id="fed-sync-now">Синхронизировать сейчас</button>
|
||
</form>
|
||
|
||
<div id="fed-status" class="empty">Загрузка…</div>
|
||
|
||
<h3 style="margin:20px 0 8px">Поиск по сети</h3>
|
||
<div class="toolbar">
|
||
<input id="fed-q" placeholder="Артист, релиз или трек…" style="width:280px">
|
||
<select id="fed-kind">
|
||
<option value="">все типы</option>
|
||
<option value="artist">артисты</option>
|
||
<option value="release">релизы</option>
|
||
<option value="track">треки</option>
|
||
</select>
|
||
<button class="btn" id="fed-search-btn">Искать</button>
|
||
<span id="fed-search-meta" style="color:var(--muted);align-self:center"></span>
|
||
</div>
|
||
<table><thead><tr>
|
||
<th>Тип</th><th>Название</th><th>Артисты</th><th class="num">Год</th><th>Детали</th><th>Владелец</th><th></th>
|
||
</tr></thead><tbody id="fed-results-body"></tbody></table>
|
||
<div class="empty" id="fed-results-empty" hidden>Ничего не найдено</div>
|
||
</section>
|
||
</main>
|
||
|
||
<div id="player" hidden>
|
||
<span id="player-title" class="muted"></span>
|
||
<audio id="player-audio" controls></audio>
|
||
</div>
|
||
|
||
<div id="toast"></div>
|
||
<dialog id="detail-dialog">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<strong id="detail-title"></strong>
|
||
<button class="btn secondary small" onclick="document.getElementById('detail-dialog').close()">закрыть</button>
|
||
</div>
|
||
<pre id="detail-body"></pre>
|
||
</dialog>
|
||
|
||
<script>
|
||
const API = '/api';
|
||
const LIMIT = 500;
|
||
|
||
// ---------- helpers ----------
|
||
async function api(path, opts = {}) {
|
||
const res = await fetch(API + path, {
|
||
headers: { 'content-type': 'application/json' },
|
||
...opts,
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || res.status + ' ' + res.statusText);
|
||
return data;
|
||
}
|
||
|
||
let toastTimer;
|
||
function toast(message, ok = false) {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = message;
|
||
el.className = ok ? 'ok' : '';
|
||
el.style.display = 'block';
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => { el.style.display = 'none'; }, ok ? 2500 : 6000);
|
||
}
|
||
|
||
function esc(value) {
|
||
const div = document.createElement('div');
|
||
div.textContent = value ?? '';
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function debounce(fn, ms = 300) {
|
||
let timer;
|
||
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
|
||
}
|
||
|
||
async function showDetail(title, path) {
|
||
try {
|
||
const data = await api(path);
|
||
document.getElementById('detail-title').textContent = title;
|
||
document.getElementById('detail-body').textContent = JSON.stringify(data, null, 2);
|
||
document.getElementById('detail-dialog').showModal();
|
||
} catch (err) { toast(err.message); }
|
||
}
|
||
|
||
function rowButtons(kind, id, extra = '') {
|
||
return `<td class="actions">${extra}
|
||
<button class="btn secondary small" onclick="viewItem('${kind}', ${id})">инфо</button>
|
||
<button class="btn danger" onclick="deleteItem('${kind}', ${id})">удалить</button>
|
||
</td>`;
|
||
}
|
||
|
||
function escAttr(value) {
|
||
return esc(value).replaceAll('"', '"');
|
||
}
|
||
|
||
// ---------- player ----------
|
||
function playUrl(url, title) {
|
||
document.getElementById('player').hidden = false;
|
||
document.getElementById('player-title').textContent = title;
|
||
const audio = document.getElementById('player-audio');
|
||
audio.src = url;
|
||
audio.play().catch(err => toast('Не удалось запустить воспроизведение: ' + err.message));
|
||
}
|
||
|
||
document.getElementById('player-audio').addEventListener('error', async () => {
|
||
const audio = document.getElementById('player-audio');
|
||
if (!audio.src) return;
|
||
// The stream endpoint reports problems as JSON; surface them.
|
||
try {
|
||
const res = await fetch(audio.src, { headers: { range: 'bytes=0-' } });
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
toast(data.error || `Ошибка воспроизведения (${res.status})`);
|
||
return;
|
||
}
|
||
} catch { /* fall through to the generic message */ }
|
||
toast('Ошибка воспроизведения: формат не поддерживается браузером или поток оборвался');
|
||
});
|
||
|
||
document.addEventListener('click', event => {
|
||
const local = event.target.closest('button.play-track');
|
||
if (local) {
|
||
playUrl(`${API}/tracks/${local.dataset.id}/audio`, local.dataset.title);
|
||
return;
|
||
}
|
||
const fed = event.target.closest('button.play-fed');
|
||
if (fed) {
|
||
const params = new URLSearchParams({ owner: fed.dataset.owner, item_id: fed.dataset.item });
|
||
const from = fed.dataset.own === '1' ? 'своя библиотека' : `пир ${fed.dataset.owner.slice(0, 12)}…`;
|
||
playUrl(`${API}/federation/stream?${params}`, `${fed.dataset.title} — ${from}`);
|
||
}
|
||
});
|
||
|
||
const KIND_PATHS = { artist: 'artists', release: 'releases', track: 'tracks', file: 'media-files' };
|
||
const KIND_NAMES = { artist: 'Артист', release: 'Релиз', track: 'Трек', file: 'Файл' };
|
||
const KIND_CONFIRM = {
|
||
artist: 'Удалить артиста? Его релизы и треки останутся, снимутся только связи.',
|
||
release: 'Удалить релиз? Его треки тоже будут удалены из базы (файлы не трогаются).',
|
||
track: 'Удалить трек из базы? Файл на диске не трогается.',
|
||
file: 'Удалить запись о файле? Сам файл на диске не трогается.',
|
||
};
|
||
|
||
function viewItem(kind, id) {
|
||
showDetail(`${KIND_NAMES[kind]} #${id}`, `/${KIND_PATHS[kind]}/${id}`);
|
||
}
|
||
|
||
async function deleteItem(kind, id) {
|
||
if (!confirm(KIND_CONFIRM[kind])) return;
|
||
try {
|
||
await api(`/${KIND_PATHS[kind]}/${id}`, { method: 'DELETE' });
|
||
toast(`${KIND_NAMES[kind]} #${id} удалён`, true);
|
||
refreshAll();
|
||
} catch (err) { toast(err.message); }
|
||
}
|
||
|
||
function fmtDuration(seconds) {
|
||
if (!seconds) return '';
|
||
const m = Math.floor(seconds / 60), s = Math.round(seconds % 60);
|
||
return `${m}:${String(s).padStart(2, '0')}`;
|
||
}
|
||
|
||
function renderRows(tbodyId, emptyId, rows, renderRow) {
|
||
document.getElementById(tbodyId).innerHTML = rows.map(renderRow).join('');
|
||
document.getElementById(emptyId).hidden = rows.length > 0;
|
||
}
|
||
|
||
function fillSelect(select, items, label, { placeholder } = {}) {
|
||
const selected = new Set([...select.selectedOptions].map(o => o.value));
|
||
select.innerHTML = (placeholder ? `<option value="">${placeholder}</option>` : '')
|
||
+ items.map(i => `<option value="${i.id}" ${selected.has(String(i.id)) ? 'selected' : ''}>${esc(label(i))}</option>`).join('');
|
||
}
|
||
|
||
// ---------- data caches (for name lookups and selects) ----------
|
||
let artistsCache = [], releasesCache = [], audioFilesCache = [];
|
||
|
||
// ---------- loaders ----------
|
||
async function loadArtists() {
|
||
const q = document.getElementById('artists-q').value.trim();
|
||
const rows = await api(`/artists?limit=${LIMIT}${q ? '&q=' + encodeURIComponent(q) : ''}`);
|
||
if (!q) artistsCache = rows;
|
||
renderRows('artists-body', 'artists-empty', rows, a => `<tr>
|
||
<td class="num">${a.id}</td>
|
||
<td>${esc(a.name)}</td>
|
||
<td class="muted">${esc(a.created_at)}</td>
|
||
${rowButtons('artist', a.id)}
|
||
</tr>`);
|
||
const artistLabel = a => `${a.name} (#${a.id})`;
|
||
fillSelect(document.getElementById('release-artists-select'), artistsCache, artistLabel);
|
||
fillSelect(document.getElementById('track-artists-select'), artistsCache, artistLabel);
|
||
}
|
||
|
||
async function loadReleases() {
|
||
const q = document.getElementById('releases-q').value.trim();
|
||
const rows = await api(`/releases?limit=${LIMIT}${q ? '&q=' + encodeURIComponent(q) : ''}`);
|
||
if (!q) releasesCache = rows;
|
||
// artist names per release come from the detail endpoint; to keep the list
|
||
// fast we show them only when artistsCache can't be avoided — fetch lazily.
|
||
renderRows('releases-body', 'releases-empty', rows, r => `<tr>
|
||
<td class="num">${r.id}</td>
|
||
<td>${esc(r.title)}</td>
|
||
<td class="muted">${esc(r.release_type)}</td>
|
||
<td class="num">${r.year ?? ''}</td>
|
||
<td class="muted" id="release-artists-${r.id}">…</td>
|
||
${rowButtons('release', r.id)}
|
||
</tr>`);
|
||
fillSelect(document.getElementById('track-release-select'), releasesCache,
|
||
r => `${r.title}${r.year ? ' (' + r.year + ')' : ''} (#${r.id})`,
|
||
{ placeholder: '— релиз —' });
|
||
fillReleaseArtists(rows);
|
||
}
|
||
|
||
async function fillReleaseArtists(rows) {
|
||
for (const r of rows) {
|
||
try {
|
||
const detail = await api(`/releases/${r.id}`);
|
||
const cell = document.getElementById(`release-artists-${r.id}`);
|
||
if (cell) cell.textContent = detail.artists.map(a => a.name).join(', ');
|
||
} catch { /* list may have been re-rendered */ }
|
||
}
|
||
}
|
||
|
||
async function loadTracks() {
|
||
const q = document.getElementById('tracks-q').value.trim();
|
||
const rows = await api(`/tracks?limit=${LIMIT}${q ? '&q=' + encodeURIComponent(q) : ''}`);
|
||
const releaseTitles = Object.fromEntries(releasesCache.map(r => [r.id, r.title]));
|
||
renderRows('tracks-body', 'tracks-empty', rows, t => `<tr>
|
||
<td class="num">${t.id}</td>
|
||
<td>${esc(t.title)}</td>
|
||
<td class="muted">${esc(releaseTitles[t.release_id] ?? '#' + t.release_id)}</td>
|
||
<td class="num">${t.track_number ?? ''}</td>
|
||
<td class="num">${fmtDuration(t.duration_seconds)}</td>
|
||
${rowButtons('track', t.id,
|
||
`<button class="btn secondary small play-track" data-id="${t.id}" data-title="${escAttr(t.title)}">▶</button>`)}
|
||
</tr>`);
|
||
}
|
||
|
||
async function loadFiles() {
|
||
const q = document.getElementById('files-q').value.trim();
|
||
const type = document.getElementById('files-type').value;
|
||
const rows = await api(`/media-files?limit=${LIMIT}`
|
||
+ (q ? '&q=' + encodeURIComponent(q) : '')
|
||
+ (type ? '&file_type=' + type : ''));
|
||
if (!q && !type) audioFilesCache = rows.filter(f => f.file_type === 'audio');
|
||
renderRows('files-body', 'files-empty', rows, f => `<tr>
|
||
<td class="num">${f.id}</td>
|
||
<td class="muted">${esc(f.file_type)}</td>
|
||
<td>${esc(f.file_path)}</td>
|
||
<td class="muted">${esc(f.mime_type)}</td>
|
||
${rowButtons('file', f.id)}
|
||
</tr>`);
|
||
fillSelect(document.getElementById('track-audio-select'), audioFilesCache,
|
||
f => `${f.file_path} (#${f.id})`, { placeholder: '— аудиофайл —' });
|
||
}
|
||
|
||
async function refreshAll() {
|
||
try {
|
||
await loadArtists();
|
||
await loadFiles();
|
||
await loadReleases();
|
||
await loadTracks();
|
||
} catch (err) { toast(err.message); }
|
||
}
|
||
|
||
// ---------- forms ----------
|
||
function onSubmit(formId, handler) {
|
||
document.getElementById(formId).addEventListener('submit', async event => {
|
||
event.preventDefault();
|
||
const form = event.target;
|
||
try {
|
||
await handler(new FormData(form), form);
|
||
form.reset();
|
||
toast('Добавлено', true);
|
||
refreshAll();
|
||
} catch (err) { toast(err.message); }
|
||
});
|
||
}
|
||
|
||
onSubmit('artist-form', fd =>
|
||
api('/artists', { method: 'POST', body: JSON.stringify({ name: fd.get('name') }) }));
|
||
|
||
onSubmit('release-form', (fd, form) => {
|
||
const artistIds = [...form.querySelector('[name=artist_ids]').selectedOptions].map(o => +o.value);
|
||
return api('/releases', { method: 'POST', body: JSON.stringify({
|
||
title: fd.get('title'),
|
||
release_type: fd.get('release_type'),
|
||
year: fd.get('year') ? +fd.get('year') : null,
|
||
artist_ids: artistIds,
|
||
}) });
|
||
});
|
||
|
||
onSubmit('track-form', (fd, form) => {
|
||
const artists = [...form.querySelector('[name=artists]').selectedOptions]
|
||
.map(o => ({ artist_id: +o.value, role: 'main' }));
|
||
return api('/tracks', { method: 'POST', body: JSON.stringify({
|
||
title: fd.get('title'),
|
||
release_id: +fd.get('release_id'),
|
||
audio_file_id: +fd.get('audio_file_id'),
|
||
track_number: fd.get('track_number') ? +fd.get('track_number') : null,
|
||
duration_seconds: fd.get('duration_seconds') ? +fd.get('duration_seconds') : 0,
|
||
artists,
|
||
}) });
|
||
});
|
||
|
||
onSubmit('file-form', fd =>
|
||
api('/media-files', { method: 'POST', body: JSON.stringify({
|
||
file_type: fd.get('file_type'),
|
||
file_path: fd.get('file_path'),
|
||
mime_type: fd.get('mime_type') || '',
|
||
}) }));
|
||
|
||
// ---------- federation ----------
|
||
const FED_KIND_NAMES = { artist: 'артист', release: 'релиз', track: 'трек' };
|
||
|
||
function shortId(id) { return id ? id.slice(0, 12) + '…' : ''; }
|
||
|
||
function renderFedStatus(data) {
|
||
document.getElementById('fed-enabled').checked = data.settings.enabled;
|
||
const networkInput = document.getElementById('fed-network');
|
||
if (document.activeElement !== networkInput) networkInput.value = data.settings.network_id;
|
||
|
||
const el = document.getElementById('fed-status');
|
||
if (!data.node.running) {
|
||
el.innerHTML = data.settings.enabled
|
||
? 'Узел не запущен.' + (data.last_error ? ' Ошибка: ' + esc(data.last_error) : '')
|
||
: 'Федерация выключена. Включите её и укажите ID сети — все инстансы с тем же ID найдут друг друга автоматически.';
|
||
return;
|
||
}
|
||
const n = data.node;
|
||
const sync = data.last_sync
|
||
? `${data.last_sync.at} (+${data.last_sync.added} / ~${data.last_sync.updated} / −${data.last_sync.removed}, без изменений ${data.last_sync.unchanged})`
|
||
: 'ещё не было';
|
||
el.innerHTML = `
|
||
<table style="max-width:720px">
|
||
<tr><td class="muted">Сеть</td><td>${esc(n.network)}</td></tr>
|
||
<tr><td class="muted">Endpoint ID</td><td title="${esc(n.endpoint_id)}">${esc(shortId(n.endpoint_id))}</td></tr>
|
||
<tr><td class="muted">Подключено пиров</td><td>${n.connected_peers.length}${n.connected_peers.length ? ' — ' + n.connected_peers.map(shortId).map(esc).join(', ') : ''}</td></tr>
|
||
<tr><td class="muted">Известно контактов</td><td>${n.known_contacts}</td></tr>
|
||
<tr><td class="muted">Опубликовано записей</td><td>${n.published_items}</td></tr>
|
||
<tr><td class="muted">Последняя синхронизация</td><td>${esc(sync)}</td></tr>
|
||
${data.last_error ? `<tr><td class="muted">Ошибка</td><td style="color:var(--danger)">${esc(data.last_error)}</td></tr>` : ''}
|
||
</table>`;
|
||
}
|
||
|
||
async function loadFederation() {
|
||
try { renderFedStatus(await api('/federation')); }
|
||
catch (err) { document.getElementById('fed-status').textContent = err.message; }
|
||
}
|
||
|
||
document.getElementById('federation-form').addEventListener('submit', async event => {
|
||
event.preventDefault();
|
||
try {
|
||
const data = await api('/federation/settings', { method: 'PUT', body: JSON.stringify({
|
||
enabled: document.getElementById('fed-enabled').checked,
|
||
network_id: document.getElementById('fed-network').value.trim(),
|
||
}) });
|
||
renderFedStatus(data);
|
||
toast('Настройки федерации сохранены', true);
|
||
} catch (err) { toast(err.message); }
|
||
});
|
||
|
||
document.getElementById('fed-sync-now').addEventListener('click', async () => {
|
||
try {
|
||
renderFedStatus(await api('/federation/sync', { method: 'POST' }));
|
||
toast('Синхронизация выполнена', true);
|
||
} catch (err) { toast(err.message); }
|
||
});
|
||
|
||
function fedDetails(r) {
|
||
if (r.kind === 'release') return [r.release_type, r.year].filter(Boolean).join(', ');
|
||
if (r.kind === 'track') return fmtDuration(r.duration_seconds);
|
||
return '';
|
||
}
|
||
|
||
async function fedSearch() {
|
||
const q = document.getElementById('fed-q').value.trim();
|
||
if (!q) return;
|
||
const kind = document.getElementById('fed-kind').value;
|
||
const meta = document.getElementById('fed-search-meta');
|
||
meta.textContent = 'ищем…';
|
||
try {
|
||
const data = await api(`/federation/search?q=${encodeURIComponent(q)}${kind ? '&kind=' + kind : ''}`);
|
||
renderRows('fed-results-body', 'fed-results-empty', data.results, r => `<tr>
|
||
<td class="muted">${FED_KIND_NAMES[r.kind] ?? esc(r.kind)}</td>
|
||
<td>${esc(r.name)}</td>
|
||
<td class="muted">${esc((r.artist_names || []).join(', '))}</td>
|
||
<td class="num">${r.year ?? ''}</td>
|
||
<td class="muted">${esc(fedDetails(r))}</td>
|
||
<td class="muted" title="${esc(r.owner)}">${r.own ? 'вы' : esc(shortId(r.owner))}</td>
|
||
<td class="actions">${r.kind === 'track'
|
||
? `<button class="btn secondary small play-fed" data-owner="${escAttr(r.owner)}" data-item="${escAttr(r.id)}" data-own="${r.own ? 1 : 0}" data-title="${escAttr(r.name)}">▶</button>`
|
||
: ''}</td>
|
||
</tr>`);
|
||
meta.textContent = `узлов опрошено: ${data.queried_nodes}, ${data.duration_ms} мс`;
|
||
} catch (err) {
|
||
meta.textContent = '';
|
||
toast(err.message);
|
||
}
|
||
}
|
||
|
||
document.getElementById('fed-search-btn').addEventListener('click', fedSearch);
|
||
document.getElementById('fed-q').addEventListener('keydown', e => { if (e.key === 'Enter') fedSearch(); });
|
||
|
||
// Poll the status while the federation tab is visible.
|
||
let fedTimer = null;
|
||
function setFederationPolling(active) {
|
||
clearInterval(fedTimer);
|
||
fedTimer = null;
|
||
if (active) {
|
||
loadFederation();
|
||
fedTimer = setInterval(loadFederation, 5000);
|
||
}
|
||
}
|
||
|
||
// ---------- search & tabs ----------
|
||
document.getElementById('artists-q').addEventListener('input', debounce(loadArtists));
|
||
document.getElementById('releases-q').addEventListener('input', debounce(loadReleases));
|
||
document.getElementById('tracks-q').addEventListener('input', debounce(loadTracks));
|
||
document.getElementById('files-q').addEventListener('input', debounce(loadFiles));
|
||
document.getElementById('files-type').addEventListener('change', loadFiles);
|
||
|
||
document.getElementById('tabs').addEventListener('click', event => {
|
||
const button = event.target.closest('button[data-tab]');
|
||
if (!button) return;
|
||
document.querySelectorAll('#tabs button').forEach(b => b.classList.toggle('active', b === button));
|
||
document.querySelectorAll('main section').forEach(s =>
|
||
s.classList.toggle('active', s.id === 'tab-' + button.dataset.tab));
|
||
setFederationPolling(button.dataset.tab === 'federation');
|
||
});
|
||
|
||
refreshAll();
|
||
</script>
|
||
</body>
|
||
</html>
|