2026-07-16 17:22:32 +03:00
<!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 : 14 px / 1.5 - apple-system , "Segoe UI" , Roboto , sans-serif ;
}
header {
display : flex ; align-items : baseline ; gap : 16 px ;
padding : 14 px 20 px ; border-bottom : 1 px solid var ( -- border );
}
header h1 { margin : 0 ; font-size : 18 px ; }
header span { color : var ( -- muted ); font-size : 12 px ; }
nav { display : flex ; gap : 4 px ; padding : 10 px 20 px 0 ; }
nav button {
background : none ; border : 1 px solid transparent ; border-bottom : none ;
color : var ( -- muted ); padding : 8 px 16 px ; cursor : pointer ;
border-radius : 8 px 8 px 0 0 ; font-size : 14 px ;
}
nav button . active {
background : var ( -- panel ); border-color : var ( -- border ); color : var ( -- text );
}
main { padding : 16 px 20 px 40 px ; }
section { display : none ; }
section . active { display : block ; }
. toolbar { display : flex ; gap : 8 px ; margin-bottom : 12 px ; flex-wrap : wrap ; }
input , select {
background : var ( -- panel ); border : 1 px solid var ( -- border ); color : var ( -- text );
border-radius : 6 px ; padding : 7 px 10 px ; font-size : 14 px ;
}
input : focus , select : focus { outline : 1 px solid var ( -- accent ); }
button . btn {
background : var ( -- accent ); border : none ; color : #0d1117 ; font-weight : 600 ;
border-radius : 6 px ; padding : 7 px 14 px ; cursor : pointer ; font-size : 14 px ;
}
button . btn . secondary { background : var ( -- panel ); color : var ( -- text ); border : 1 px solid var ( -- border ); font-weight : 400 ; }
button . btn . danger { background : none ; color : var ( -- danger ); border : 1 px solid transparent ; padding : 3 px 8 px ; }
button . btn . danger : hover { border-color : var ( -- danger ); }
button . btn . small { padding : 3 px 8 px ; font-weight : 400 ; }
form . add {
display : flex ; gap : 8 px ; flex-wrap : wrap ; align-items : flex-end ;
background : var ( -- panel ); border : 1 px solid var ( -- border );
border-radius : 8 px ; padding : 12 px ; margin-bottom : 16 px ;
}
form . add label { display : flex ; flex-direction : column ; gap : 3 px ; font-size : 12 px ; color : var ( -- muted ); }
form . add select [ multiple ] { min-width : 160 px ; min-height : 64 px ; }
table { border-collapse : collapse ; width : 100 % ; }
th , td { text-align : left ; padding : 7 px 10 px ; border-bottom : 1 px solid var ( -- border ); }
th { color : var ( -- muted ); font-weight : 500 ; font-size : 12 px ; }
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 {
2026-07-16 18:45:39 +03:00
position : fixed ; bottom : 76 px ; left : 50 % ; transform : translateX ( -50 % );
2026-07-16 17:22:32 +03:00
background : var ( -- danger ); color : #fff ; padding : 10 px 18 px ; border-radius : 8 px ;
2026-07-16 18:45:39 +03:00
display : none ; max-width : 80 vw ; z-index : 10 ;
2026-07-16 17:22:32 +03:00
}
2026-07-16 18:45:39 +03:00
# player {
position : fixed ; bottom : 0 ; left : 0 ; right : 0 ; z-index : 9 ;
display : flex ; align-items : center ; gap : 14 px ;
padding : 8 px 20 px ; background : var ( -- panel ); border-top : 1 px solid var ( -- border );
}
# player-title { min-width : 200 px ; max-width : 40 vw ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; }
# player audio { flex : 1 ; max-width : 640 px ; height : 36 px ; }
main { padding-bottom : 90 px ; }
2026-07-16 17:22:32 +03:00
# toast . ok { background : #3f9d5f ; }
dialog {
background : var ( -- panel ); color : var ( -- text ); border : 1 px solid var ( -- border );
border-radius : 10 px ; max-width : min ( 700 px , 90 vw ); max-height : 80 vh ;
}
dialog :: backdrop { background : rgba ( 0 , 0 , 0 , .5 ); }
dialog pre { white-space : pre-wrap ; word-break : break-all ; font-size : 12 px ; }
. empty { color : var ( -- muted ); padding : 20 px 0 ; }
</ style >
</ head >
< body >
2026-07-16 18:45:39 +03:00
< header >< h1 > furumi-fd</ h1 >< span > менеджер музыкальной библиотеки (индекс + прослушивание; файлы никогда не изменяются)</ span ></ header >
2026-07-16 17:22:32 +03:00
< 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 >
2026-07-16 18:45:39 +03:00
< th > Тип</ th >< th > Название</ th >< th > Артисты</ th >< th class = "num" > Год</ th >< th > Детали</ th >< th > Владелец</ th >< th ></ th >
2026-07-16 17:22:32 +03:00
</ tr ></ thead >< tbody id = "fed-results-body" ></ tbody ></ table >
< div class = "empty" id = "fed-results-empty" hidden > Ничего не найдено</ div >
</ section >
</ main >
2026-07-16 18:45:39 +03:00
< div id = "player" hidden >
< span id = "player-title" class = "muted" ></ span >
< audio id = "player-audio" controls ></ audio >
</ div >
2026-07-16 17:22:32 +03:00
< 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 ); }
}
2026-07-16 18:45:39 +03:00
function rowButtons ( kind , id , extra = '' ) {
return `<td class="actions"> ${ extra }
2026-07-16 17:22:32 +03:00
<button class="btn secondary small" onclick="viewItem(' ${ kind } ', ${ id } )">инфо</button>
<button class="btn danger" onclick="deleteItem(' ${ kind } ', ${ id } )">удалить</button>
</td>` ;
}
2026-07-16 18:45:39 +03:00
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 } ` );
}
});
2026-07-16 17:22:32 +03:00
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>
2026-07-16 18:45:39 +03:00
${ rowButtons ( 'track' , t . id ,
`<button class="btn secondary small play-track" data-id=" ${ t . id } " data-title=" ${ escAttr ( t . title ) } ">▶</button>` ) }
2026-07-16 17:22:32 +03:00
</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>
2026-07-16 18:45:39 +03:00
<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>
2026-07-16 17:22:32 +03:00
</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 >