2026-05-26 00:19:11 +03:00
{% extends "base.html" %}
{% block title %}FuruMusic Admin v{{ version }}{% endblock title %}
{% block head_extra %}
< style >
: root {
--bg-primary : #121212 ;
--bg-secondary : #181818 ;
--bg-elevated : #232323 ;
--bg-hover : #2a2a2a ;
--bg-active : #333 ;
--text-primary : #fff ;
--text-secondary : #b3b3b3 ;
--text-subdued : #727272 ;
--accent : #1db954 ;
--accent-hover : #1ed760 ;
--blue : #5aa7ff ;
--amber : #f1b84b ;
--red : #ef6262 ;
--violet : #b98cff ;
--border-color : #2b2b2b ;
--sidebar-width : 244 px ;
}
* { box-sizing : border-box ; }
[ x-cloak ] { display : none !important ; }
html , body {
width : 100 % ;
height : 100 % ;
min-width : 1180 px ;
margin : 0 ;
overflow : hidden ;
}
body {
background : var ( -- bg - primary );
color : var ( -- text - primary );
font-family : - apple-system , BlinkMacSystemFont , "Segoe UI" , Roboto , Arial , sans-serif ;
}
button , input , select , textarea {
font : inherit ;
}
button {
border : 0 ;
}
. admin-shell {
display : grid ;
grid-template-columns : var ( -- sidebar - width ) minmax ( 0 , 1 fr );
height : 100 vh ;
height : 100 dvh ;
overflow : hidden ;
}
. sidebar {
min-width : 0 ;
border-right : 1 px solid var ( -- border - color );
background : var ( -- bg - secondary );
display : flex ;
flex-direction : column ;
}
. brand {
padding : 18 px 16 px ;
border-bottom : 1 px solid var ( -- border - color );
}
. brand-title {
display : flex ;
align-items : baseline ;
gap : 8 px ;
font-size : 18 px ;
font-weight : 800 ;
}
. version {
color : var ( -- text - subdued );
font-size : 11 px ;
font-weight : 700 ;
}
. admin-user {
margin-top : 8 px ;
color : var ( -- text - subdued );
font-size : 12 px ;
}
. nav-group {
padding : 12 px ;
}
. nav-label {
padding : 8 px 8 px 6 px ;
color : var ( -- text - subdued );
font-size : 10 px ;
font-weight : 800 ;
letter-spacing : 0 ;
text-transform : uppercase ;
}
. nav-btn {
width : 100 % ;
height : 38 px ;
padding : 0 10 px ;
border-radius : 6 px ;
background : transparent ;
color : var ( -- text - secondary );
display : grid ;
grid-template-columns : 18 px minmax ( 0 , 1 fr ) auto ;
align-items : center ;
gap : 10 px ;
cursor : pointer ;
text-align : left ;
}
. nav-btn : hover ,
. nav-btn . active {
background : var ( -- bg - hover );
color : var ( -- text - primary );
}
. nav-btn svg ,
. icon-btn svg ,
. btn svg {
width : 16 px ;
height : 16 px ;
}
. nav-count {
color : var ( -- text - subdued );
font-size : 11 px ;
}
. main {
min-width : 0 ;
min-height : 0 ;
overflow : auto ;
background : var ( -- bg - primary );
}
. topbar {
position : sticky ;
top : 0 ;
z-index : 5 ;
height : 64 px ;
padding : 12 px 18 px ;
border-bottom : 1 px solid var ( -- border - color );
background : rgba ( 18 , 18 , 18 , 0.96 );
backdrop-filter : blur ( 12 px );
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 16 px ;
}
. page-title {
min-width : 0 ;
}
. page-title h1 {
margin : 0 ;
font-size : 20 px ;
line-height : 1.1 ;
}
. page-title p {
margin : 4 px 0 0 ;
color : var ( -- text - subdued );
font-size : 12 px ;
}
. top-actions {
display : flex ;
align-items : center ;
gap : 8 px ;
}
. content {
padding : 18 px ;
}
. stats-strip {
display : grid ;
2026-06-01 14:53:51 +03:00
grid-template-columns : repeat ( auto - fit , minmax ( 132 px , 1 fr ));
2026-05-26 00:19:11 +03:00
gap : 8 px ;
margin-bottom : 14 px ;
}
. stat-cell {
min-width : 0 ;
padding : 10 px 12 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - secondary );
}
. stat-value {
font-size : 18 px ;
font-weight : 800 ;
2026-06-01 14:53:51 +03:00
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
2026-05-26 00:19:11 +03:00
}
. stat-label {
margin-top : 2 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
2026-06-01 14:53:51 +03:00
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
2026-05-26 00:19:11 +03:00
}
. panel {
min-width : 0 ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - secondary );
}
. panel-head {
min-height : 56 px ;
padding : 12 px ;
border-bottom : 1 px solid var ( -- border - color );
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 12 px ;
}
. panel-title {
min-width : 0 ;
}
. panel-title strong {
display : block ;
font-size : 14 px ;
}
. panel-title span {
display : block ;
margin-top : 2 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
}
. toolbar {
display : flex ;
align-items : center ;
gap : 8 px ;
flex-wrap : wrap ;
}
. btn ,
. icon-btn ,
. seg-btn {
height : 32 px ;
border-radius : 6 px ;
background : var ( -- bg - elevated );
color : var ( -- text - secondary );
display : inline-flex ;
align-items : center ;
justify-content : center ;
gap : 7 px ;
cursor : pointer ;
}
. btn {
padding : 0 12 px ;
font-size : 12 px ;
font-weight : 800 ;
}
. icon-btn {
width : 32 px ;
padding : 0 ;
}
. btn : hover ,
. icon-btn : hover ,
. seg-btn : hover {
background : var ( -- bg - hover );
color : var ( -- text - primary );
}
. btn . primary {
background : var ( -- accent );
color : #07120b ;
}
. btn . primary : hover {
background : var ( -- accent - hover );
}
. btn . danger {
background : rgba ( 239 , 98 , 98 , 0.18 );
color : #ffb8b8 ;
}
. btn . warn {
background : rgba ( 241 , 184 , 75 , 0.16 );
color : #ffd891 ;
}
. btn : disabled ,
. icon-btn : disabled {
opacity : 0.45 ;
cursor : not-allowed ;
}
. segmented {
display : inline-flex ;
padding : 3 px ;
border-radius : 8 px ;
background : var ( -- bg - primary );
}
. seg-btn {
min-width : 76 px ;
height : 28 px ;
padding : 0 10 px ;
background : transparent ;
font-size : 12 px ;
}
. seg-btn . active {
background : var ( -- bg - elevated );
color : var ( -- text - primary );
}
. search {
width : 260 px ;
height : 32 px ;
padding : 0 10 px ;
border : 1 px solid var ( -- border - color );
border-radius : 6 px ;
background : var ( -- bg - primary );
color : var ( -- text - primary );
outline : none ;
}
. search : focus {
border-color : var ( -- accent );
}
. action-strip {
min-height : 44 px ;
padding : 8 px 12 px ;
border-bottom : 1 px solid var ( -- border - color );
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 10 px ;
}
. selection-summary {
color : var ( -- text - subdued );
font-size : 12 px ;
}
. table-wrap {
max-height : 560 px ;
overflow : auto ;
}
. review-table-wrap {
max-height : calc ( 100 vh - 318 px );
}
table {
width : 100 % ;
border-collapse : collapse ;
table-layout : fixed ;
}
th {
position : sticky ;
top : 0 ;
z-index : 2 ;
height : 34 px ;
padding : 0 10 px ;
background : var ( -- bg - secondary );
color : var ( -- text - subdued );
border-bottom : 1 px solid var ( -- border - color );
font-size : 11 px ;
font-weight : 800 ;
text-align : left ;
text-transform : uppercase ;
}
td {
height : 54 px ;
padding : 8 px 10 px ;
border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.055 );
color : var ( -- text - secondary );
font-size : 12 px ;
vertical-align : middle ;
}
tr {
cursor : default ;
}
tbody tr : hover {
background : rgba ( 255 , 255 , 255 , 0.035 );
}
. col-check { width : 38 px ; }
. col-status { width : 112 px ; }
. col-type { width : 120 px ; }
. col-confidence { width : 88 px ; }
. col-tags { width : 250 px ; }
. col-time { width : 132 px ; }
. check {
width : 16 px ;
height : 16 px ;
accent-color : var ( -- accent );
}
. primary-line {
color : var ( -- text - primary );
font-weight : 750 ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
. secondary-line {
margin-top : 3 px ;
color : var ( -- text - subdued );
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
. badge {
display : inline-flex ;
align-items : center ;
height : 22 px ;
padding : 0 8 px ;
border-radius : 999 px ;
background : var ( -- bg - elevated );
color : var ( -- text - secondary );
font-size : 11 px ;
font-weight : 800 ;
}
. badge . queued { background : rgba ( 90 , 167 , 255 , 0.16 ); color : #9ccbff ; }
. badge . processing { background : rgba ( 185 , 140 , 255 , 0.18 ); color : #d8c2ff ; }
. badge . pending { background : rgba ( 241 , 184 , 75 , 0.18 ); color : #ffe1a6 ; }
. badge . failed ,
. badge . rejected { background : rgba ( 239 , 98 , 98 , 0.18 ); color : #ffb7b7 ; }
. badge . approved ,
. badge . auto_approved ,
. badge . completed ,
. badge . ok { background : rgba ( 29 , 185 , 84 , 0.16 ); color : #8ef0b2 ; }
. badge . running { background : rgba ( 185 , 140 , 255 , 0.18 ); color : #d8c2ff ; }
. badge . disabled { background : rgba ( 255 , 255 , 255 , 0.08 ); color : var ( -- text - subdued ); }
. tags {
display : flex ;
gap : 5 px ;
flex-wrap : wrap ;
max-height : 42 px ;
overflow : hidden ;
}
. tag {
display : inline-flex ;
align-items : center ;
height : 20 px ;
padding : 0 7 px ;
border-radius : 999 px ;
font-size : 10 px ;
font-weight : 800 ;
color : var ( -- text - primary );
background : rgba ( 255 , 255 , 255 , 0.1 );
}
. tag . format { background : rgba ( 90 , 167 , 255 , 0.22 ); color : #b8d9ff ; }
. tag . bitrate { background : rgba ( 185 , 140 , 255 , 0.22 ); color : #dfcbff ; }
. tag . sample ,
. tag . depth { background : rgba ( 29 , 185 , 84 , 0.18 ); color : #9af0b8 ; }
. tag . size { background : rgba ( 255 , 255 , 255 , 0.1 ); color : #d2d2d2 ; }
. tag . count { background : rgba ( 90 , 167 , 255 , 0.18 ); color : #b8d9ff ; }
. tag . relation { background : rgba ( 241 , 184 , 75 , 0.18 ); color : #ffe1a6 ; }
. tag . plays ,
. tag . followers { background : rgba ( 185 , 140 , 255 , 0.18 ); color : #d8c2ff ; }
. tag . visibility { background : rgba ( 29 , 185 , 84 , 0.16 ); color : #8ef0b2 ; }
2026-05-29 17:04:30 +03:00
. tag . metadata-lastfm { background : rgba ( 90 , 167 , 255 , 0.18 ); color : #c7e0ff ; }
. tag . metadata-file { background : rgba ( 29 , 185 , 84 , 0.16 ); color : #9af0b8 ; }
. tag . metadata-review { background : rgba ( 241 , 184 , 75 , 0.18 ); color : #ffe1a6 ; }
. tag . metadata-track-genre { background : rgba ( 255 , 255 , 255 , 0.1 ); color : #d2d2d2 ; }
. tag . metadata-release-lastfm { background : rgba ( 90 , 167 , 255 , 0.12 ); color : #b7d6ff ; border : 1 px solid rgba ( 90 , 167 , 255 , 0.22 ); }
. tag . metadata-release-file { background : rgba ( 29 , 185 , 84 , 0.1 ); color : #a7eabd ; border : 1 px solid rgba ( 29 , 185 , 84 , 0.2 ); }
. tag . metadata-release-review { background : rgba ( 241 , 184 , 75 , 0.12 ); color : #ffe1a6 ; border : 1 px solid rgba ( 241 , 184 , 75 , 0.22 ); }
2026-05-26 00:19:11 +03:00
. jobs-grid {
display : grid ;
grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ));
gap : 8 px ;
padding : 12 px ;
}
2026-05-29 17:04:30 +03:00
. metadata-backfill-options {
display : grid ;
gap : 8 px ;
margin-bottom : 12 px ;
padding : 10 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - primary );
}
. metadata-backfill-options . option-grid {
display : grid ;
grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ));
gap : 8 px ;
}
. metadata-backfill-options label {
display : inline-flex ;
align-items : center ;
gap : 7 px ;
min-height : 26 px ;
color : var ( -- text - secondary );
font-size : 12 px ;
font-weight : 700 ;
}
. metadata-backfill-options input {
width : auto ;
min-height : auto ;
}
. metadata-backfill-options . mode-row {
display : flex ;
gap : 14 px ;
flex-wrap : wrap ;
padding-top : 4 px ;
border-top : 1 px solid rgba ( 255 , 255 , 255 , 0.06 );
}
2026-05-26 00:19:11 +03:00
. jobs-page {
display : grid ;
grid-template-columns : minmax ( 720 px , 1 fr ) 380 px ;
grid-template-rows : minmax ( 310 px , 1 fr ) minmax ( 260 px , 0.85 fr );
gap : 14 px ;
align-items : stretch ;
height : calc ( 100 vh - 100 px );
min-height : 0 ;
}
. jobs-page . jobs-list-panel {
grid-column : 1 ;
grid-row : 1 ;
min-height : 0 ;
display : flex ;
flex-direction : column ;
overflow : hidden ;
}
. job-table-wrap {
flex : 1 ;
min-height : 0 ;
max-height : none ;
overflow : auto ;
}
. job-table {
table-layout : fixed ;
}
2026-05-29 17:04:30 +03:00
. jobs-page . jobs-list-panel . panel-head {
2026-05-26 00:19:11 +03:00
flex-shrink : 0 ;
}
2026-05-29 17:04:30 +03:00
. job-table . job-column { width : 32 % ; }
. job-table . state-column { width : 12 % ; }
. job-table . schedule-column { width : 22 % ; }
. job-table . runs-column { width : 24 % ; }
2026-05-26 00:19:11 +03:00
. job-table . actions-column { width : 10 % ; }
2026-05-29 17:04:30 +03:00
. job-table . toolbar {
flex-wrap : nowrap ;
justify-content : flex-end ;
}
2026-05-26 00:19:11 +03:00
. inline-runs {
display : flex ;
2026-05-29 17:04:30 +03:00
flex-wrap : nowrap ;
2026-05-26 00:19:11 +03:00
gap : 5 px ;
2026-05-29 17:04:30 +03:00
max-width : 100 % ;
overflow : hidden ;
2026-05-26 00:19:11 +03:00
}
. run-chip {
gap : 5 px ;
2026-05-29 17:04:30 +03:00
max-width : 70 px ;
height : 20 px ;
padding : 0 7 px ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
2026-05-26 00:19:11 +03:00
}
. task-form {
position : sticky ;
top : 82 px ;
grid-column : 2 ;
grid-row : 1 / span 2 ;
align-self : start ;
2026-05-29 17:04:30 +03:00
display : flex ;
flex-direction : column ;
max-height : calc ( 100 vh - 100 px );
overflow : hidden ;
}
. task-form-body {
min-height : 0 ;
overflow : auto ;
padding : 12 px ;
}
. job-detail-description {
margin-bottom : 10 px ;
color : var ( -- text - secondary );
font-size : 12 px ;
line-height : 1.45 ;
}
. job-facts {
display : grid ;
gap : 8 px ;
margin-bottom : 12 px ;
}
. job-fact {
display : grid ;
grid-template-columns : 58 px minmax ( 0 , 1 fr );
gap : 8 px ;
align-items : center ;
min-height : 28 px ;
padding : 6 px 8 px ;
border : 1 px solid var ( -- border - color );
border-radius : 7 px ;
background : var ( -- bg - primary );
}
. job-fact span : first-child {
color : var ( -- text - subdued );
font-size : 10 px ;
font-weight : 850 ;
text-transform : uppercase ;
}
. job-fact span : last-child {
min-width : 0 ;
color : var ( -- text - secondary );
font-size : 12 px ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
. job-param-note {
margin : -2 px 0 10 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
}
. job-run-pager {
min-height : 34 px ;
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 8 px ;
padding : 8 px 0 0 ;
}
. job-run-pager . toolbar {
flex-wrap : nowrap ;
2026-05-26 00:19:11 +03:00
}
. run-log-panel {
grid-column : 1 ;
grid-row : 2 ;
min-height : 0 ;
display : flex ;
flex-direction : column ;
overflow : hidden ;
}
. run-log-body {
flex : 1 ;
min-height : 0 ;
padding : 12 px ;
overflow : hidden ;
}
. run-log-output {
width : 100 % ;
height : 100 % ;
min-height : 210 px ;
margin : 0 ;
padding : 14 px 16 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : #0b0d0f ;
color : #dce6ef ;
font-family : ui-monospace , SFMono-Regular , Consolas , "Liberation Mono" , monospace ;
font-size : 13 px ;
line-height : 1.55 ;
white-space : pre-wrap ;
word-break : break-word ;
overflow : auto ;
tab-size : 4 ;
}
. job-card {
min-width : 0 ;
padding : 10 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - primary );
cursor : pointer ;
}
. job-card : hover ,
. job-card . active {
border-color : rgba ( 29 , 185 , 84 , 0.55 );
}
. job-head {
display : flex ;
justify-content : space-between ;
gap : 8 px ;
}
. job-name {
min-width : 0 ;
color : var ( -- text - primary );
font-size : 13 px ;
font-weight : 850 ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
. job-desc {
height : 32 px ;
margin-top : 6 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
line-height : 16 px ;
overflow : hidden ;
}
. job-meta {
display : grid ;
grid-template-columns : 1 fr 1 fr ;
gap : 6 px ;
margin-top : 8 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
}
. runs {
border-top : 1 px solid var ( -- border - color );
}
. run-row {
display : grid ;
grid-template-columns : 76 px 96 px minmax ( 0 , 1 fr ) 72 px ;
align-items : center ;
gap : 8 px ;
min-height : 42 px ;
padding : 6 px 12 px ;
border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.055 );
color : var ( -- text - secondary );
font-size : 12 px ;
cursor : pointer ;
}
. run-row : hover {
background : rgba ( 255 , 255 , 255 , 0.035 );
}
. library-shell {
display : block ;
}
2026-05-26 18:16:34 +03:00
. settings-page {
2026-05-26 18:40:05 +03:00
max-width : none ;
}
. settings-layout {
2026-05-26 18:16:34 +03:00
display : grid ;
2026-05-26 18:40:05 +03:00
grid-template-columns : minmax ( 620 px , 1 fr ) minmax ( 360 px , 440 px );
2026-05-26 18:16:34 +03:00
gap : 14 px ;
align-items : start ;
}
2026-05-26 18:40:05 +03:00
. settings-column {
display : grid ;
gap : 14 px ;
align-content : start ;
}
. settings-side . settings-grid {
grid-template-columns : minmax ( 0 , 1 fr );
}
. settings-actions {
grid-column : 1 / -1 ;
}
. settings-grid {
display : grid ;
grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ));
gap : 10 px ;
padding : 14 px ;
}
2026-05-26 18:16:34 +03:00
. settings-card {
padding : 14 px ;
}
2026-05-26 18:40:05 +03:00
. setting-field {
min-width : 0 ;
}
. setting-field label ,
. setting-toggle label {
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 8 px ;
margin-bottom : 6 px ;
color : var ( -- text - secondary );
font-size : 11 px ;
font-weight : 800 ;
text-transform : uppercase ;
}
. setting-field input {
width : 100 % ;
height : 34 px ;
padding : 0 10 px ;
border : 1 px solid var ( -- border - color );
border-radius : 6 px ;
background : var ( -- bg - primary );
color : var ( -- text - primary );
outline : none ;
}
. setting-field input : focus {
border-color : var ( -- accent );
}
. setting-toggle {
min-height : 74 px ;
padding : 12 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - primary );
}
. setting-toggle-row {
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 10 px ;
}
. setting-toggle-row span {
color : var ( -- text - primary );
font-size : 13 px ;
font-weight : 800 ;
}
. setting-toggle input {
width : 18 px ;
height : 18 px ;
accent-color : var ( -- accent );
}
. setting-help {
margin-top : 6 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
line-height : 1.4 ;
}
. source-pill {
flex : 0 0 auto ;
display : inline-flex ;
align-items : center ;
height : 18 px ;
padding : 0 6 px ;
border-radius : 999 px ;
background : rgba ( 255 , 255 , 255 , 0.08 );
color : var ( -- text - subdued );
font-size : 10 px ;
font-weight : 850 ;
text-transform : lowercase ;
}
. source-pill . env { background : rgba ( 90 , 167 , 255 , 0.16 ); color : #9ccbff ; }
. source-pill . database { background : rgba ( 29 , 185 , 84 , 0.16 ); color : #8ef0b2 ; }
. source-pill . default { background : rgba ( 255 , 255 , 255 , 0.08 ); color : var ( -- text - subdued ); }
. settings-wide {
grid-column : 1 / -1 ;
}
2026-05-26 18:16:34 +03:00
. settings-note {
padding : 14 px ;
color : var ( -- text - secondary );
font-size : 12 px ;
line-height : 1.55 ;
}
2026-05-26 18:40:05 +03:00
. probe-body {
padding : 14 px ;
}
. probe-intro {
margin : 0 0 12 px ;
color : var ( -- text - primary );
font-size : 13 px ;
line-height : 1.45 ;
}
. probe-table {
display : grid ;
gap : 7 px ;
color : var ( -- text - secondary );
font-size : 12 px ;
}
. probe-row {
display : flex ;
justify-content : space-between ;
gap : 10 px ;
}
2026-05-26 00:19:11 +03:00
. library-row {
display : grid ;
grid-template-columns : 38 px minmax ( 0 , 1 fr ) 300 px 130 px ;
align-items : center ;
gap : 10 px ;
min-height : 58 px ;
padding : 8 px 12 px ;
border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.055 );
}
. library-row : hover ,
. library-row . active {
background : rgba ( 255 , 255 , 255 , 0.035 );
}
. field {
margin-bottom : 12 px ;
}
. field label {
display : block ;
margin-bottom : 6 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
font-weight : 800 ;
text-transform : uppercase ;
}
. field input ,
2026-05-27 15:03:06 +03:00
. field textarea ,
. field select {
2026-05-26 00:19:11 +03:00
width : 100 % ;
min-height : 34 px ;
padding : 8 px 10 px ;
border : 1 px solid var ( -- border - color );
border-radius : 6 px ;
background : var ( -- bg - primary );
color : var ( -- text - primary );
resize : vertical ;
outline : none ;
}
. field textarea {
min-height : 110 px ;
font-family : ui-monospace , SFMono-Regular , Consolas , monospace ;
font-size : 11 px ;
}
2026-05-27 15:56:57 +03:00
. image-editor {
display : grid ;
grid-template-columns : 150 px minmax ( 0 , 1 fr );
gap : 14 px ;
margin-bottom : 12 px ;
}
. image-preview {
width : 150 px ;
aspect-ratio : 1 ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - primary );
display : grid ;
place-items : center ;
overflow : hidden ;
color : var ( -- text - subdued );
font-size : 12 px ;
}
. image-preview img {
width : 100 % ;
height : 100 % ;
object-fit : cover ;
}
. cover-grid {
display : grid ;
grid-template-columns : repeat ( auto - fill , minmax ( 86 px , 1 fr ));
gap : 8 px ;
}
. cover-option {
min-width : 0 ;
padding : 6 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - primary );
color : var ( -- text - secondary );
cursor : pointer ;
}
. cover-option : hover {
border-color : rgba ( 29 , 185 , 84 , 0.55 );
color : var ( -- text - primary );
}
. cover-option img {
width : 100 % ;
aspect-ratio : 1 ;
object-fit : cover ;
border-radius : 5 px ;
display : block ;
}
. cover-option span {
display : block ;
margin-top : 5 px ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
font-size : 10 px ;
}
. artist-tags {
display : flex ;
gap : 6 px ;
flex-wrap : wrap ;
margin-bottom : 8 px ;
}
2026-05-29 17:04:30 +03:00
. run-log-output . is-following {
border-color : rgba ( 29 , 185 , 84 , 0.36 );
}
. metadata-tags {
display : flex ;
flex-wrap : wrap ;
gap : 6 px ;
max-height : 112 px ;
overflow : auto ;
padding : 8 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - primary );
}
. metadata-tags . tag {
height : auto ;
min-height : 24 px ;
gap : 5 px ;
padding : 4 px 8 px ;
}
. metadata-tags small {
font-size : 9 px ;
font-weight : 800 ;
opacity : 0.72 ;
text-transform : uppercase ;
}
. metadata-empty {
min-height : 34 px ;
padding : 8 px 10 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - primary );
}
2026-05-27 15:56:57 +03:00
. artist-picker {
position : relative ;
}
. artist-picker input {
width : 100 % ;
}
. artist-results {
position : absolute ;
left : 0 ;
right : 0 ;
top : calc ( 100 % + 5 px );
z-index : 5 ;
max-height : 242 px ;
overflow : auto ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - elevated );
box-shadow : 0 14 px 34 px rgba ( 0 , 0 , 0 , 0.38 );
}
. artist-result {
width : 100 % ;
min-height : 34 px ;
padding : 8 px 10 px ;
border : 0 ;
border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.06 );
background : transparent ;
color : var ( -- text - secondary );
text-align : left ;
cursor : pointer ;
}
2026-05-29 00:43:32 +03:00
. artist-result small {
display : block ;
margin-top : 2 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
2026-05-27 15:56:57 +03:00
. artist-result : hover ,
. artist-result : focus {
background : var ( -- bg - hover );
color : var ( -- text - primary );
}
. artist-result : last-child {
border-bottom : 0 ;
}
. tag button {
width : 16 px ;
height : 16 px ;
margin-left : 5 px ;
border : 0 ;
border-radius : 999 px ;
background : rgba ( 255 , 255 , 255 , 0.14 );
color : inherit ;
cursor : pointer ;
line-height : 1 ;
}
. tag button : hover {
background : rgba ( 255 , 255 , 255 , 0.24 );
}
. editor-grid {
display : grid ;
grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ));
gap : 12 px ;
}
. image-actions {
display : flex ;
align-items : center ;
gap : 8 px ;
flex-wrap : wrap ;
margin-bottom : 10 px ;
}
. file-name {
max-width : 280 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
. cover-option . active {
border-color : rgba ( 29 , 185 , 84 , 0.8 );
box-shadow : 0 0 0 1 px rgba ( 29 , 185 , 84 , 0.32 );
}
2026-05-26 00:19:11 +03:00
. muted {
color : var ( -- text - subdued );
}
. empty {
padding : 28 px ;
color : var ( -- text - subdued );
text-align : center ;
}
. toast {
position : fixed ;
right : 18 px ;
bottom : 18 px ;
z-index : 20 ;
max-width : 420 px ;
padding : 10 px 12 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - elevated );
color : var ( -- text - primary );
box-shadow : 0 12 px 28 px rgba ( 0 , 0 , 0 , 0.35 );
font-size : 12 px ;
}
. loading-mask {
position : absolute ;
inset : 64 px 0 0 var ( -- sidebar - width );
z-index : 10 ;
display : grid ;
place-items : center ;
background : rgba ( 18 , 18 , 18 , 0.68 );
}
. modal-backdrop {
position : fixed ;
inset : 0 ;
z-index : 30 ;
display : grid ;
place-items : center ;
padding : 28 px ;
background : rgba ( 0 , 0 , 0 , 0.62 );
}
. modal {
width : min ( 760 px , calc ( 100 vw - 80 px ));
max-height : calc ( 100 vh - 80 px );
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - secondary );
box-shadow : 0 24 px 72 px rgba ( 0 , 0 , 0 , 0.56 );
overflow : hidden ;
}
. modal-head {
min-height : 58 px ;
padding : 12 px 14 px ;
border-bottom : 1 px solid var ( -- border - color );
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 12 px ;
}
. modal-body {
max-height : calc ( 100 vh - 154 px );
padding : 14 px ;
overflow : auto ;
}
</ style >
{% endblock head_extra %}
{% block content %}
< div class = "admin-shell" x-data = "adminV2()" x-init = "init()" x-cloak >
< aside class = "sidebar" >
< div class = "brand" >
< div class = "brand-title" >
< span > FuruMusic Admin</ span >
< span class = "version" > v{{ version }}</ span >
</ div >
< div class = "admin-user" > {{ user_name }} - {{ user_role }}</ div >
</ div >
< div class = "nav-group" >
< div class = "nav-label" > Operations</ div >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" :class = "{active: activeView === 'reviews'}" @ click = "openReviews()" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "inbox" ></ i >
< span > Review Queue</ span >
< span class = "nav-count" x-text = "reviews.total || 0" ></ span >
</ button >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" :class = "{active: activeView === 'jobs'}" @ click = "openJobs()" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "calendar-clock" ></ i >
< span > Tasks</ span >
< span class = "nav-count" x-text = "jobs.length || 0" ></ span >
</ button >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" :class = "{active: activeView === 'library'}" @ click = "openLibrary(libraryKind)" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "library" ></ i >
< span > Library Workbench</ span >
< span class = "nav-count" x-text = "fmt(stats.tracks || 0)" ></ span >
</ button >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" :class = "{active: activeView === 'tools'}" @ click = "openTools()" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "wrench" ></ i >
< span > Future Tools</ span >
</ button >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" :class = "{active: activeView === 'settings'}" @ click = "openSettings()" >
2026-05-26 18:16:34 +03:00
< i data-lucide = "settings" ></ i >
< span > Settings</ span >
2026-05-27 16:40:06 +03:00
< span class = "nav-count" x-text = "settings.lastfm_scrobbling_configured ? 'ok' : ''" ></ span >
2026-05-26 18:16:34 +03:00
</ button >
2026-05-26 00:19:11 +03:00
</ div >
< div class = "nav-group" >
< div class = "nav-label" > Entities</ div >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" @ click = "openLibrary('artists')" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "mic-2" ></ i >
< span > Artists</ span >
< span class = "nav-count" x-text = "fmt(libraryOverview.artists || 0)" ></ span >
</ button >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" @ click = "openLibrary('releases')" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "disc-3" ></ i >
< span > Releases</ span >
< span class = "nav-count" x-text = "fmt(libraryOverview.releases || 0)" ></ span >
</ button >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" @ click = "openLibrary('playlists')" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "list-music" ></ i >
< span > Playlists</ span >
< span class = "nav-count" x-text = "fmt(libraryOverview.playlists || 0)" ></ span >
</ button >
</ div >
</ aside >
< main class = "main" >
< div class = "topbar" >
< div class = "page-title" >
< h1 x-text = "pageTitle()" ></ h1 >
< p x-text = "pageSubtitle()" ></ p >
</ div >
< div class = "top-actions" >
< button class = "btn" @ click = "refreshAll()" >
< i data-lucide = "refresh-cw" ></ i >
Refresh
</ button >
< a class = "btn" href = "/admin/debug" >
< i data-lucide = "bug" ></ i >
Debug
</ a >
< a class = "btn" href = "/" >
< i data-lucide = "music-2" ></ i >
Player
</ a >
</ div >
</ div >
< div class = "content" x-show = "activeView === 'reviews'" >
< section class = "stats-strip" >
< template x-for = "cell in statCells()" :key = "cell.label" >
2026-06-01 14:53:51 +03:00
< div class = "stat-cell" :title = "cell.title || ''" >
< div class = "stat-value" x-text = "cell.display || fmt(cell.value)" ></ div >
2026-05-26 00:19:11 +03:00
< div class = "stat-label" x-text = "cell.label" ></ div >
</ div >
</ template >
</ section >
< div >
< section class = "panel jobs-list-panel" >
< div class = "panel-head" >
< div class = "panel-title" >
< strong > Pending Reviews</ strong >
< span x-text = "reviewPanelSubtitle()" ></ span >
</ div >
< div class = "toolbar" >
< div class = "segmented" >
< template x-for = "status in reviewStatuses" :key = "status.value" >
< button class = "seg-btn" :class = "{active: reviewFilter.status === status.value}" @ click = "setReviewStatus(status.value)" >
< span x-text = "status.label" ></ span >
< span class = "muted" x-text = "status.value ? statusCount(status.value) : reviews.total" ></ span >
</ button >
</ template >
</ div >
< input class = "search" placeholder = "Search queue" x-model = "reviewFilter.search" @ input . debounce . 350ms = "loadReviews()" />
</ div >
</ div >
< div class = "action-strip" >
< div class = "toolbar" >
< button class = "btn" @ click = "selectVisibleReviews()" >
< i data-lucide = "check-square" ></ i >
Select visible
</ button >
< button class = "btn" @ click = "selectReviewFilter()" :disabled = "reviews.total === 0" >
< i data-lucide = "list-checks" ></ i >
Select filter
</ button >
< button class = "btn" @ click = "clearReviewSelection()" :disabled = "selectedReviewCount() === 0" >
< i data-lucide = "x" ></ i >
Clear
</ button >
</ div >
< div class = "toolbar" >
< span class = "selection-summary" x-text = "reviewSelectionSummary()" ></ span >
< button class = "btn warn" @ click = "bulkReviews('requeue')" :disabled = "selectedReviewCount() === 0" >
< i data-lucide = "rotate-ccw" ></ i >
Requeue selected
</ button >
< button class = "btn danger" @ click = "bulkReviews('delete')" :disabled = "selectedReviewCount() === 0" >
< i data-lucide = "trash-2" ></ i >
Delete selected
</ button >
</ div >
</ div >
< div class = "table-wrap review-table-wrap" >
< table >
< thead >
< tr >
< th class = "col-check" ></ th >
< th class = "col-status" > Status</ th >
< th > Input</ th >
< th class = "col-type" > Type</ th >
< th class = "col-confidence" > Conf.</ th >
< th class = "col-tags" > Tags</ th >
< th class = "col-time" > Updated</ th >
</ tr >
</ thead >
< tbody >
< template x-for = "row in reviews.items" :key = "row.id" >
< tr @ click = "openReview(row)" >
< td class = "col-check" @ click . stop >
< input class = "check" type = "checkbox" :checked = "isReviewSelected(row.id)" @ change = "toggleReview(row)" />
</ td >
< td >< span class = "badge" :class = "row.status" x-text = "row.status" ></ span ></ td >
< td >
< div class = "primary-line" :title = "row.input_path" x-text = "row.filename || row.display_path" ></ div >
< div class = "secondary-line" :title = "row.input_path" x-text = "row.display_path" ></ div >
</ td >
< td >< span x-text = "row.review_type" ></ span ></ td >
< td >< span x-text = "formatConfidence(row.confidence)" ></ span ></ td >
< td >
< div class = "tags" >
< template x-for = "tag in row.tags" :key = "tag.kind + tag.label" >
< span class = "tag" :class = "tag.kind" x-text = "tag.label" ></ span >
</ template >
</ div >
</ td >
< td >< span x-text = "shortDate(row.updated_at)" ></ span ></ td >
</ tr >
</ template >
</ tbody >
</ table >
< div class = "empty" x-show = "!loading && reviews.items.length === 0" > No reviews in this filter</ div >
</ div >
< div class = "action-strip" >
< span class = "selection-summary" x-text = "reviewRangeText()" ></ span >
< div class = "toolbar" >
< select class = "search" style = "width:92px" x-model . number = "reviews.limit" @ change = "reviews.offset = 0; loadReviews(false)" >
< option value = "40" > 40</ option >
< option value = "80" > 80</ option >
< option value = "150" > 150</ option >
< option value = "250" > 250</ option >
</ select >
< button class = "btn" @ click = "pageReviews(-1)" :disabled = "reviews.offset === 0" > Previous</ button >
< button class = "btn" @ click = "pageReviews(1)" :disabled = "reviews.offset + reviews.limit >= reviews.total" > Next</ button >
</ div >
</ div >
</ section >
</ div >
</ div >
< div class = "content" x-show = "activeView === 'jobs'" >
< div class = "jobs-page" >
< section class = "panel jobs-list-panel" >
< div class = "panel-head" >
< div class = "panel-title" >
< strong > Tasks</ strong >
< span x-text = "jobPanelSubtitle()" ></ span >
</ div >
< div class = "toolbar" >
< button class = "btn" @ click = "loadJobs()" >
< i data-lucide = "refresh-cw" ></ i >
Refresh
</ button >
</ div >
</ div >
< div class = "job-table-wrap" >
< table class = "job-table" >
< thead >
< tr >
< th class = "job-column" > Task</ th >
< th class = "state-column" > State</ th >
< th class = "schedule-column" > Schedule</ th >
< th class = "runs-column" > Latest Runs</ th >
< th class = "actions-column" > Actions</ th >
</ tr >
</ thead >
< tbody >
2026-05-29 17:04:30 +03:00
< template x-for = "job in jobs" :key = "job.name" >
2026-05-26 00:19:11 +03:00
< tr :class = "{active: activeJob && activeJob.name === job.name}" @ click = "selectJob(job.name)" >
< td >
< div class = "primary-line" x-text = "job.name" ></ div >
< div class = "secondary-line" x-text = "job.description" ></ div >
</ td >
< td >< span class = "badge" :class = "job.health" x-text = "job.health" ></ span ></ td >
< td >
< div class = "primary-line" x-text = "'Next ' + relativeDate(job.next_run_at)" ></ div >
< div class = "secondary-line" x-text = "'Last ' + relativeDate(job.last_run_at)" ></ div >
</ td >
< td >
< div class = "inline-runs" >
2026-05-29 17:04:30 +03:00
< template x-for = "run in (job.recent_runs || []).slice(0, 3)" :key = "run.id" >
2026-05-26 00:19:11 +03:00
< button class = "badge run-chip" :class = "run.status" @ click . stop = "loadRunDetail(run)" :title = "runTitle(run)" >
< span x-text = "runChipLabel(run)" ></ span >
</ button >
</ template >
</ div >
</ td >
< td >
< div class = "toolbar" >
2026-05-29 17:04:30 +03:00
< button class = "icon-btn" type = "button" @ click . stop = "runJob(job)" :disabled = "Boolean(job.launching)" title = "Run now" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "play" ></ i >
</ button >
2026-05-29 17:04:30 +03:00
< button class = "icon-btn" type = "button" @ click . stop = "toggleJob(job)" :title = "job.enabled ? 'Disable' : 'Enable'" >
2026-05-26 00:19:11 +03:00
< i :data-lucide = "job.enabled ? 'pause' : 'power'" ></ i >
</ button >
</ div >
</ td >
</ tr >
</ template >
</ tbody >
</ table >
</ div >
</ section >
< section class = "panel task-form" >
< div class = "panel-head" >
< div class = "panel-title" >
< strong x-text = "activeJob ? activeJob.name : 'Task Details'" ></ strong >
< span x-text = "activeJob ? activeJob.health : 'Select a task'" ></ span >
</ div >
</ div >
2026-05-29 17:04:30 +03:00
< div class = "task-form-body" x-show = "activeJob" >
< div class = "job-detail-description" x-text = "activeJob?.description || ''" ></ div >
< div class = "job-facts" >
< div class = "job-fact" >
< span > Cron</ span >
< span :title = "activeJob?.cron_expression || ''" x-text = "cronLabel(activeJob)" ></ span >
</ div >
< div class = "job-fact" >
< span > Next</ span >
< span x-text = "relativeDate(activeJob?.next_run_at)" ></ span >
</ div >
< div class = "job-fact" >
< span > Last</ span >
< span x-text = "relativeDate(activeJob?.last_run_at)" ></ span >
</ div >
2026-05-26 00:19:11 +03:00
</ div >
2026-05-29 17:04:30 +03:00
< div class = "metadata-backfill-options" x-show = "isMetadataBackfillJob(activeJob)" >
< div class = "option-grid" >
< label >< input type = "checkbox" x-model = "metadataBackfillOptions.audio_bitrate" /> audio_bitrate</ label >
< label >< input type = "checkbox" x-model = "metadataBackfillOptions.audio_sample_rate" /> audio_sample_rate</ label >
< label >< input type = "checkbox" x-model = "metadataBackfillOptions.audio_bit_depth" /> audio_bit_depth</ label >
< label >< input type = "checkbox" x-model = "metadataBackfillOptions.duration_seconds" /> duration_seconds</ label >
< label >< input type = "checkbox" x-model = "metadataBackfillOptions.local_genres" /> local genres from files</ label >
< label >< input type = "checkbox" x-model = "metadataBackfillOptions.lastfm_tags" /> Last.fm tags</ label >
</ div >
< div class = "mode-row" >
< label >< input type = "radio" value = "fill_missing" x-model = "metadataBackfillOptions.mode" /> Fill missing only</ label >
< label >< input type = "radio" value = "overwrite" x-model = "metadataBackfillOptions.mode" /> Overwrite existing values</ label >
</ div >
2026-05-26 00:19:11 +03:00
</ div >
2026-05-29 17:04:30 +03:00
< div class = "job-param-note" x-show = "activeJob && !jobHasParameterForm(activeJob)" >
This task has no manual parameters.
2026-05-26 00:19:11 +03:00
</ div >
< div class = "toolbar" style = "margin-bottom:12px" >
2026-05-29 17:04:30 +03:00
< button class = "btn primary" type = "button" @ click = "runJob(activeJob)" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "play" ></ i >
2026-05-29 17:04:30 +03:00
< span x-text = "isMetadataBackfillJob(activeJob) ? 'Run with options' : 'Run now'" ></ span >
2026-05-26 00:19:11 +03:00
</ button >
2026-05-29 17:04:30 +03:00
< button class = "btn" type = "button" @ click = "toggleJob(activeJob)" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "power" ></ i >
Toggle
</ button >
</ div >
< div class = "field" >
< label > Recent Runs</ label >
< div class = "runs" >
2026-05-29 17:04:30 +03:00
< template x-for = "run in pagedVisibleRuns()" :key = "run.id" >
2026-05-26 00:19:11 +03:00
< div class = "run-row" @ click = "loadRunDetail(run)" >
< span class = "badge" :class = "run.status" x-text = "run.status" ></ span >
< span x-text = "'#' + run.id" ></ span >
< span class = "secondary-line" x-text = "run.error_message || run.log_excerpt || run.trigger" ></ span >
< span x-text = "duration(run.duration_ms)" ></ span >
</ div >
</ template >
2026-05-29 17:04:30 +03:00
< div class = "empty" x-show = "visibleRuns().length === 0" > No runs for this task yet</ div >
</ div >
< div class = "job-run-pager" x-show = "visibleRuns().length > recentRunsPerPage" >
< span class = "selection-summary" x-text = "recentRunsRangeText()" ></ span >
< div class = "toolbar" >
< button class = "btn" type = "button" @ click = "pageRecentRuns(-1)" :disabled = "recentRunsPage === 0" > Previous</ button >
< button class = "btn" type = "button" @ click = "pageRecentRuns(1)" :disabled = "(recentRunsPage + 1) * recentRunsPerPage >= visibleRuns().length" > Next</ button >
</ div >
2026-05-26 00:19:11 +03:00
</ div >
</ div >
</ div >
< div class = "empty" x-show = "!activeJob" > Select a task from the table</ div >
</ section >
< section class = "panel run-log-panel" >
< div class = "panel-head" >
< div class = "panel-title" >
< strong > Selected Run Log</ strong >
< span x-text = "activeRunDetail ? activeRunDetail.run.job_name + ' #' + activeRunDetail.run.id : 'Select a run chip or recent run row'" ></ span >
</ div >
< div class = "toolbar" x-show = "activeRunDetail" >
< span class = "badge" :class = "activeRunDetail?.run?.status" x-text = "activeRunDetail?.run?.status" ></ span >
< span class = "selection-summary" x-text = "duration(activeRunDetail?.run?.duration_ms)" ></ span >
2026-05-29 17:04:30 +03:00
< button class = "btn" type = "button" x-show = "!runLogAutoScroll" @ click = "enableRunLogFollow()" >
< i data-lucide = "chevrons-down" ></ i >
Follow
</ button >
2026-05-26 00:19:11 +03:00
</ div >
</ div >
< div class = "run-log-body" >
2026-05-29 17:04:30 +03:00
< pre class = "run-log-output" :class = "{'is-following': runLogAutoScroll}" x-ref = "runLogOutput" x-show = "activeRunDetail" @ scroll . passive = "handleRunLogScroll()" x-text = "activeRunDetail?.log_output || activeRunDetail?.run?.log_excerpt || 'No log output captured for this run.'" ></ pre >
2026-05-26 00:19:11 +03:00
< div class = "empty" x-show = "!activeRunDetail" > No run selected</ div >
</ div >
</ section >
</ div >
</ div >
< div class = "content" x-show = "activeView === 'library'" >
< div class = "library-shell" >
< section class = "panel" >
< div class = "panel-head" >
< div class = "panel-title" >
< strong > Library Workbench</ strong >
< span x-text = "librarySubtitle()" ></ span >
</ div >
< div class = "toolbar" >
< div class = "segmented" >
2026-05-26 18:40:05 +03:00
< button class = "seg-btn" :class = "{active: libraryKind === 'artists'}" @ click = "openLibrary('artists')" > Artists</ button >
< button class = "seg-btn" :class = "{active: libraryKind === 'releases'}" @ click = "openLibrary('releases')" > Releases</ button >
2026-05-29 00:43:32 +03:00
< button class = "seg-btn" :class = "{active: libraryKind === 'tracks'}" @ click = "openLibrary('tracks')" > Tracks</ button >
2026-05-26 18:40:05 +03:00
< button class = "seg-btn" :class = "{active: libraryKind === 'playlists'}" @ click = "openLibrary('playlists')" > Playlists</ button >
2026-05-26 00:19:11 +03:00
</ div >
< input class = "search" placeholder = "Search library" x-model = "librarySearch" @ input . debounce . 350ms = "loadLibrary()" />
</ div >
</ div >
< div class = "action-strip" >
< div class = "toolbar" >
< button class = "btn" @ click = "selectVisibleLibrary()" >
< i data-lucide = "check-square" ></ i >
Select visible
</ button >
< button class = "btn" @ click = "selectLibraryFilter()" :disabled = "library.total === 0" >
< i data-lucide = "list-checks" ></ i >
Select filter
</ button >
< button class = "btn" @ click = "clearLibrarySelection()" :disabled = "selectedLibraryCount() === 0" >
< i data-lucide = "x" ></ i >
Clear
</ button >
</ div >
< div class = "toolbar" >
< span class = "selection-summary" x-text = "librarySelectionSummary()" ></ span >
< button class = "btn" @ click = "bulkLibrary('hide')" :disabled = "selectedLibraryCount() === 0" >
< i data-lucide = "eye-off" ></ i >
Hide
</ button >
< button class = "btn" @ click = "bulkLibrary('show')" :disabled = "selectedLibraryCount() === 0" >
< i data-lucide = "eye" ></ i >
Show
</ button >
< button class = "btn" @ click = "openEditor(activeLibraryItem)" :disabled = "!activeLibraryItem" >
< i data-lucide = "square-pen" ></ i >
Edit
</ button >
< button class = "btn warn" @ click = "mockAction('Merge wizard will open from this action slot')" >
< i data-lucide = "git-merge" ></ i >
Merge
</ button >
< button class = "btn danger" @ click = "bulkLibrary('delete')" :disabled = "selectedLibraryCount() === 0" >
< i data-lucide = "trash-2" ></ i >
Delete
</ button >
</ div >
</ div >
< div >
< template x-for = "item in library.items" :key = "item.kind + item.id" >
< div class = "library-row" :class = "{active: activeLibraryItem && activeLibraryItem.id === item.id && activeLibraryItem.kind === item.kind}" @ click = "openEditor(item)" >
< input class = "check" type = "checkbox" :checked = "isLibrarySelected(item)" @ click . stop @ change = "toggleLibrary(item)" />
< div >
< div class = "primary-line" x-text = "item.title" ></ div >
< div class = "secondary-line" x-text = "item.subtitle || item.kind" ></ div >
</ div >
< div class = "tags" >
< template x-for = "tag in item.tags" :key = "tag.kind + tag.label" >
< span class = "tag" :class = "tag.kind" x-text = "tag.label" ></ span >
</ template >
</ div >
< span class = "badge" :class = "item.is_hidden ? 'disabled' : 'ok'" x-text = "item.is_hidden ? 'hidden' : 'visible'" ></ span >
</ div >
</ template >
< div class = "empty" x-show = "!libraryLoading && library.items.length === 0" > No library rows in this filter</ div >
</ div >
< div class = "action-strip" >
< span class = "selection-summary" x-text = "libraryRangeText()" ></ span >
< div class = "toolbar" >
< select class = "search" style = "width:92px" x-model . number = "library.limit" @ change = "library.offset = 0; loadLibrary(false)" >
< option value = "25" > 25</ option >
< option value = "40" > 40</ option >
< option value = "80" > 80</ option >
< option value = "120" > 120</ option >
</ select >
< button class = "btn" @ click = "pageLibrary(-1)" :disabled = "library.offset === 0" > Previous</ button >
< button class = "btn" @ click = "pageLibrary(1)" :disabled = "library.offset + library.limit >= library.total" > Next</ button >
</ div >
</ div >
</ section >
</ div >
</ div >
< div class = "content" x-show = "activeView === 'tools'" >
< section class = "panel" >
< div class = "panel-head" >
< div class = "panel-title" >
< strong > Future Tools</ strong >
< span > Reserved operation slots for complex library maintenance</ span >
</ div >
</ div >
< div style = "padding:14px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px" >
< button class = "btn" style = "height:72px" @ click = "mockAction('Artist merge wizard placeholder')" >
< i data-lucide = "git-merge" ></ i >
Artist merge
</ button >
< button class = "btn" style = "height:72px" @ click = "mockAction('Release split/move tracks placeholder')" >
< i data-lucide = "scissors" ></ i >
Split release
</ button >
< button class = "btn" style = "height:72px" @ click = "mockAction('Metadata enrichment rerun placeholder')" >
< i data-lucide = "sparkles" ></ i >
Enrich metadata
</ button >
</ div >
</ section >
</ div >
2026-05-26 18:16:34 +03:00
< div class = "content" x-show = "activeView === 'settings'" >
< div class = "settings-page" >
2026-05-26 18:40:05 +03:00
< form class = "settings-layout" @ submit . prevent = "saveSettings()" >
< div class = "settings-column" >
< section class = "panel" >
< div class = "panel-head" >
< div class = "panel-title" >
< strong > OIDC</ strong >
< span > Identity provider and group mapping</ span >
</ div >
</ div >
< div class = "settings-grid" >
< div class = "setting-field settings-wide" >
< label > Callback URL</ label >
< input readonly :value = "callbackUrl()" />
</ div >
< div class = "setting-field" >
< label >
< span > SSO button text</ span >
< span class = "source-pill" :class = "sourceClass('oidc_button_text')" x-text = "settingSource('oidc_button_text')" ></ span >
</ label >
< input x-model = "settingsDraft.oidc_button_text" />
</ div >
< div class = "setting-field" >
< label >
< span > Issuer URL</ span >
< span class = "source-pill" :class = "sourceClass('oidc_issuer')" x-text = "settingSource('oidc_issuer')" ></ span >
</ label >
< input x-model = "settingsDraft.oidc_issuer" placeholder = "https://accounts.google.com" />
</ div >
< div class = "setting-field" >
< label >
< span > Client ID</ span >
< span class = "source-pill" :class = "sourceClass('oidc_client_id')" x-text = "settingSource('oidc_client_id')" ></ span >
</ label >
< input x-model = "settingsDraft.oidc_client_id" />
</ div >
< div class = "setting-field" >
< label >
< span > Client secret</ span >
< span class = "source-pill" :class = "sourceClass('oidc_client_secret')" x-text = "settingSource('oidc_client_secret')" ></ span >
</ label >
< input type = "password" x-model = "settingsDraft.oidc_client_secret" autocomplete = "off" />
</ div >
< div class = "setting-field" >
< label >
< span > Admin groups</ span >
< span class = "source-pill" :class = "sourceClass('oidc_admin_groups')" x-text = "settingSource('oidc_admin_groups')" ></ span >
</ label >
< input x-model = "settingsDraft.oidc_admin_groups" placeholder = "/admin,/furumusic-admins" />
</ div >
< div class = "setting-field" >
< label >
< span > User groups</ span >
< span class = "source-pill" :class = "sourceClass('oidc_user_groups')" x-text = "settingSource('oidc_user_groups')" ></ span >
</ label >
< input x-model = "settingsDraft.oidc_user_groups" />
</ div >
</ div >
</ section >
< section class = "panel" >
< div class = "panel-head" >
< div class = "panel-title" >
< strong > Agent</ strong >
< span > AI processing directories, LLM endpoint, and execution limits</ span >
</ div >
</ div >
< div class = "settings-grid" >
< div class = "setting-toggle" >
< label >
< span > Agent enabled</ span >
< span class = "source-pill" :class = "sourceClass('agent_enabled')" x-text = "settingSource('agent_enabled')" ></ span >
</ label >
< div class = "setting-toggle-row" >
< span x-text = "settingsDraft.agent_enabled ? 'Enabled' : 'Disabled'" ></ span >
< input type = "checkbox" x-model = "settingsDraft.agent_enabled" />
</ div >
</ div >
< div class = "setting-field" >
< label >
< span > Concurrency</ span >
< span class = "source-pill" :class = "sourceClass('agent_concurrency')" x-text = "settingSource('agent_concurrency')" ></ span >
</ label >
< input type = "number" min = "1" max = "32" x-model = "settingsDraft.agent_concurrency" />
</ div >
< div class = "setting-field settings-wide" >
< label >
< span > Inbox directory</ span >
< span class = "source-pill" :class = "sourceClass('agent_inbox_dir')" x-text = "settingSource('agent_inbox_dir')" ></ span >
</ label >
< input x-model = "settingsDraft.agent_inbox_dir" />
</ div >
< div class = "setting-field settings-wide" >
< label >
< span > Storage directory</ span >
< span class = "source-pill" :class = "sourceClass('agent_storage_dir')" x-text = "settingSource('agent_storage_dir')" ></ span >
</ label >
< input x-model = "settingsDraft.agent_storage_dir" />
</ div >
< div class = "setting-field settings-wide" >
< label >
< span > LLM API URL</ span >
< span class = "source-pill" :class = "sourceClass('agent_llm_url')" x-text = "settingSource('agent_llm_url')" ></ span >
</ label >
< input x-model = "settingsDraft.agent_llm_url" />
</ div >
< div class = "setting-field" >
< label >
< span > LLM model</ span >
< span class = "source-pill" :class = "sourceClass('agent_llm_model')" x-text = "settingSource('agent_llm_model')" ></ span >
</ label >
< input x-model = "settingsDraft.agent_llm_model" />
</ div >
< div class = "setting-field" >
< label >
< span > LLM auth header</ span >
< span class = "source-pill" :class = "sourceClass('agent_llm_auth')" x-text = "settingSource('agent_llm_auth')" ></ span >
</ label >
< input type = "password" x-model = "settingsDraft.agent_llm_auth" autocomplete = "off" />
</ div >
< div class = "setting-field" >
< label >
< span > Confidence threshold</ span >
< span class = "source-pill" :class = "sourceClass('agent_confidence_threshold')" x-text = "settingSource('agent_confidence_threshold')" ></ span >
</ label >
< input x-model = "settingsDraft.agent_confidence_threshold" />
</ div >
< div class = "setting-field" >
< label >
< span > Context limit</ span >
< span class = "source-pill" :class = "sourceClass('agent_context_limit')" x-text = "settingSource('agent_context_limit')" ></ span >
</ label >
< input x-model = "settingsDraft.agent_context_limit" />
</ div >
</ div >
</ section >
2026-05-26 18:16:34 +03:00
</ div >
2026-05-26 18:40:05 +03:00
< div class = "settings-column settings-side" >
< section class = "panel" >
< div class = "panel-head" >
< div class = "panel-title" >
< strong > Authentication</ strong >
< span > Password and SSO access switches</ span >
</ div >
</ div >
< div class = "settings-grid" >
< div class = "setting-toggle" >
< label >
< span > Password login</ span >
< span class = "source-pill" :class = "sourceClass('auth_password_enabled')" x-text = "settingSource('auth_password_enabled')" ></ span >
</ label >
< div class = "setting-toggle-row" >
< span x-text = "settingsDraft.auth_password_enabled ? 'Enabled' : 'Disabled'" ></ span >
< input type = "checkbox" x-model = "settingsDraft.auth_password_enabled" />
</ div >
</ div >
< div class = "setting-toggle" >
< label >
< span > SSO login</ span >
< span class = "source-pill" :class = "sourceClass('auth_sso_enabled')" x-text = "settingSource('auth_sso_enabled')" ></ span >
</ label >
< div class = "setting-toggle-row" >
< span x-text = "settingsDraft.auth_sso_enabled ? 'Enabled' : 'Disabled'" ></ span >
< input type = "checkbox" x-model = "settingsDraft.auth_sso_enabled" />
</ div >
</ div >
</ div >
</ section >
< section class = "panel" >
< div class = "panel-head" >
< div class = "panel-title" >
< strong > API</ strong >
< span > Developer and enrichment integrations</ span >
</ div >
2026-05-27 16:40:06 +03:00
< span class = "badge" :class = "settings.lastfm_scrobbling_configured ? 'ok' : 'disabled'" x-text = "settings.lastfm_scrobbling_configured ? 'Last.fm configured' : 'Last.fm missing'" ></ span >
2026-05-26 18:40:05 +03:00
</ div >
< div class = "settings-grid" >
< div class = "setting-toggle" >
< label >
< span > Swagger UI</ span >
< span class = "source-pill" :class = "sourceClass('swagger_enabled')" x-text = "settingSource('swagger_enabled')" ></ span >
</ label >
< div class = "setting-toggle-row" >
< span x-text = "settingsDraft.swagger_enabled ? 'Enabled' : 'Disabled'" ></ span >
< input type = "checkbox" x-model = "settingsDraft.swagger_enabled" />
</ div >
< div class = "setting-help" > Interactive API docs at /swagger/ after restart.</ div >
</ div >
< div class = "setting-field" >
< label >
2026-05-27 16:40:06 +03:00
< span > {{ t.settings_lastfm_api_key }}</ span >
2026-05-26 18:40:05 +03:00
< span class = "source-pill" :class = "sourceClass('lastfm_api_key')" x-text = "settingSource('lastfm_api_key')" ></ span >
</ label >
< input type = "password" x-model = "settingsDraft.lastfm_api_key" autocomplete = "off" />
2026-05-27 16:40:06 +03:00
< div class = "setting-help" > {{ t.settings_lastfm_api_key_help }}</ div >
</ div >
< div class = "setting-field" >
< label >
< span > {{ t.settings_lastfm_shared_secret }}</ span >
< span class = "source-pill" :class = "sourceClass('lastfm_shared_secret')" x-text = "settingSource('lastfm_shared_secret')" ></ span >
</ label >
< input type = "password" x-model = "settingsDraft.lastfm_shared_secret" autocomplete = "off" />
< div class = "setting-help" > {{ t.settings_lastfm_shared_secret_help }}</ div >
2026-05-26 18:40:05 +03:00
</ div >
</ div >
</ section >
< section class = "panel" >
< div class = "panel-head" >
< div class = "panel-title" >
< strong > Agent Status</ strong >
< span x-text = "settingsProbeSubtitle()" ></ span >
</ div >
< span class = "badge" :class = "settingsProbeBadge()" x-text = "settingsProbe.status || 'idle'" ></ span >
</ div >
< div class = "probe-body" >
< p class = "probe-intro" x-show = "settingsProbe.model_intro" x-text = "settingsProbe.model_intro" ></ p >
< p class = "probe-intro muted" x-show = "!settingsProbe.model_intro" x-text = "settingsProbeText()" ></ p >
< div class = "probe-table" x-show = "settingsProbe.ok" >
< div class = "probe-row" >< span > Model</ span >< strong x-text = "settingsProbe.model_name || 'unknown'" ></ strong ></ div >
< div class = "probe-row" >< span > Latency</ span >< strong x-text = "settingsProbe.latency_ms + ' ms'" ></ strong ></ div >
< div class = "probe-row" >< span > Prompt tokens</ span >< strong x-text = "settingsProbe.prompt_tokens ?? '-'" ></ strong ></ div >
< div class = "probe-row" >< span > Completion tokens</ span >< strong x-text = "settingsProbe.completion_tokens ?? '-'" ></ strong ></ div >
< div class = "probe-row" >< span > Tokens/sec</ span >< strong x-text = "settingsProbe.tokens_per_sec != null ? settingsProbe.tokens_per_sec.toFixed(1) : '-'" ></ strong ></ div >
</ div >
< div class = "toolbar" style = "margin-top:14px" >
< button class = "btn" type = "button" @ click = "loadSettingsProbe()" :disabled = "settingsProbeLoading" >
< i data-lucide = "activity" ></ i >
Test agent
</ button >
</ div >
</ div >
</ section >
</ div >
< div class = "action-strip settings-actions" >
< span class = "selection-summary" > Settings are stored as database overrides unless an environment variable wins.</ span >
2026-05-26 18:16:34 +03:00
< div class = "toolbar" >
< button class = "btn" type = "button" @ click = "loadSettings()" >
< i data-lucide = "refresh-cw" ></ i >
Reload
</ button >
2026-05-26 18:40:05 +03:00
< button class = "btn primary" type = "submit" :disabled = "settingsSaving" >
< i :data-lucide = "settingsSaving ? 'loader-circle' : 'save'" ></ i >
< span x-text = "settingsSaving ? 'Saving...' : 'Save settings'" ></ span >
</ button >
2026-05-26 18:16:34 +03:00
</ div >
</ div >
2026-05-26 18:40:05 +03:00
</ form >
2026-05-26 18:16:34 +03:00
</ div >
</ div >
2026-05-26 00:19:11 +03:00
</ main >
< div class = "modal-backdrop" x-show = "reviewModalOpen && activeReview" x-transition @ click . self = "reviewModalOpen = false" >
< section class = "modal" >
< div class = "modal-head" >
< div class = "panel-title" >
< strong x-text = "activeReview ? 'Review #' + activeReview.id : 'Review'" ></ strong >
< span x-text = "activeReview?.filename || activeReview?.review_type || ''" ></ span >
</ div >
< button class = "icon-btn" @ click = "reviewModalOpen = false" >
< i data-lucide = "x" ></ i >
</ button >
</ div >
< div class = "modal-body" x-show = "activeReview" >
< div class = "field" >
< label > Input</ label >
< textarea readonly x-text = "activeReview?.input_path || ''" ></ textarea >
</ div >
< div class = "field" >
< label > Status</ label >
< span class = "badge" :class = "activeReview?.status" x-text = "activeReview?.status" ></ span >
</ div >
< div class = "field" >
< label > Agent</ label >
< input readonly :value = "activeReview?.model_name || 'not processed yet'" />
</ div >
< div class = "field" x-show = "activeReview?.error_message" >
< label > Error</ label >
< textarea readonly x-text = "activeReview?.error_message || ''" ></ textarea >
</ div >
2026-05-27 15:03:06 +03:00
< div x-show = "activeReview?.status === 'pending'" >
< div class = "field" >
< label > Artist</ label >
< input x-model = "reviewDraft.artist" />
</ div >
< div class = "field" >
< label > Album</ label >
< input x-model = "reviewDraft.album" />
</ div >
< div class = "field" >
< label > Title</ label >
< input x-model = "reviewDraft.title" />
</ div >
< div style = "display:grid; grid-template-columns: 1fr 1fr; gap: 12px;" >
< div class = "field" >
< label > Year</ label >
< input type = "number" min = "0" max = "3000" x-model = "reviewDraft.year" />
</ div >
< div class = "field" >
< label > Track</ label >
< input type = "number" min = "0" x-model = "reviewDraft.track_number" />
</ div >
</ div >
< div class = "field" >
< label > Genre</ label >
< input x-model = "reviewDraft.genre" />
</ div >
< div class = "field" >
< label > Featured artists</ label >
< input x-model = "reviewDraft.featured_artists" />
</ div >
< div class = "field" >
< label > Release type</ label >
< select x-model = "reviewDraft.release_type" >
< option value = "album" > Album</ option >
< option value = "single" > Single</ option >
< option value = "ep" > EP</ option >
< option value = "compilation" > Compilation</ option >
< option value = "soundtrack" > Soundtrack</ option >
< option value = "live" > Live</ option >
< option value = "remix" > Remix</ option >
< option value = "unknown" > Unknown</ option >
</ select >
</ div >
< div class = "field" >
< label > Notes</ label >
< textarea x-model = "reviewDraft.notes" ></ textarea >
</ div >
</ div >
2026-05-26 00:19:11 +03:00
< div class = "toolbar" >
2026-05-27 15:03:06 +03:00
< button class = "btn primary" x-show = "activeReview?.status === 'pending'" @ click = "approveActiveReview()" >
< i data-lucide = "check" ></ i >
Approve
</ button >
2026-05-26 00:19:11 +03:00
< button class = "btn warn" @ click = "bulkOneReview('requeue', activeReview)" >
< i data-lucide = "rotate-ccw" ></ i >
Requeue
</ button >
< button class = "btn danger" @ click = "bulkOneReview('delete', activeReview)" >
< i data-lucide = "trash-2" ></ i >
Delete
</ button >
</ div >
</ div >
</ section >
</ div >
< div class = "modal-backdrop" x-show = "editorOpen && activeLibraryItem" x-transition @ click . self = "editorOpen = false" >
< section class = "modal" >
< div class = "modal-head" >
< div class = "panel-title" >
< strong x-text = "activeLibraryItem?.title || 'Editor'" ></ strong >
< span x-text = "activeLibraryItem?.kind || 'Library entity'" ></ span >
</ div >
< button class = "icon-btn" @ click = "editorOpen = false" >
< i data-lucide = "x" ></ i >
</ button >
</ div >
< div class = "modal-body" x-show = "activeLibraryItem" >
2026-05-27 15:56:57 +03:00
< div class = "empty" x-show = "editorLoading" > Loading editor...</ div >
< div x-show = "!editorLoading" >
< div class = "field" >
2026-05-29 01:13:04 +03:00
< label x-text = "isArtistEditor() ? 'Artist name' : (isReleaseEditor() ? 'Release title' : (isTrackEditor() ? 'Track title' : 'Title'))" ></ label >
2026-05-27 15:56:57 +03:00
< input x-model = "editorDraft.title" />
</ div >
< div class = "editor-grid" x-show = "isReleaseEditor()" >
< div class = "field" >
< label > Release type</ label >
< select x-model = "editorDraft.release_type" >
< option value = "album" > Album</ option >
< option value = "single" > Single</ option >
< option value = "ep" > EP</ option >
< option value = "compilation" > Compilation</ option >
< option value = "soundtrack" > Soundtrack</ option >
< option value = "live" > Live</ option >
< option value = "remix" > Remix</ option >
< option value = "unknown" > Unknown</ option >
</ select >
</ div >
< div class = "field" >
< label > Year</ label >
< input type = "number" min = "0" max = "3000" x-model = "editorDraft.year" />
</ div >
</ div >
2026-05-29 00:43:32 +03:00
< div class = "editor-grid" x-show = "isTrackEditor()" >
< div class = "field" >
< label > Track #</ label >
< input type = "number" min = "1" max = "9999" x-model = "editorDraft.track_number" />
</ div >
< div class = "field" >
< label > Disc #</ label >
< input type = "number" min = "1" max = "999" x-model = "editorDraft.disc_number" />
</ div >
< div class = "field" >
< label > Year</ label >
< input type = "number" min = "0" max = "3000" x-model = "editorDraft.year" />
</ div >
</ div >
< div class = "field" x-show = "isTrackEditor()" >
< label > Release</ label >
< div class = "artist-tags" >
< span class = "tag relation" x-show = "selectedEditorRelease()" x-text = "selectedEditorReleaseLabel()" ></ span >
< span class = "muted" x-show = "!selectedEditorRelease()" > No release selected</ span >
</ div >
< div class = "artist-picker" >
< input class = "search" placeholder = "Search release to move track" x-model = "editorReleaseToAdd" @ keydown . enter . prevent = "selectEditorRelease()" @ keydown . escape = "editorReleaseToAdd = ''" />
< div class = "artist-results" x-show = "editorReleaseSearchOpen()" x-transition >
< template x-for = "release in filteredEditorReleases()" :key = "release.id" >
< button class = "artist-result" type = "button" @ click = "selectEditorRelease(release)" >
< span x-text = "release.title" ></ span >
< small x-text = "release.subtitle" ></ small >
</ button >
</ template >
< div class = "artist-result muted" x-show = "filteredEditorReleases().length === 0" > No matching releases</ div >
</ div >
</ div >
</ div >
< div class = "field" x-show = "isReleaseEditor() || isTrackEditor()" >
< label x-text = "isTrackEditor() ? 'Track artists' : 'Release artists'" ></ label >
2026-05-27 15:56:57 +03:00
< div class = "artist-tags" >
< template x-for = "artist in selectedEditorArtists()" :key = "artist.id" >
< span class = "tag relation" >
< span x-text = "artist.name" ></ span >
< button type = "button" @ click = "removeEditorArtist(artist.id)" > x</ button >
</ span >
</ template >
< span class = "muted" x-show = "selectedEditorArtists().length === 0" > No artists attached</ span >
</ div >
< div class = "artist-picker" >
< input class = "search" placeholder = "Search artist" x-model = "editorArtistToAdd" @ keydown . enter . prevent = "addEditorArtist()" @ keydown . escape = "editorArtistToAdd = ''" />
< div class = "artist-results" x-show = "editorArtistSearchOpen()" x-transition >
< template x-for = "artist in filteredEditorArtists()" :key = "artist.id" >
< button class = "artist-result" type = "button" @ click = "addEditorArtist(artist)" x-text = "artist.name" ></ button >
</ template >
< div class = "artist-result muted" x-show = "filteredEditorArtists().length === 0" > No matching artists</ div >
</ div >
</ div >
</ div >
2026-05-29 17:04:30 +03:00
< div class = "field" x-show = "canShowMetadataTags()" >
< label > Metadata genres and tags</ label >
< div class = "metadata-tags" x-show = "metadataTags().length" >
< template x-for = "tag in metadataTags()" :key = "`${tag.source}:${tag.name}`" >
< span class = "tag" :class = "metadataTagClass(tag)" :title = "metadataTagTitle(tag)" >
< span x-text = "tag.name" ></ span >
< small x-text = "metadataTagSourceLabel(tag)" ></ small >
< small x-show = "metadataTagScore(tag)" x-text = "metadataTagScore(tag)" ></ small >
</ span >
</ template >
</ div >
< div class = "metadata-empty muted" x-show = "!metadataTags().length" >
No metadata genres or tags saved yet
</ div >
</ div >
2026-05-27 15:56:57 +03:00
< div class = "field" >
< label > Visibility</ label >
< select class = "search" style = "width:100%" x-model = "editorDraft.hidden" >
< option value = "false" > Visible in player</ option >
< option value = "true" > Hidden from player</ option >
</ select >
</ div >
< div x-show = "canEditLibraryImage()" >
< label class = "muted" style = "display:block; margin: 2px 0 8px; font-size: 11px; font-weight: 800; text-transform: uppercase;" > Image</ label >
< div class = "image-editor" >
< div class = "image-preview" >
< template x-if = "editorDetail && editorDetail.current_image_url" >
< img :src = "editorDetail.current_image_url" alt = "" />
</ template >
< span x-show = "!editorDetail || !editorDetail.current_image_url" > No image</ span >
</ div >
< div >
< div class = "image-actions" >
< input type = "file" accept = "image/*" x-ref = "libraryImageInput" style = "display:none" @ change = "setEditorImageFile($event)" />
< button class = "btn" type = "button" @ click = "$refs.libraryImageInput.click()" >
< i data-lucide = "image-plus" ></ i >
Choose image
</ button >
< button class = "btn primary" type = "button" @ click = "uploadLibraryImage()" :disabled = "!editorImageFile || editorImageUploading" >
< i :data-lucide = "editorImageUploading ? 'loader-circle' : 'upload'" ></ i >
< span x-text = "editorImageUploading ? 'Uploading...' : 'Upload'" ></ span >
</ button >
< button class = "btn danger" type = "button" @ click = "removeLibraryImage()" :disabled = "!editorDetail || !editorDetail.current_image_url || editorImageUploading" >
< i data-lucide = "trash-2" ></ i >
Remove
</ button >
< span class = "file-name" x-show = "editorImageFile" x-text = "editorImageFile ? editorImageFile.name : ''" ></ span >
</ div >
< p class = "muted" x-show = "isReleaseEditor()" > Release covers are uploaded manually here.</ p >
< p class = "muted" x-show = "isArtistEditor()" > Pick an image from releases where this artist appears, or upload one manually.</ p >
</ div >
</ div >
< div x-show = "isArtistEditor()" >
< div class = "field" style = "margin-bottom:8px" >
< label > Available release images</ label >
</ div >
< div class = "cover-grid" x-show = "editorDetail && editorDetail.available_covers && editorDetail.available_covers.length" >
< template x-for = "cover in editorDetail.available_covers" :key = "cover.media_file_id" >
< button class = "cover-option" type = "button" :class = "{active: editorDetail.current_image_url && editorDetail.current_image_url.includes('/cover/' + cover.media_file_id + '/')}" @ click = "setLibraryImage(cover.media_file_id)" >
< img :src = "cover.cover_url" alt = "" />
< span x-text = "cover.release_title" ></ span >
</ button >
</ template >
</ div >
< div class = "empty" x-show = "editorDetail && (!editorDetail.available_covers || editorDetail.available_covers.length === 0)" > No release images found for this artist</ div >
</ div >
</ div >
< div class = "toolbar" >
< button class = "btn primary" @ click = "saveLibraryItem()" :disabled = "!editorCanSave()" >
< i :data-lucide = "editorSaving ? 'loader-circle' : 'save'" ></ i >
< span x-text = "editorSaving ? 'Saving...' : 'Save'" ></ span >
</ button >
< button class = "btn danger" @ click = "deleteLibraryItem(activeLibraryItem)" :disabled = "editorSaving || editorImageUploading" >
< i data-lucide = "trash-2" ></ i >
Delete
</ button >
</ div >
2026-05-26 00:19:11 +03:00
</ div >
</ div >
</ section >
</ div >
< div class = "toast" x-show = "toastMessage" x-transition x-text = "toastMessage" ></ div >
< div class = "loading-mask" x-show = "loading" >
< span class = "badge running" > Loading</ span >
</ div >
</ div >
< script defer src = "https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" ></ script >
< script defer src = "https://cdn.jsdelivr.net/npm/lucide@latest/dist/umd/lucide.min.js" ></ script >
< script >
function adminV2 () {
return {
apiBase : '/admin/v2/api' ,
activeView : 'reviews' ,
loading : true ,
libraryLoading : false ,
toastMessage : '' ,
stats : {},
2026-06-01 14:53:51 +03:00
runtime : { agent : {}, storage : [], node : {} },
2026-05-26 00:19:11 +03:00
libraryOverview : {},
reviews : { items : [], total : 0 , limit : 80 , offset : 0 , status_counts : [] },
reviewFilter : { status : null , search : '' },
selectedReviewIds : {},
reviewSelectionScope : 'ids' ,
activeReview : null ,
2026-05-27 15:03:06 +03:00
reviewDraft : {
title : '' ,
artist : '' ,
album : '' ,
year : '' ,
track_number : '' ,
genre : '' ,
featured_artists : '' ,
release_type : 'album' ,
notes : ''
},
2026-05-26 00:19:11 +03:00
reviewModalOpen : false ,
reviewStatuses : [
{ value : null , label : 'All' },
{ value : 'queued' , label : 'Queued' },
{ value : 'processing' , label : 'Processing' },
{ value : 'pending' , label : 'Pending' },
{ value : 'failed' , label : 'Failed' }
],
jobs : [],
recentRuns : [],
2026-05-29 17:24:30 +03:00
activeJobRuns : [],
activeJobRunsName : null ,
2026-05-26 00:19:11 +03:00
activeJobName : null ,
activeRunDetail : null ,
2026-05-29 17:04:30 +03:00
recentRunsPage : 0 ,
recentRunsPerPage : 8 ,
runLogAutoScroll : true ,
runLogRefreshing : false ,
metadataBackfillOptions : {
audio_bitrate : true ,
audio_sample_rate : true ,
audio_bit_depth : true ,
duration_seconds : true ,
local_genres : true ,
lastfm_tags : true ,
mode : 'fill_missing'
},
2026-05-26 00:19:11 +03:00
libraryKind : 'artists' ,
librarySearch : '' ,
library : { items : [], total : 0 , limit : 40 , offset : 0 },
selectedLibraryIds : {},
librarySelectionScope : 'ids' ,
activeLibraryItem : null ,
editorOpen : false ,
2026-05-27 15:56:57 +03:00
editorLoading : false ,
editorSaving : false ,
editorImageUploading : false ,
editorImageFile : null ,
editorArtistToAdd : '' ,
2026-05-29 00:43:32 +03:00
editorReleaseToAdd : '' ,
2026-05-27 15:56:57 +03:00
editorDetail : null ,
2026-05-29 00:43:32 +03:00
editorDraft : { title : '' , hidden : 'false' , release_type : 'album' , year : '' , release_id : null , track_number : '' , disc_number : '' , artist_ids : [] },
2026-05-27 16:40:06 +03:00
settings : { values : {}, sources : {}, lastfm_api_key_configured : false , lastfm_shared_secret_configured : false , lastfm_scrobbling_configured : false },
2026-05-26 18:40:05 +03:00
settingsDraft : {
auth_password_enabled : false ,
auth_sso_enabled : false ,
oidc_button_text : '' ,
oidc_issuer : '' ,
oidc_client_id : '' ,
oidc_client_secret : '' ,
oidc_admin_groups : '' ,
oidc_user_groups : '' ,
swagger_enabled : false ,
lastfm_api_key : '' ,
2026-05-27 16:40:06 +03:00
lastfm_shared_secret : '' ,
2026-05-26 18:40:05 +03:00
agent_enabled : false ,
agent_inbox_dir : '' ,
agent_storage_dir : '' ,
agent_llm_url : '' ,
agent_llm_model : '' ,
agent_llm_auth : '' ,
agent_confidence_threshold : '' ,
agent_context_limit : '' ,
agent_concurrency : ''
},
settingsProbe : { status : 'idle' , ok : false },
settingsProbeLoading : false ,
settingsSaving : false ,
routeReady : false ,
2026-05-26 00:19:11 +03:00
poller : null ,
async init () {
2026-05-26 18:40:05 +03:00
this . applyRouteFromHash ();
2026-05-26 00:19:11 +03:00
await this . refreshAll ();
2026-05-26 18:40:05 +03:00
this . routeReady = true ;
this . activateCurrentView ( false );
window . addEventListener ( 'hashchange' , () => {
this . applyRouteFromHash ();
this . activateCurrentView ( false );
});
2026-05-29 17:04:30 +03:00
this . poller = setInterval (() => this . poll (), 4000 );
2026-05-26 00:19:11 +03:00
this . icons ();
},
async request ( url , options = {}) {
const headers = Object . assign ({ 'Accept' : 'application/json' }, options . headers || {});
if ( options . body && ! headers [ 'Content-Type' ]) headers [ 'Content-Type' ] = 'application/json' ;
const response = await fetch ( url , Object . assign ({ credentials : 'same-origin' , headers }, options ));
if ( ! response . ok ) {
let message = response . statusText ;
try {
const body = await response . json ();
message = body . error || message ;
} catch ( _ ) {}
throw new Error ( message );
}
return response . json ();
},
async refreshAll () {
this . loading = true ;
try {
const data = await this . request ( ` ${ this . apiBase } /dashboard` );
this . stats = data . stats || {};
2026-06-01 14:53:51 +03:00
this . runtime = data . runtime || this . runtime ;
2026-05-26 00:19:11 +03:00
this . libraryOverview = data . library || {};
this . reviews = data . reviews || this . reviews ;
this . jobs = data . jobs || [];
this . recentRuns = data . recent_runs || [];
if ( ! this . activeJobName && this . jobs . length ) this . activeJobName = this . jobs [ 0 ]. name ;
2026-05-26 18:16:34 +03:00
await this . loadSettings ( false );
2026-05-26 00:19:11 +03:00
await this . loadLibrary ( false );
} catch ( error ) {
this . showToast ( error . message );
} finally {
this . loading = false ;
this . icons ();
}
},
async poll () {
if ( this . loading ) return ;
2026-05-29 17:04:30 +03:00
if ( this . activeView === 'jobs' ) {
await Promise . allSettled ([
this . loadJobs ( false ),
this . activeJobName ? this . loadRunsForJob ( this . activeJobName , false ) : Promise . resolve (),
this . refreshActiveRunDetail ()
]);
} else {
await Promise . allSettled ([ this . loadJobs ( false ), this . loadReviews ( false )]);
}
2026-05-26 00:19:11 +03:00
},
2026-05-26 18:40:05 +03:00
applyRouteFromHash () {
const raw = ( window . location . hash || '#reviews' ). replace ( /^#\/?/ , '' );
const parts = raw . split ( '/' ). filter ( Boolean );
const view = parts [ 0 ] || 'reviews' ;
if ( view === 'reviews' ) {
2026-05-27 15:56:57 +03:00
const nextStatus = parts [ 1 ] || null ;
if ( this . reviewFilter . status !== nextStatus ) this . clearReviewSelection ();
2026-05-26 18:40:05 +03:00
this . activeView = 'reviews' ;
2026-05-27 15:56:57 +03:00
this . reviewFilter . status = nextStatus ;
2026-05-26 18:40:05 +03:00
} else if ( view === 'jobs' ) {
this . activeView = 'jobs' ;
2026-05-29 17:24:30 +03:00
if ( parts [ 1 ]) {
const nextJobName = decodeURIComponent ( parts [ 1 ]);
if ( this . activeJobName !== nextJobName ) {
this . activeJobRuns = [];
this . activeJobRunsName = nextJobName ;
this . recentRunsPage = 0 ;
}
this . activeJobName = nextJobName ;
}
2026-05-26 18:40:05 +03:00
} else if ( view === 'library' ) {
2026-05-29 00:43:32 +03:00
const nextKind = [ 'artists' , 'releases' , 'tracks' , 'playlists' ]. includes ( parts [ 1 ]) ? parts [ 1 ] : 'artists' ;
2026-05-27 15:56:57 +03:00
if ( this . libraryKind !== nextKind ) this . clearLibrarySelection ();
2026-05-26 18:40:05 +03:00
this . activeView = 'library' ;
2026-05-27 15:56:57 +03:00
this . libraryKind = nextKind ;
2026-05-26 18:40:05 +03:00
} else if ( view === 'settings' ) {
this . activeView = 'settings' ;
} else if ( view === 'tools' ) {
this . activeView = 'tools' ;
} else {
this . activeView = 'reviews' ;
}
},
setRoute ( path ) {
if ( ! path . startsWith ( '#' )) path = '#' + path ;
if ( window . location . hash !== path ) {
window . history . pushState ( null , '' , path );
}
},
async activateCurrentView ( updateRoute = true ) {
if ( this . activeView === 'reviews' ) {
if ( updateRoute ) this . setRoute ( this . reviewFilter . status ? `#reviews/ ${ this . reviewFilter . status } ` : '#reviews' );
await this . loadReviews ( false );
} else if ( this . activeView === 'jobs' ) {
if ( updateRoute ) this . setRoute ( this . activeJobName ? `#jobs/ ${ encodeURIComponent ( this . activeJobName ) } ` : '#jobs' );
await this . loadJobs ();
if ( this . activeJobName ) await this . loadRunsForJob ( this . activeJobName );
} else if ( this . activeView === 'library' ) {
if ( updateRoute ) this . setRoute ( `#library/ ${ this . libraryKind } ` );
await this . loadLibrary ( false );
} else if ( this . activeView === 'settings' ) {
if ( updateRoute ) this . setRoute ( '#settings' );
await this . loadSettings ();
if ( ! this . settingsProbe . status || this . settingsProbe . status === 'idle' ) {
await this . loadSettingsProbe ( false );
}
} else if ( this . activeView === 'tools' && updateRoute ) {
this . setRoute ( '#tools' );
}
},
openReviews ( status = null ) {
2026-05-27 15:56:57 +03:00
if ( this . reviewFilter . status !== status ) this . clearReviewSelection ();
2026-05-26 18:40:05 +03:00
this . activeView = 'reviews' ;
this . reviewFilter . status = status ;
this . setRoute ( status ? `#reviews/ ${ status } ` : '#reviews' );
this . loadReviews ();
},
openJobs ( name = this . activeJobName ) {
this . activeView = 'jobs' ;
2026-05-29 17:24:30 +03:00
if ( name && this . activeJobName !== name ) {
this . activeJobRuns = [];
this . activeJobRunsName = name ;
this . recentRunsPage = 0 ;
this . activeJobName = name ;
} else if ( name ) {
this . activeJobName = name ;
}
2026-05-26 18:40:05 +03:00
this . setRoute ( this . activeJobName ? `#jobs/ ${ encodeURIComponent ( this . activeJobName ) } ` : '#jobs' );
this . loadJobs ();
if ( this . activeJobName ) this . loadRunsForJob ( this . activeJobName );
},
openLibrary ( kind = this . libraryKind ) {
this . activeView = 'library' ;
2026-05-29 00:43:32 +03:00
const nextKind = [ 'artists' , 'releases' , 'tracks' , 'playlists' ]. includes ( kind ) ? kind : 'artists' ;
2026-05-27 15:56:57 +03:00
if ( this . libraryKind !== nextKind ) this . clearLibrarySelection ();
this . libraryKind = nextKind ;
2026-05-26 18:40:05 +03:00
this . setRoute ( `#library/ ${ this . libraryKind } ` );
this . loadLibrary ();
},
openTools () {
this . activeView = 'tools' ;
this . setRoute ( '#tools' );
},
2026-05-26 00:19:11 +03:00
async loadReviews ( resetOffset = true ) {
if ( resetOffset ) this . reviews . offset = 0 ;
const params = new URLSearchParams ();
if ( this . reviewFilter . status ) params . set ( 'status' , this . reviewFilter . status );
if ( this . reviewFilter . search ) params . set ( 'search' , this . reviewFilter . search );
params . set ( 'limit' , this . reviews . limit || 80 );
params . set ( 'offset' , this . reviews . offset || 0 );
try {
this . reviews = await this . request ( ` ${ this . apiBase } /reviews? ${ params . toString () } ` );
2026-05-27 15:56:57 +03:00
if ( resetOffset ) this . clearReviewSelection ();
2026-05-26 00:19:11 +03:00
} catch ( error ) {
this . showToast ( error . message );
} finally {
this . icons ();
}
},
async loadJobs ( showErrors = true ) {
try {
this . jobs = await this . request ( ` ${ this . apiBase } /jobs` );
if ( ! this . activeJobName && this . jobs . length ) this . activeJobName = this . jobs [ 0 ]. name ;
} catch ( error ) {
if ( showErrors ) this . showToast ( error . message );
} finally {
this . icons ();
}
},
async loadLibrary ( resetOffset = true ) {
if ( this . activeView !== 'library' && resetOffset ) return ;
if ( resetOffset ) this . library . offset = 0 ;
this . libraryLoading = true ;
const params = new URLSearchParams ();
params . set ( 'kind' , this . libraryKind );
params . set ( 'limit' , this . library . limit || 40 );
params . set ( 'offset' , this . library . offset || 0 );
if ( this . librarySearch ) params . set ( 'search' , this . librarySearch );
try {
this . library = await this . request ( ` ${ this . apiBase } /library? ${ params . toString () } ` );
2026-05-27 15:56:57 +03:00
if ( resetOffset ) this . clearLibrarySelection ();
2026-05-26 00:19:11 +03:00
} catch ( error ) {
this . showToast ( error . message );
} finally {
this . libraryLoading = false ;
this . icons ();
}
},
2026-05-26 18:16:34 +03:00
async loadSettings ( showErrors = true ) {
try {
this . settings = await this . request ( ` ${ this . apiBase } /settings` );
2026-05-26 18:40:05 +03:00
this . settingsDraft = Object . assign ({}, this . settingsDraft , this . settings . values || {});
2026-05-26 18:16:34 +03:00
} catch ( error ) {
if ( showErrors ) this . showToast ( error . message );
} finally {
this . icons ();
}
},
async saveSettings () {
2026-05-26 18:40:05 +03:00
if ( this . settingsSaving ) return ;
this . settingsSaving = true ;
2026-05-26 18:16:34 +03:00
try {
await this . request ( ` ${ this . apiBase } /settings` , {
method : 'POST' ,
2026-05-26 18:40:05 +03:00
body : JSON . stringify ( this . settingsDraft )
2026-05-26 18:16:34 +03:00
});
await this . loadSettings ( false );
this . showToast ( 'Settings saved' );
} catch ( error ) {
this . showToast ( error . message );
2026-05-26 18:40:05 +03:00
} finally {
this . settingsSaving = false ;
this . icons ();
}
},
async openSettings () {
this . activeView = 'settings' ;
this . setRoute ( '#settings' );
await this . loadSettings ();
if ( ! this . settingsProbe . status || this . settingsProbe . status === 'idle' ) {
await this . loadSettingsProbe ( false );
2026-05-26 18:16:34 +03:00
}
},
2026-05-26 18:40:05 +03:00
async loadSettingsProbe ( showErrors = true ) {
this . settingsProbeLoading = true ;
try {
this . settingsProbe = await this . request ( ` ${ this . apiBase } /settings/probe` );
} catch ( error ) {
this . settingsProbe = { status : 'error' , ok : false , error : error . message };
if ( showErrors ) this . showToast ( error . message );
} finally {
this . settingsProbeLoading = false ;
this . icons ();
}
},
settingSource ( key ) {
return ( this . settings . sources || {})[ key ] || 'default' ;
},
sourceClass ( key ) {
return this . settingSource ( key );
},
callbackUrl () {
return ` ${ window . location . origin } /auth/oidc/callback` ;
},
settingsProbeBadge () {
if ( this . settingsProbeLoading ) return 'running' ;
if ( this . settingsProbe . status === 'ok' ) return 'ok' ;
if ( this . settingsProbe . status === 'error' ) return 'failed' ;
return 'disabled' ;
},
settingsProbeSubtitle () {
if ( this . settingsProbeLoading ) return 'Checking LLM connection' ;
if ( this . settingsProbe . status === 'ok' ) return 'LLM connection OK' ;
if ( this . settingsProbe . status === 'error' ) return 'LLM connection error' ;
if ( this . settingsProbe . status === 'disabled' ) return 'Agent is disabled' ;
if ( this . settingsProbe . status === 'not_configured' ) return 'LLM URL is not configured' ;
return 'Connection probe' ;
},
settingsProbeText () {
if ( this . settingsProbeLoading ) return 'Checking connection...' ;
if ( this . settingsProbe . error ) return this . settingsProbe . error ;
return this . settingsProbeSubtitle ();
},
2026-05-26 00:19:11 +03:00
setReviewStatus ( status ) {
2026-05-26 18:40:05 +03:00
this . openReviews ( status );
2026-05-26 00:19:11 +03:00
},
openReview ( row ) {
this . activeReview = row ;
2026-05-27 15:03:06 +03:00
this . reviewDraft = Object . assign ({
title : '' ,
artist : '' ,
album : '' ,
year : '' ,
track_number : '' ,
genre : '' ,
featured_artists : '' ,
release_type : 'album' ,
notes : ''
}, row . normalized || {});
2026-05-26 00:19:11 +03:00
this . activeRunDetail = null ;
this . reviewModalOpen = true ;
},
toggleReview ( row ) {
this . reviewSelectionScope = 'ids' ;
if ( this . selectedReviewIds [ row . id ]) {
delete this . selectedReviewIds [ row . id ];
} else {
this . selectedReviewIds [ row . id ] = row . status ;
}
this . selectedReviewIds = Object . assign ({}, this . selectedReviewIds );
},
isReviewSelected ( id ) {
return this . reviewSelectionScope === 'filter' || Boolean ( this . selectedReviewIds [ id ]);
},
selectVisibleReviews () {
this . reviewSelectionScope = 'ids' ;
const selected = {};
for ( const row of this . reviews . items ) selected [ row . id ] = row . status ;
this . selectedReviewIds = selected ;
},
selectReviewFilter () {
this . reviewSelectionScope = 'filter' ;
this . selectedReviewIds = {};
},
clearReviewSelection () {
this . reviewSelectionScope = 'ids' ;
this . selectedReviewIds = {};
},
selectedReviewCount () {
if ( this . reviewSelectionScope === 'filter' ) return this . reviews . total || 0 ;
return Object . keys ( this . selectedReviewIds ). length ;
},
reviewSelectionSummary () {
const total = this . selectedReviewCount ();
if ( ! total ) return 'Nothing selected' ;
const counts = this . reviewSelectionScope === 'filter'
? this . filterStatusCounts ()
: Object . values ( this . selectedReviewIds ). reduce (( acc , status ) => {
acc [ status ] = ( acc [ status ] || 0 ) + 1 ;
return acc ;
}, {});
const parts = Object . entries ( counts ). filter (([, count ]) => count > 0 ). map (([ status , count ]) => ` ${ status } : ${ count } ` );
return ` ${ total } selected · ${ parts . join ( ' · ' ) } ` ;
},
filterStatusCounts () {
if ( this . reviewFilter . status ) return { [ this . reviewFilter . status ] : this . reviews . total || 0 };
return ( this . reviews . status_counts || []). reduce (( acc , row ) => {
acc [ row . status ] = row . count ;
return acc ;
}, {});
},
async bulkReviews ( action ) {
const count = this . selectedReviewCount ();
if ( ! count ) return ;
if ( action === 'delete' && ! confirm ( `Delete ${ count } review row(s)?` )) return ;
const payload = {
action ,
mode : this . reviewSelectionScope ,
ids : Object . keys ( this . selectedReviewIds ). map ( Number ),
filter : {
status : this . reviewFilter . status ,
search : this . reviewFilter . search || null
}
};
try {
const result = await this . request ( ` ${ this . apiBase } /reviews/bulk` , {
method : 'POST' ,
body : JSON . stringify ( payload )
});
this . showToast ( ` ${ result . affected } review row(s) updated` );
2026-05-27 15:56:57 +03:00
this . clearReviewSelection ();
2026-05-26 00:19:11 +03:00
await this . loadReviews ( false );
} catch ( error ) {
this . showToast ( error . message );
}
},
async bulkOneReview ( action , row ) {
this . reviewSelectionScope = 'ids' ;
this . selectedReviewIds = { [ row . id ] : row . status };
await this . bulkReviews ( action );
this . reviewModalOpen = false ;
},
2026-05-27 15:03:06 +03:00
async approveActiveReview () {
if ( ! this . activeReview ) return ;
try {
await this . request ( ` ${ this . apiBase } /reviews/ ${ this . activeReview . id } /approve` , {
method : 'POST' ,
body : JSON . stringify ( this . reviewDraft )
});
this . showToast ( 'Review approved' );
this . reviewModalOpen = false ;
this . activeReview = null ;
await this . loadReviews ( false );
await this . loadLibrary ( false );
} catch ( error ) {
this . showToast ( error . message );
}
},
2026-05-26 00:19:11 +03:00
async runJob ( job ) {
2026-05-29 17:04:30 +03:00
if ( ! job ) return ;
if ( job . launching ) return ;
2026-05-26 00:19:11 +03:00
job . launching = true ;
try {
2026-05-29 17:04:30 +03:00
const result = this . isMetadataBackfillJob ( job )
? await this . request ( ` ${ this . apiBase } /jobs/metadata_backfill/run-options` , {
method : 'POST' ,
body : JSON . stringify ( this . metadataBackfillPayload ())
})
: await this . request ( ` ${ this . apiBase } /jobs/ ${ encodeURIComponent ( job . name ) } /run` , { method : 'POST' });
2026-05-26 00:19:11 +03:00
this . showToast ( `Run # ${ result . run_id } started` );
this . activeJobName = job . name ;
2026-05-29 17:04:30 +03:00
this . recentRunsPage = 0 ;
2026-05-26 00:19:11 +03:00
await this . loadJobs ( false );
await this . loadRunsForJob ( job . name );
2026-05-29 17:04:30 +03:00
await this . loadRunDetail ({ id : result . run_id , job_name : job . name }, { silent : true });
2026-05-26 00:19:11 +03:00
} catch ( error ) {
this . showToast ( error . message );
} finally {
job . launching = false ;
this . icons ();
}
},
2026-05-29 17:04:30 +03:00
isMetadataBackfillJob ( job ) {
return job && job . name === 'metadata_backfill' ;
},
jobHasParameterForm ( job ) {
return this . isMetadataBackfillJob ( job );
},
metadataBackfillPayload () {
const options = this . metadataBackfillOptions || {};
return {
audio_bitrate : Boolean ( options . audio_bitrate ),
audio_sample_rate : Boolean ( options . audio_sample_rate ),
audio_bit_depth : Boolean ( options . audio_bit_depth ),
duration_seconds : Boolean ( options . duration_seconds ),
local_genres : Boolean ( options . local_genres ),
lastfm_tags : Boolean ( options . lastfm_tags ),
overwrite : options . mode === 'overwrite'
};
},
2026-05-26 00:19:11 +03:00
async toggleJob ( job ) {
try {
const result = await this . request ( ` ${ this . apiBase } /jobs/ ${ encodeURIComponent ( job . name ) } /toggle` , { method : 'POST' });
job . enabled = result . enabled ;
await this . loadJobs ( false );
} catch ( error ) {
this . showToast ( error . message );
}
},
async selectJob ( name ) {
this . activeJobName = name ;
2026-05-26 18:40:05 +03:00
this . setRoute ( `#jobs/ ${ encodeURIComponent ( name ) } ` );
2026-05-26 00:19:11 +03:00
this . activeReview = null ;
this . activeRunDetail = null ;
2026-05-29 17:24:30 +03:00
this . activeJobRuns = [];
this . activeJobRunsName = name ;
2026-05-29 17:04:30 +03:00
this . recentRunsPage = 0 ;
this . runLogAutoScroll = true ;
2026-05-26 00:19:11 +03:00
await this . loadRunsForJob ( name );
},
2026-05-29 17:04:30 +03:00
async loadRunsForJob ( name , showErrors = true ) {
2026-05-26 00:19:11 +03:00
try {
const data = await this . request ( ` ${ this . apiBase } /jobs/ ${ encodeURIComponent ( name ) } /runs` );
2026-05-29 17:24:30 +03:00
const runs = data . runs || [];
2026-05-26 00:19:11 +03:00
const job = this . jobs . find ( item => item . name === name );
2026-05-29 17:24:30 +03:00
if ( job ) job . recent_runs = runs ;
if ( this . activeJobName === name ) {
this . activeJobRuns = runs ;
this . activeJobRunsName = name ;
const maxPage = Math . max ( 0 , Math . ceil ( runs . length / this . recentRunsPerPage ) - 1 );
this . recentRunsPage = Math . min ( this . recentRunsPage , maxPage );
}
2026-05-26 00:19:11 +03:00
} catch ( error ) {
2026-05-29 17:04:30 +03:00
if ( showErrors ) this . showToast ( error . message );
2026-05-26 00:19:11 +03:00
}
},
2026-05-29 17:04:30 +03:00
async loadRunDetail ( run , options = {}) {
if ( ! run ) return ;
2026-05-26 00:19:11 +03:00
this . activeReview = null ;
2026-05-29 17:04:30 +03:00
const previousJobName = this . activeJobName ;
const preserveScroll = Boolean ( options . preserveScroll );
const sameRun = this . activeRunDetail && Number ( this . activeRunDetail . run . id ) === Number ( run . id );
const shouldFollow = ! preserveScroll || ! sameRun || this . runLogAutoScroll || this . isRunLogAtBottom ();
2026-05-26 00:19:11 +03:00
try {
this . activeRunDetail = await this . request ( ` ${ this . apiBase } /jobs/ ${ encodeURIComponent ( run . job_name ) } /runs/ ${ run . id } ` );
this . activeJobName = run . job_name ;
2026-05-29 17:04:30 +03:00
if ( previousJobName !== run . job_name ) this . recentRunsPage = 0 ;
if ( ! preserveScroll || ! sameRun ) this . runLogAutoScroll = true ;
this . $nextTick (() => {
if ( shouldFollow ) this . scrollRunLogToBottom ( true );
});
2026-05-26 00:19:11 +03:00
} catch ( error ) {
2026-05-29 17:04:30 +03:00
if ( ! options . silent ) this . showToast ( error . message );
}
},
async refreshActiveRunDetail () {
if ( this . runLogRefreshing || ! this . activeRunDetail || this . activeView !== 'jobs' ) return ;
this . runLogRefreshing = true ;
try {
await this . loadRunDetail ( this . activeRunDetail . run , { preserveScroll : true , silent : true });
} finally {
this . runLogRefreshing = false ;
}
},
handleRunLogScroll () {
this . runLogAutoScroll = this . isRunLogAtBottom ();
},
isRunLogAtBottom () {
const el = this . $refs . runLogOutput ;
if ( ! el ) return true ;
return el . scrollHeight - el . scrollTop - el . clientHeight < 28 ;
},
scrollRunLogToBottom ( force = false ) {
const el = this . $refs . runLogOutput ;
if ( ! el ) return ;
if ( force || this . runLogAutoScroll ) {
el . scrollTop = el . scrollHeight ;
this . runLogAutoScroll = true ;
2026-05-26 00:19:11 +03:00
}
},
2026-05-29 17:04:30 +03:00
enableRunLogFollow () {
this . runLogAutoScroll = true ;
this . $nextTick (() => this . scrollRunLogToBottom ( true ));
},
2026-05-26 00:19:11 +03:00
visibleRuns () {
2026-05-29 17:24:30 +03:00
if ( this . activeJobName && this . activeJobRunsName === this . activeJobName ) {
return this . activeJobRuns || [];
}
2026-05-26 00:19:11 +03:00
const job = this . activeJob ;
return job ? ( job . recent_runs || []) : this . recentRuns ;
},
2026-05-29 17:04:30 +03:00
pagedVisibleRuns () {
const runs = this . visibleRuns ();
const start = this . recentRunsPage * this . recentRunsPerPage ;
return runs . slice ( start , start + this . recentRunsPerPage );
},
pageRecentRuns ( delta ) {
const maxPage = Math . max ( 0 , Math . ceil ( this . visibleRuns (). length / this . recentRunsPerPage ) - 1 );
this . recentRunsPage = Math . min ( maxPage , Math . max ( 0 , this . recentRunsPage + delta ));
},
recentRunsRangeText () {
const total = this . visibleRuns (). length ;
if ( ! total ) return '0 runs' ;
const start = this . recentRunsPage * this . recentRunsPerPage + 1 ;
const end = Math . min (( this . recentRunsPage + 1 ) * this . recentRunsPerPage , total );
return ` ${ start } - ${ end } of ${ total } ` ;
},
2026-05-26 00:19:11 +03:00
get activeJob () {
return this . jobs . find ( job => job . name === this . activeJobName ) || null ;
},
selectVisibleLibrary () {
this . librarySelectionScope = 'ids' ;
const selected = {};
for ( const item of this . library . items ) selected [ ` ${ item . kind } : ${ item . id } ` ] = true ;
this . selectedLibraryIds = selected ;
},
selectLibraryFilter () {
this . librarySelectionScope = 'filter' ;
this . selectedLibraryIds = {};
},
clearLibrarySelection () {
this . librarySelectionScope = 'ids' ;
this . selectedLibraryIds = {};
},
openEditor ( item ) {
if ( ! item ) return ;
this . activeLibraryItem = item ;
this . editorDraft = {
title : item . title || '' ,
2026-05-27 15:56:57 +03:00
hidden : item . is_hidden ? 'true' : 'false' ,
release_type : 'album' ,
year : '' ,
2026-05-29 00:43:32 +03:00
release_id : null ,
track_number : '' ,
disc_number : '' ,
2026-05-27 15:56:57 +03:00
artist_ids : []
2026-05-26 00:19:11 +03:00
};
2026-05-27 15:56:57 +03:00
this . editorDetail = null ;
this . editorImageFile = null ;
this . editorArtistToAdd = '' ;
2026-05-29 00:43:32 +03:00
this . editorReleaseToAdd = '' ;
2026-05-26 00:19:11 +03:00
this . editorOpen = true ;
2026-05-27 15:56:57 +03:00
this . loadEditorDetail ( item );
},
async loadEditorDetail ( item ) {
const key = ` ${ item . kind } : ${ item . id } ` ;
this . editorLoading = true ;
try {
const params = new URLSearchParams ({ kind : item . kind , id : item . id });
const detail = await this . request ( ` ${ this . apiBase } /library/item/detail? ${ params . toString () } ` );
if ( ! this . activeLibraryItem || ` ${ this . activeLibraryItem . kind } : ${ this . activeLibraryItem . id } ` !== key ) return ;
this . editorDetail = detail ;
this . editorDraft = {
title : detail . title || '' ,
hidden : detail . hidden ? 'true' : 'false' ,
release_type : detail . release_type || 'album' ,
year : detail . year || '' ,
2026-05-29 00:43:32 +03:00
release_id : detail . release_id || null ,
track_number : detail . track_number || '' ,
disc_number : detail . disc_number || '' ,
2026-05-27 15:56:57 +03:00
artist_ids : Array . isArray ( detail . selected_artist_ids ) ? detail . selected_artist_ids . slice () : []
};
this . editorImageFile = null ;
this . editorArtistToAdd = '' ;
2026-05-29 00:43:32 +03:00
this . editorReleaseToAdd = '' ;
2026-05-27 15:56:57 +03:00
} catch ( error ) {
this . showToast ( error . message );
} finally {
if ( this . activeLibraryItem && ` ${ this . activeLibraryItem . kind } : ${ this . activeLibraryItem . id } ` === key ) {
this . editorLoading = false ;
}
this . icons ();
}
},
isArtistEditor () {
return this . activeLibraryItem && this . activeLibraryItem . kind === 'artists' ;
},
isReleaseEditor () {
return this . activeLibraryItem && this . activeLibraryItem . kind === 'releases' ;
},
2026-05-29 00:43:32 +03:00
isTrackEditor () {
return this . activeLibraryItem && this . activeLibraryItem . kind === 'tracks' ;
},
2026-05-27 15:56:57 +03:00
canEditLibraryImage () {
return this . isArtistEditor () || this . isReleaseEditor ();
},
2026-05-29 17:04:30 +03:00
canShowMetadataTags () {
return this . isArtistEditor () || this . isReleaseEditor () || this . isTrackEditor ();
},
metadataTags () {
return this . editorDetail && Array . isArray ( this . editorDetail . metadata_tags )
? this . editorDetail . metadata_tags
: [];
},
metadataTagClass ( tag ) {
const source = String (( tag && tag . source ) || 'unknown' ). toLowerCase (). replace ( /_/g , '-' );
return `metadata- ${ source } ` ;
},
metadataTagSourceLabel ( tag ) {
const source = String (( tag && tag . source ) || '' ). toLowerCase ();
if ( source === 'lastfm' ) return 'Last.fm' ;
if ( source === 'file' ) return 'File' ;
if ( source === 'review' ) return 'Review' ;
if ( source === 'track_genre' ) return 'Track genre' ;
if ( source === 'release_lastfm' ) return 'Release Last.fm' ;
if ( source === 'release_file' ) return 'Release file' ;
if ( source === 'release_review' ) return 'Release review' ;
return source || 'Source' ;
},
metadataTagScore ( tag ) {
const source = String (( tag && tag . source ) || '' ). toLowerCase ();
const weight = Number (( tag && tag . weight ) || 0 );
return ( source === 'lastfm' || source === 'release_lastfm' ) && weight > 1 ? String ( Math . round ( weight )) : '' ;
},
metadataTagTitle ( tag ) {
if ( ! tag ) return '' ;
const parts = [ this . metadataTagSourceLabel ( tag )];
const weight = Number ( tag . weight || 0 );
if ( weight > 0 ) parts . push ( `weight ${ Math . round ( weight ) } ` );
if ( tag . updated_at ) parts . push ( tag . updated_at );
return parts . join ( ' / ' );
},
2026-05-27 15:56:57 +03:00
editorCanSave () {
2026-05-29 00:43:32 +03:00
if ( ! this . activeLibraryItem || ! this . editorDetail || this . editorLoading || this . editorSaving ) return false ;
if ( this . isTrackEditor () && ! this . editorDraft . release_id ) return false ;
return true ;
2026-05-27 15:56:57 +03:00
},
selectedEditorArtists () {
const selected = this . editorDraft . artist_ids || [];
const artists = ( this . editorDetail && this . editorDetail . artists ) || [];
return selected . map ( id => {
const artist = artists . find ( row => Number ( row . id ) === Number ( id ));
return artist || { id , name : `Artist # ${ id } ` };
});
},
editorAvailableArtists () {
const selected = new Set (( this . editorDraft . artist_ids || []). map ( id => Number ( id )));
const artists = ( this . editorDetail && this . editorDetail . artists ) || [];
return artists . filter ( artist => ! selected . has ( Number ( artist . id )));
},
normalizedArtistSearch ( value ) {
return String ( value || '' ). trim (). toLowerCase ();
},
filteredEditorArtists () {
const raw = String ( this . editorArtistToAdd || '' ). trim ();
const query = this . normalizedArtistSearch ( raw );
const candidates = this . editorAvailableArtists ();
if ( ! query ) return candidates . slice ( 0 , 12 );
return candidates
. map ( artist => {
const name = this . normalizedArtistSearch ( artist . name );
let score = 3 ;
if ( name === query ) score = 0 ;
else if ( name . startsWith ( query )) score = 1 ;
else if ( name . includes ( query )) score = 2 ;
return { artist , score };
})
. filter ( row => row . score < 3 )
. sort (( a , b ) => a . score - b . score || a . artist . name . localeCompare ( b . artist . name ))
. slice ( 0 , 12 )
. map ( row => row . artist );
},
editorArtistSearchOpen () {
2026-05-29 00:43:32 +03:00
return ( this . isReleaseEditor () || this . isTrackEditor ()) && String ( this . editorArtistToAdd || '' ). trim (). length > 0 ;
2026-05-27 15:56:57 +03:00
},
addEditorArtist ( artist = null ) {
const raw = String ( this . editorArtistToAdd || '' ). trim ();
const candidates = this . filteredEditorArtists ();
artist = artist
|| candidates . find ( row => this . normalizedArtistSearch ( row . name ) === this . normalizedArtistSearch ( raw ))
|| candidates [ 0 ];
if ( artist && ! ( this . editorDraft . artist_ids || []). map ( Number ). includes ( Number ( artist . id ))) {
this . editorDraft . artist_ids = ( this . editorDraft . artist_ids || []). concat ([ Number ( artist . id )]);
}
this . editorArtistToAdd = '' ;
},
removeEditorArtist ( id ) {
this . editorDraft . artist_ids = ( this . editorDraft . artist_ids || []). filter ( value => Number ( value ) !== Number ( id ));
},
2026-05-29 00:43:32 +03:00
selectedEditorRelease () {
const releases = ( this . editorDetail && this . editorDetail . releases ) || [];
return releases . find ( row => Number ( row . id ) === Number ( this . editorDraft . release_id ));
},
selectedEditorReleaseLabel () {
const release = this . selectedEditorRelease ();
if ( ! release ) return '' ;
return release . subtitle ? ` ${ release . title } / ${ release . subtitle } ` : release . title ;
},
filteredEditorReleases () {
const releases = ( this . editorDetail && this . editorDetail . releases ) || [];
const query = String ( this . editorReleaseToAdd || '' ). trim (). toLowerCase ();
const currentId = Number ( this . editorDraft . release_id || 0 );
const candidates = releases . filter ( release => Number ( release . id ) !== currentId );
if ( ! query ) return candidates . slice ( 0 , 12 );
return candidates
. map ( release => {
const haystack = ` ${ release . title || '' } ${ release . subtitle || '' } ` . toLowerCase ();
let score = 3 ;
if ( String ( release . title || '' ). toLowerCase () === query ) score = 0 ;
else if ( String ( release . title || '' ). toLowerCase (). startsWith ( query )) score = 1 ;
else if ( haystack . includes ( query )) score = 2 ;
return { release , score };
})
. filter ( row => row . score < 3 )
. sort (( a , b ) => a . score - b . score || a . release . title . localeCompare ( b . release . title ))
. slice ( 0 , 12 )
. map ( row => row . release );
},
editorReleaseSearchOpen () {
return this . isTrackEditor () && String ( this . editorReleaseToAdd || '' ). trim (). length > 0 ;
},
selectEditorRelease ( release = null ) {
const candidates = this . filteredEditorReleases ();
release = release || candidates [ 0 ];
2026-05-29 01:13:04 +03:00
if ( ! release ) return false ;
this . editorDraft . release_id = Number ( release . id );
2026-05-29 00:43:32 +03:00
this . editorReleaseToAdd = '' ;
2026-05-29 01:13:04 +03:00
return true ;
2026-05-29 00:43:32 +03:00
},
2026-05-27 15:56:57 +03:00
setEditorImageFile ( event ) {
this . editorImageFile = event . target . files && event . target . files . length ? event . target . files [ 0 ] : null ;
},
readFileAsDataUrl ( file ) {
return new Promise (( resolve , reject ) => {
const reader = new FileReader ();
reader . onload = () => resolve ( reader . result );
reader . onerror = () => reject ( reader . error || new Error ( 'failed to read file' ));
reader . readAsDataURL ( file );
});
},
async uploadLibraryImage () {
if ( ! this . activeLibraryItem || ! this . editorImageFile || this . editorImageUploading ) return ;
this . editorImageUploading = true ;
try {
const dataUrl = await this . readFileAsDataUrl ( this . editorImageFile );
const data = String ( dataUrl ). split ( ',' )[ 1 ] || '' ;
await this . request ( ` ${ this . apiBase } /library/item/upload-image` , {
method : 'POST' ,
body : JSON . stringify ({
kind : this . activeLibraryItem . kind ,
id : this . activeLibraryItem . id ,
filename : this . editorImageFile . name ,
mime_type : this . editorImageFile . type || 'application/octet-stream' ,
data
})
});
await this . loadEditorDetail ( this . activeLibraryItem );
this . showToast ( 'Image uploaded' );
} catch ( error ) {
this . showToast ( error . message );
} finally {
this . editorImageUploading = false ;
if ( this . $refs . libraryImageInput ) this . $refs . libraryImageInput . value = '' ;
this . icons ();
}
},
async setLibraryImage ( mediaFileId ) {
if ( ! this . activeLibraryItem || this . editorImageUploading ) return ;
this . editorImageUploading = true ;
try {
await this . request ( ` ${ this . apiBase } /library/item/image` , {
method : 'POST' ,
body : JSON . stringify ({
kind : this . activeLibraryItem . kind ,
id : this . activeLibraryItem . id ,
media_file_id : mediaFileId
})
});
await this . loadEditorDetail ( this . activeLibraryItem );
this . showToast ( mediaFileId ? 'Image selected' : 'Image removed' );
} catch ( error ) {
this . showToast ( error . message );
} finally {
this . editorImageUploading = false ;
this . icons ();
}
},
removeLibraryImage () {
this . setLibraryImage ( null );
2026-05-26 00:19:11 +03:00
},
toggleLibrary ( item ) {
this . librarySelectionScope = 'ids' ;
const key = ` ${ item . kind } : ${ item . id } ` ;
if ( this . selectedLibraryIds [ key ]) delete this . selectedLibraryIds [ key ];
else this . selectedLibraryIds [ key ] = true ;
this . selectedLibraryIds = Object . assign ({}, this . selectedLibraryIds );
},
isLibrarySelected ( item ) {
return this . librarySelectionScope === 'filter' || Boolean ( this . selectedLibraryIds [ ` ${ item . kind } : ${ item . id } ` ]);
},
selectedLibraryCount () {
if ( this . librarySelectionScope === 'filter' ) return this . library . total || 0 ;
2026-05-27 15:56:57 +03:00
return this . currentLibrarySelectionKeys (). length ;
},
currentLibrarySelectionKeys () {
return Object . keys ( this . selectedLibraryIds ). filter ( key => key . startsWith ( ` ${ this . libraryKind } :` ));
2026-05-26 00:19:11 +03:00
},
async bulkLibrary ( action ) {
const count = this . selectedLibraryCount ();
if ( ! count ) return ;
if ( action === 'delete' && ! confirm ( `Delete ${ count } ${ this . libraryKind } ?` )) return ;
2026-05-27 15:56:57 +03:00
const ids = this . currentLibrarySelectionKeys ()
2026-05-26 00:19:11 +03:00
. map ( key => Number ( key . split ( ':' )[ 1 ]))
. filter ( Boolean );
try {
const result = await this . request ( ` ${ this . apiBase } /library/bulk` , {
method : 'POST' ,
body : JSON . stringify ({
action ,
kind : this . libraryKind ,
mode : this . librarySelectionScope ,
ids ,
filter : { search : this . librarySearch || null }
})
});
this . showToast ( ` ${ result . affected } ${ this . libraryKind } updated` );
2026-05-27 15:56:57 +03:00
this . clearLibrarySelection ();
2026-05-26 00:19:11 +03:00
await this . loadLibrary ( false );
await this . refreshCountsOnly ();
} catch ( error ) {
this . showToast ( error . message );
}
},
async saveLibraryItem () {
2026-05-29 01:13:04 +03:00
if ( this . isTrackEditor () && String ( this . editorReleaseToAdd || '' ). trim ()) {
if ( ! this . selectEditorRelease ()) {
this . showToast ( 'Choose a release from search results' );
return ;
}
}
2026-05-27 15:56:57 +03:00
if ( ! this . editorCanSave ()) return ;
this . editorSaving = true ;
2026-05-26 00:19:11 +03:00
try {
const updated = await this . request ( ` ${ this . apiBase } /library/item` , {
method : 'POST' ,
body : JSON . stringify ({
kind : this . activeLibraryItem . kind ,
id : this . activeLibraryItem . id ,
title : this . editorDraft . title ,
2026-05-27 15:56:57 +03:00
hidden : this . editorDraft . hidden === 'true' ,
release_type : this . editorDraft . release_type || null ,
year : this . editorDraft . year || '' ,
2026-05-29 00:43:32 +03:00
release_id : this . editorDraft . release_id ? Number ( this . editorDraft . release_id ) : null ,
track_number : this . editorDraft . track_number || '' ,
disc_number : this . editorDraft . disc_number || '' ,
2026-05-27 15:56:57 +03:00
artist_ids : this . editorDraft . artist_ids || []
2026-05-26 00:19:11 +03:00
})
});
this . replaceLibraryItem ( updated );
this . activeLibraryItem = updated ;
2026-05-27 15:56:57 +03:00
if ( this . editorDetail ) this . editorDetail . item = updated ;
2026-05-26 00:19:11 +03:00
this . showToast ( 'Saved' );
await this . refreshCountsOnly ();
} catch ( error ) {
this . showToast ( error . message );
2026-05-27 15:56:57 +03:00
} finally {
this . editorSaving = false ;
this . icons ();
2026-05-26 00:19:11 +03:00
}
},
async deleteLibraryItem ( item ) {
if ( ! item ) return ;
if ( ! confirm ( `Delete " ${ item . title } "?` )) return ;
this . librarySelectionScope = 'ids' ;
this . selectedLibraryIds = { [ ` ${ item . kind } : ${ item . id } ` ] : true };
await this . bulkLibrary ( 'delete' );
this . editorOpen = false ;
this . activeLibraryItem = null ;
},
replaceLibraryItem ( updated ) {
this . library . items = this . library . items . map ( item =>
item . kind === updated . kind && item . id === updated . id ? updated : item
);
},
async refreshCountsOnly () {
try {
const data = await this . request ( ` ${ this . apiBase } /dashboard` );
this . stats = data . stats || this . stats ;
this . libraryOverview = data . library || this . libraryOverview ;
} catch ( _ ) {}
},
pageReviews ( delta ) {
const next = Math . max ( 0 , ( this . reviews . offset || 0 ) + delta * ( this . reviews . limit || 80 ));
if ( next === this . reviews . offset ) return ;
this . reviews . offset = next ;
this . loadReviews ( false );
},
pageLibrary ( delta ) {
const next = Math . max ( 0 , ( this . library . offset || 0 ) + delta * ( this . library . limit || 40 ));
if ( next === this . library . offset ) return ;
this . library . offset = next ;
this . loadLibrary ( false );
},
statCells () {
2026-06-01 14:53:51 +03:00
const agent = ( this . runtime && this . runtime . agent ) || {};
const storage = ( this . runtime && this . runtime . storage ) || [];
const node = ( this . runtime && this . runtime . node ) || {};
const inbox = storage . find ( item => item . label === 'Inbox' ) || {};
const library = storage . find ( item => item . label === 'Library' ) || {};
2026-05-26 00:19:11 +03:00
return [
{ label : 'Tracks' , value : this . stats . tracks || 0 },
{ label : 'Releases' , value : this . stats . releases || 0 },
{ label : 'Artists' , value : this . stats . artists || 0 },
{ label : 'Playlists' , value : this . stats . playlists || 0 },
2026-06-01 14:53:51 +03:00
{
label : agent . model ? `AI agent · ${ agent . model } ` : 'AI agent' ,
display : agent . status || 'unknown' ,
title : `enabled: ${ agent . enabled ? 'yes' : 'no' } · llm: ${ agent . llm_configured ? 'configured' : 'missing' } · concurrency: ${ agent . concurrency || 0 } `
},
this . storageStatCell ( inbox , 'Inbox disk' ),
this . storageStatCell ( library , 'Library disk' ),
{
label : ` ${ node . hostname || 'node' } · pid ${ node . pid || '?' } ` ,
display : ` ${ node . cpu_count || '?' } CPU` ,
title : ` ${ node . os || 'unknown' } / ${ node . arch || 'unknown' } `
}
2026-05-26 00:19:11 +03:00
];
},
2026-06-01 14:53:51 +03:00
storageStatCell ( item , fallbackLabel ) {
const free = item && item . free_bytes != null ? item . free_bytes : null ;
const total = item && item . total_bytes != null ? item . total_bytes : null ;
const suffix = item && item . exists === false ? ' · missing' : '' ;
return {
label : ` ${ item . label || fallbackLabel }${ suffix } ` ,
display : free != null ? ` ${ this . formatBytes ( free ) } free` : 'n/a' ,
title : ` ${ item . path || 'not configured' }${ total != null ? ` · ${ this . formatBytes ( total ) } total` : '' } `
};
},
formatBytes ( bytes ) {
const value = Number ( bytes || 0 );
if ( ! Number . isFinite ( value ) || value <= 0 ) return '0 B' ;
const units = [ 'B' , 'KB' , 'MB' , 'GB' , 'TB' , 'PB' ];
let size = value ;
let unit = 0 ;
while ( size >= 1024 && unit < units . length - 1 ) {
size /= 1024 ;
unit += 1 ;
}
const digits = size >= 10 || unit === 0 ? 0 : 1 ;
return ` ${ size . toFixed ( digits ) } ${ units [ unit ] } ` ;
},
2026-05-26 00:19:11 +03:00
pageTitle () {
if ( this . activeView === 'library' ) return 'Library Workbench' ;
if ( this . activeView === 'jobs' ) return 'Tasks' ;
if ( this . activeView === 'tools' ) return 'Future Tools' ;
2026-05-26 18:16:34 +03:00
if ( this . activeView === 'settings' ) return 'Settings' ;
2026-05-26 00:19:11 +03:00
return 'Review Queue' ;
},
pageSubtitle () {
2026-05-29 00:43:32 +03:00
if ( this . activeView === 'library' ) return 'Fast entity control surface for artists, releases, tracks, and playlists' ;
2026-05-26 00:19:11 +03:00
if ( this . activeView === 'jobs' ) return 'Scheduler state, recent runs, and manual controls in one place' ;
if ( this . activeView === 'tools' ) return 'Reserved space for merge, split, enrichment, and destructive workflows' ;
2026-05-26 18:16:34 +03:00
if ( this . activeView === 'settings' ) return 'Application configuration and external API credentials' ;
2026-05-26 00:19:11 +03:00
return 'Full-screen review triage with filter-aware bulk actions' ;
},
reviewPanelSubtitle () {
return ` ${ this . fmt ( this . reviews . total || 0 ) } rows · ${ this . reviewFilter . status || 'all statuses' } ` ;
},
jobPanelSubtitle () {
const running = this . jobs . filter ( job => job . is_running ). length ;
return ` ${ this . jobs . length } jobs · ${ running } running` ;
},
librarySubtitle () {
return ` ${ this . libraryKind } · ${ this . fmt ( this . library . total || 0 ) } rows` ;
},
librarySelectionSummary () {
const count = this . selectedLibraryCount ();
if ( ! count ) return 'Nothing selected' ;
return this . librarySelectionScope === 'filter' ? ` ${ count } selected by filter` : ` ${ count } selected` ;
},
reviewRangeText () {
if ( ! this . reviews . total ) return '0 rows' ;
const start = ( this . reviews . offset || 0 ) + 1 ;
const end = Math . min (( this . reviews . offset || 0 ) + ( this . reviews . limit || 80 ), this . reviews . total );
return ` ${ start } - ${ end } of ${ this . fmt ( this . reviews . total ) } ` ;
},
libraryRangeText () {
if ( ! this . library . total ) return '0 rows' ;
const start = ( this . library . offset || 0 ) + 1 ;
const end = Math . min (( this . library . offset || 0 ) + ( this . library . limit || 40 ), this . library . total );
return ` ${ start } - ${ end } of ${ this . fmt ( this . library . total ) } ` ;
},
2026-05-29 17:04:30 +03:00
cronLabel ( job ) {
const value = job && job . cron_expression ? String ( job . cron_expression ). trim () : '' ;
return value || 'manual only' ;
2026-05-26 00:19:11 +03:00
},
statusCount ( status ) {
const row = ( this . reviews . status_counts || []). find ( item => item . status === status );
return row ? row . count : 0 ;
},
formatConfidence ( value ) {
return typeof value === 'number' ? ` ${ Math . round ( value * 100 ) } %` : '-' ;
},
duration ( ms ) {
if ( ! ms && ms !== 0 ) return '-' ;
return ms >= 1000 ? ` ${ ( ms / 1000 ). toFixed ( 1 ) } s` : ` ${ ms } ms` ;
},
runChipLabel ( run ) {
2026-05-29 17:04:30 +03:00
return `# ${ run . id } ` ;
2026-05-26 00:19:11 +03:00
},
runTitle ( run ) {
const parts = [
`# ${ run . id } ` ,
run . status ,
run . started_at ? `started ${ this . shortDate ( run . started_at ) } ` : '' ,
run . duration_ms || run . duration_ms === 0 ? `duration ${ this . duration ( run . duration_ms ) } ` : '' ,
run . error_message || run . log_excerpt || ''
];
return parts . filter ( Boolean ). join ( ' · ' );
},
relativeDate ( value ) {
if ( ! value ) return '-' ;
const date = new Date ( value );
if ( Number . isNaN ( date . getTime ())) return value ;
const seconds = Math . round (( date . getTime () - Date . now ()) / 1000 );
const abs = Math . abs ( seconds );
if ( abs < 60 ) return seconds >= 0 ? 'now' : 'just now' ;
if ( abs < 3600 ) return ` ${ Math . round ( abs / 60 ) } m ${ seconds >= 0 ? 'left' : 'ago' } ` ;
if ( abs < 86400 ) return ` ${ Math . round ( abs / 3600 ) } h ${ seconds >= 0 ? 'left' : 'ago' } ` ;
return date . toLocaleDateString ();
},
shortDate ( value ) {
if ( ! value ) return '-' ;
const date = new Date ( value );
if ( Number . isNaN ( date . getTime ())) return value ;
return date . toLocaleString ([], { month : 'short' , day : '2-digit' , hour : '2-digit' , minute : '2-digit' });
},
fmt ( value ) {
return new Intl . NumberFormat (). format ( value || 0 );
},
mockAction ( message ) {
this . showToast ( message );
},
showToast ( message ) {
this . toastMessage = message ;
setTimeout (() => {
if ( this . toastMessage === message ) this . toastMessage = '' ;
}, 3400 );
},
icons () {
this . $nextTick (() => {
if ( window . lucide ) window . lucide . createIcons ();
});
}
};
}
</ script >
{% endblock content %}