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 ,
2026-06-02 20:45:00 +03:00
. icon-btn : disabled ,
. seg-btn : disabled {
2026-05-26 00:19:11 +03:00
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 ; }
2026-06-02 20:45:00 +03:00
. users-table-wrap {
max-height : calc ( 100 vh - 300 px ) ;
}
. users-table . user-status-column { width : 116 px ; }
. users-table . user-role-column { width : 110 px ; }
. users-table . user-active-column { width : 112 px ; }
. users-table . user-seen-column { width : 170 px ; }
2026-05-26 00:19:11 +03:00
. 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 ;
}
2026-06-02 20:45:00 +03:00
. user-modal {
width : min ( 860 px , calc ( 100 vw - 80 px ) ) ;
}
. user-modal-tabs {
margin-bottom : 14 px ;
}
. user-summary-grid ,
. user-profile-facts {
display : grid ;
grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ;
gap : 8 px ;
margin-bottom : 14 px ;
}
. user-summary-card ,
. user-profile-facts > div {
min-width : 0 ;
padding : 10 px 12 px ;
border : 1 px solid var ( - - border - color ) ;
border-radius : 8 px ;
background : var ( - - bg - primary ) ;
}
. user-summary-card span ,
. user-profile-facts span {
display : block ;
color : var ( - - text - subdued ) ;
font-size : 11 px ;
margin-bottom : 4 px ;
}
. user-summary-card strong ,
. user-profile-facts strong {
display : block ;
overflow : hidden ;
color : var ( - - text - primary ) ;
font-size : 13 px ;
font-weight : 800 ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
. user-stats-grid {
grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ;
}
2026-06-03 02:02:23 +03:00
. user-activity-list {
display : grid ;
gap : 8 px ;
}
. user-activity-row {
min-width : 0 ;
padding : 8 px ;
border : 1 px solid var ( - - border - color ) ;
border-radius : 8 px ;
background : var ( - - bg - primary ) ;
display : grid ;
grid-template-columns : 52 px minmax ( 0 , 1 fr ) auto ;
align-items : center ;
gap : 10 px ;
}
. user-activity-cover {
width : 52 px ;
height : 52 px ;
border-radius : 4 px ;
background : var ( - - bg - elevated ) ;
overflow : hidden ;
display : flex ;
align-items : center ;
justify-content : center ;
color : var ( - - text - subdued ) ;
}
. user-activity-cover img {
width : 100 % ;
height : 100 % ;
object-fit : cover ;
}
. user-activity-cover svg {
width : 24 px ;
height : 24 px ;
}
. user-activity-main {
min-width : 0 ;
}
. user-activity-title ,
. user-activity-meta ,
. user-activity-sub {
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
. user-activity-title {
color : var ( - - text - primary ) ;
font-size : 13 px ;
font-weight : 800 ;
}
. user-activity-meta {
margin-top : 3 px ;
color : var ( - - text - secondary ) ;
font-size : 12 px ;
}
. user-activity-sub {
margin-top : 3 px ;
color : var ( - - text - subdued ) ;
font-size : 11 px ;
}
. user-activity-time {
min-width : 118 px ;
color : var ( - - text - subdued ) ;
font-size : 11 px ;
text-align : right ;
}
. user-activity-time strong {
display : block ;
color : var ( - - text - primary ) ;
font-size : 12 px ;
}
2026-05-26 00:19:11 +03:00
< / 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 >
2026-06-03 03:39:16 +03:00
< span class = "nav-count" x-text = "reviewTotalAll()" > < / span >
2026-05-26 00:19:11 +03:00
< / 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-06-02 20:45:00 +03:00
< button class = "nav-btn" :class = "{active: activeView === 'users'}" @ click = "openUsers()" >
< i data-lucide = "users" > < / i >
< span > Users< / span >
< span class = "nav-count" x-text = "users.online_count ? users.online_count + ' online' : ''" > < / 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 >
2026-06-03 03:39:16 +03:00
< span class = "muted" x-text = "status.value ? statusCount(status.value) : reviewTotalAll()" > < / span >
2026-05-26 00:19:11 +03:00
< / 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 >
2026-06-03 03:39:16 +03:00
< label > < input type = "checkbox" x-model = "metadataBackfillOptions.musicbrainz_tags" / > MusicBrainz tags< / label >
2026-05-29 17:04:30 +03:00
< / 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-06-03 03:39:16 +03:00
< div class = "metadata-backfill-options" x-show = "isArtworkBackfillJob(activeJob)" >
< div class = "mode-row" >
< label > < input type = "radio" value = "missing" x-model = "artworkBackfillOptions.mode" / > Missing images only< / label >
< label > < input type = "radio" value = "overwrite" x-model = "artworkBackfillOptions.mode" / > Search all and replace existing< / label >
< / div >
< / 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 >
2026-06-02 20:45:00 +03:00
< div class = "content" x-show = "activeView === 'users'" >
< section class = "stats-strip" >
< div class = "stat-cell" >
< div class = "stat-value" x-text = "fmt(users.total || 0)" > < / div >
< div class = "stat-label" > Users< / div >
< / div >
< div class = "stat-cell" >
< div class = "stat-value" x-text = "fmt(users.online_count || 0)" > < / div >
< div class = "stat-label" > Online now< / div >
< / div >
< / section >
< section class = "panel" >
< div class = "panel-head" >
< div class = "panel-title" >
< strong > Users< / strong >
< span x-text = "usersSubtitle()" > < / span >
< / div >
< div class = "toolbar" >
< input class = "search" placeholder = "Search users" x-model = "userSearch" @ input . debounce . 350ms = "loadUsers()" / >
< button class = "btn" @ click = "loadUsers(false)" >
< i data-lucide = "refresh-cw" > < / i >
Refresh
< / button >
< / div >
< / div >
< div class = "table-wrap users-table-wrap" >
< table class = "users-table" >
< thead >
< tr >
< th class = "user-status-column" > Status< / th >
< th > User< / th >
< th class = "user-role-column" > Role< / th >
< th class = "user-active-column" > Account< / th >
< th class = "user-seen-column" > Last activity< / th >
< / tr >
< / thead >
< tbody >
< template x-for = "user in users.items" :key = "user.id" >
< tr @ click = "openUser(user)" >
< td >
< span class = "badge" :class = "userStatusClass(user)" x-text = "userStatusLabel(user)" > < / span >
< / td >
< td >
< div class = "primary-line" x-text = "user.display_name || user.username" > < / div >
< div class = "secondary-line" x-text = "user.email ? user.username + ' · ' + user.email : user.username" > < / div >
< / td >
< td > < span x-text = "user.role" > < / span > < / td >
< td >
< span class = "badge" :class = "user.is_active ? 'ok' : 'disabled'" x-text = "user.is_active ? 'active' : 'disabled'" > < / span >
< / td >
< td > < span x-text = "userLastSeenLabel(user)" > < / span > < / td >
< / tr >
< / template >
< / tbody >
< / table >
< div class = "empty" x-show = "!usersLoading && users.items.length === 0" > No users in this filter< / div >
< / div >
< div class = "action-strip" >
< span class = "selection-summary" x-text = "usersRangeText()" > < / span >
< div class = "toolbar" >
< select class = "search" style = "width:92px" x-model . number = "users.limit" @ change = "users.offset = 0; loadUsers(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 = "pageUsers(-1)" :disabled = "users.offset === 0" > Previous< / button >
< button class = "btn" @ click = "pageUsers(1)" :disabled = "users.offset + users.limit >= users.total" > Next< / button >
< / div >
< / div >
< / section >
< / div >
2026-05-26 00:19:11 +03:00
< 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 >
2026-06-02 20:45:00 +03:00
< div class = "modal-backdrop" x-show = "userModalOpen && activeUserDetail" x-transition @ click . self = "userModalOpen = false" >
< section class = "modal user-modal" >
< div class = "modal-head" >
< div class = "panel-title" >
< strong x-text = "activeUserDetail ? (activeUserDetail.user.display_name || activeUserDetail.user.username) : 'User'" > < / strong >
< span x-text = "activeUserDetail ? userStatusLabel(activeUserDetail.user) + ' · ' + activeUserDetail.user.role : ''" > < / span >
< / div >
< button class = "icon-btn" @ click = "userModalOpen = false" >
< i data-lucide = "x" > < / i >
< / button >
< / div >
< div class = "modal-body" x-show = "activeUserDetail" >
< div class = "segmented user-modal-tabs" >
< button class = "seg-btn" :class = "{active: userModalTab === 'overview'}" @ click = "userModalTab = 'overview'" > Overview< / button >
2026-06-03 02:02:23 +03:00
< button class = "seg-btn" :class = "{active: userModalTab === 'activity'}" @ click = "userModalTab = 'activity'" > Activity< / button >
2026-06-02 20:45:00 +03:00
< button class = "seg-btn" :class = "{active: userModalTab === 'library'}" @ click = "userModalTab = 'library'" disabled > Library< / button >
< / div >
< div x-show = "userModalTab === 'overview'" >
< section class = "user-summary-grid" >
< div class = "user-summary-card" >
< span > Status< / span >
< strong x-text = "userStatusLabel(activeUserDetail.user)" > < / strong >
< / div >
< div class = "user-summary-card" >
< span > Last activity< / span >
< strong x-text = "userLastSeenLabel(activeUserDetail.user)" > < / strong >
< / div >
< div class = "user-summary-card" >
< span > Account< / span >
< strong x-text = "activeUserDetail.user.is_active ? 'active' : 'disabled'" > < / strong >
< / div >
< div class = "user-summary-card" >
< span > Last.fm< / span >
< strong x-text = "activeUserDetail.stats.lastfm_connected ? 'connected' : 'not connected'" > < / strong >
< / div >
< / section >
< section class = "stats-strip user-stats-grid" >
< template x-for = "cell in userStatCards()" :key = "cell.label" >
< div class = "stat-cell" >
< div class = "stat-value" x-text = "cell.display" > < / div >
< div class = "stat-label" x-text = "cell.label" > < / div >
< / div >
< / template >
< / section >
< div class = "field" >
< label > Profile< / label >
< div class = "user-profile-facts" >
< div > < span > Username< / span > < strong x-text = "activeUserDetail.user.username" > < / strong > < / div >
< div > < span > Email< / span > < strong x-text = "activeUserDetail.user.email || 'not set'" > < / strong > < / div >
< div > < span > Display name< / span > < strong x-text = "activeUserDetail.user.display_name || 'not set'" > < / strong > < / div >
< div > < span > Role< / span > < strong x-text = "activeUserDetail.user.role" > < / strong > < / div >
< / div >
< / div >
< / div >
2026-06-03 02:02:23 +03:00
< div x-show = "userModalTab === 'activity'" >
< div class = "user-activity-list" >
< template x-for = "play in (activeUserDetail?.recent_plays || [])" :key = "play.history_id" >
< div class = "user-activity-row" >
< div class = "user-activity-cover" >
< template x-if = "play.cover_url" >
< img :src = "play.cover_url" :alt = "play.release_title || play.title" loading = "lazy" >
< / template >
< template x-if = "!play.cover_url" >
< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < rect x = "3" y = "3" width = "18" height = "18" rx = "2" / > < circle cx = "12" cy = "12" r = "4" / > < / svg >
< / template >
< / div >
< div class = "user-activity-main" >
< div class = "user-activity-title" x-text = "play.title" > < / div >
< div class = "user-activity-meta" x-text = "play.artists" > < / div >
< div class = "user-activity-sub" x-text = "userPlayMeta(play)" > < / div >
< / div >
< div class = "user-activity-time" >
< strong x-text = "userPlayListened(play)" > < / strong >
< span x-text = "shortDate(play.played_at)" > < / span >
< / div >
< / div >
< / template >
< div class = "empty" x-show = "activeUserDetail && (!activeUserDetail.recent_plays || activeUserDetail.recent_plays.length === 0)" > No play history for this user yet< / div >
< / div >
< / div >
2026-06-02 20:45:00 +03:00
< / div >
< / section >
< / div >
2026-05-26 00:19:11 +03:00
< 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 : { } ,
2026-06-03 03:39:16 +03:00
reviews : { items : [ ] , total : 0 , total _all : 0 , limit : 80 , offset : 0 , status _counts : [ ] } ,
2026-06-02 20:45:00 +03:00
users : { items : [ ] , total : 0 , limit : 40 , offset : 0 , online _count : 0 } ,
usersLoading : false ,
userSearch : '' ,
userModalOpen : false ,
userModalTab : 'overview' ,
activeUserDetail : null ,
2026-05-26 00:19:11 +03:00
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 ,
2026-06-03 03:39:16 +03:00
musicbrainz _tags : true ,
2026-05-29 17:04:30 +03:00
mode : 'fill_missing'
} ,
2026-06-03 03:39:16 +03:00
artworkBackfillOptions : {
mode : '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 ( )
] ) ;
2026-06-02 20:45:00 +03:00
} else if ( this . activeView === 'users' ) {
await Promise . allSettled ( [ this . loadUsers ( false ) ] ) ;
2026-05-29 17:04:30 +03:00
} 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' ;
2026-06-02 20:45:00 +03:00
} else if ( view === 'users' ) {
this . activeView = 'users' ;
2026-05-26 18:40:05 +03:00
} 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 ) ;
}
2026-06-02 20:45:00 +03:00
} else if ( this . activeView === 'users' ) {
if ( updateRoute ) this . setRoute ( '#users' ) ;
await this . loadUsers ( false ) ;
2026-05-26 18:40:05 +03:00
} 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-06-02 20:45:00 +03:00
openUsers ( ) {
this . activeView = 'users' ;
this . setRoute ( '#users' ) ;
this . loadUsers ( ) ;
} ,
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-06-02 20:45:00 +03:00
async loadUsers ( resetOffset = true ) {
if ( this . activeView !== 'users' && resetOffset ) return ;
if ( resetOffset ) this . users . offset = 0 ;
this . usersLoading = true ;
const params = new URLSearchParams ( ) ;
params . set ( 'limit' , this . users . limit || 40 ) ;
params . set ( 'offset' , this . users . offset || 0 ) ;
if ( this . userSearch ) params . set ( 'search' , this . userSearch ) ;
try {
this . users = await this . request ( ` ${ this . apiBase } /users? ${ params . toString ( ) } ` ) ;
} catch ( error ) {
this . showToast ( error . message ) ;
} finally {
this . usersLoading = false ;
this . icons ( ) ;
}
} ,
async openUser ( user ) {
if ( ! user ) return ;
this . userModalTab = 'overview' ;
2026-06-03 02:02:23 +03:00
this . activeUserDetail = { user , stats : { } , recent _plays : [ ] } ;
2026-06-02 20:45:00 +03:00
this . userModalOpen = true ;
try {
this . activeUserDetail = await this . request ( ` ${ this . apiBase } /users/ ${ user . id } ` ) ;
} catch ( error ) {
this . showToast ( error . message ) ;
} finally {
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 ( ) )
} )
2026-06-03 03:39:16 +03:00
: this . isArtworkBackfillJob ( job )
? await this . request ( ` ${ this . apiBase } /jobs/artwork_backfill/run-options ` , {
method : 'POST' ,
body : JSON . stringify ( this . artworkBackfillPayload ( ) )
} )
2026-05-29 17:04:30 +03:00
: 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' ;
} ,
2026-06-03 03:39:16 +03:00
isArtworkBackfillJob ( job ) {
return job && job . name === 'artwork_backfill' ;
} ,
2026-05-29 17:04:30 +03:00
jobHasParameterForm ( job ) {
2026-06-03 03:39:16 +03:00
return this . isMetadataBackfillJob ( job ) || this . isArtworkBackfillJob ( job ) ;
2026-05-29 17:04:30 +03:00
} ,
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 ) ,
2026-06-03 03:39:16 +03:00
musicbrainz _tags : Boolean ( options . musicbrainz _tags ) ,
2026-05-29 17:04:30 +03:00
overwrite : options . mode === 'overwrite'
} ;
} ,
2026-06-03 03:39:16 +03:00
artworkBackfillPayload ( ) {
const options = this . artworkBackfillOptions || { } ;
return {
overwrite _existing : 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 ) ;
} ,
2026-06-02 20:45:00 +03:00
pageUsers ( delta ) {
const next = Math . max ( 0 , ( this . users . offset || 0 ) + delta * ( this . users . limit || 40 ) ) ;
if ( next === this . users . offset ) return ;
this . users . offset = next ;
this . loadUsers ( false ) ;
} ,
2026-05-26 00:19:11 +03:00
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-06-02 20:45:00 +03:00
if ( this . activeView === 'users' ) return 'Users' ;
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-06-02 20:45:00 +03:00
if ( this . activeView === 'users' ) return 'Account status, live presence, and per-user listening statistics' ;
2026-05-26 00:19:11 +03:00
return 'Full-screen review triage with filter-aware bulk actions' ;
} ,
reviewPanelSubtitle ( ) {
2026-06-03 03:39:16 +03:00
const total = this . reviews . total || 0 ;
const totalAll = this . reviewTotalAll ( ) ;
const status = this . reviewFilter . status || 'all statuses' ;
if ( this . reviewFilter . status ) return ` ${ this . fmt ( total ) } ${ status } · ${ this . fmt ( totalAll ) } total ` ;
return ` ${ this . fmt ( totalAll ) } rows · ${ status } ` ;
2026-05-26 00:19:11 +03:00
} ,
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 ` ;
} ,
2026-06-02 20:45:00 +03:00
usersSubtitle ( ) {
return ` ${ this . fmt ( this . users . total || 0 ) } users · ${ this . fmt ( this . users . online _count || 0 ) } online ` ;
} ,
userStatusClass ( user ) {
return user ? . is _online ? 'ok' : 'disabled' ;
} ,
userStatusLabel ( user ) {
return user ? . is _online ? 'online' : 'offline' ;
} ,
userLastSeenLabel ( user ) {
if ( ! user || user . last _seen _ms == null ) return 'never in this session' ;
if ( user . is _online ) return 'online now' ;
return this . durationApprox ( user . last _seen _ms / 1000 ) + ' ago' ;
} ,
usersRangeText ( ) {
const total = this . users . total || 0 ;
if ( ! total ) return 'No users' ;
const start = ( this . users . offset || 0 ) + 1 ;
const end = Math . min ( total , ( this . users . offset || 0 ) + ( this . users . limit || 40 ) ) ;
return ` ${ start } - ${ end } of ${ this . fmt ( total ) } ` ;
} ,
userStatCards ( ) {
const stats = this . activeUserDetail ? . stats || { } ;
return [
{ label : 'Plays' , display : this . fmt ( stats . plays || 0 ) } ,
{ label : 'Completed plays' , display : this . fmt ( stats . completed _plays || 0 ) } ,
{ label : 'Listened time' , display : this . durationApprox ( stats . listened _seconds || 0 ) } ,
{ label : 'Liked tracks' , display : this . fmt ( stats . liked _tracks || 0 ) } ,
{ label : 'Followed artists' , display : this . fmt ( stats . followed _artists || 0 ) } ,
{ label : 'Own playlists' , display : this . fmt ( stats . own _playlists || 0 ) } ,
{ label : 'Saved playlists' , display : this . fmt ( stats . saved _playlists || 0 ) } ,
{ label : 'Uploaded tracks' , display : this . fmt ( stats . uploaded _tracks || 0 ) } ,
{ label : 'Torrent sessions' , display : this . fmt ( stats . torrent _sessions || 0 ) }
] ;
} ,
2026-06-03 02:02:23 +03:00
userPlayListened ( play ) {
const listened = play ? . duration _listened ;
const duration = play ? . track _duration _seconds ;
const listenedText = listened == null ? 'unknown' : this . durationApprox ( listened ) ;
const durationText = duration ? this . durationApprox ( duration ) : null ;
return durationText ? ` ${ listenedText } / ${ durationText } ` : listenedText ;
} ,
userPlayMeta ( play ) {
const parts = [ ] ;
if ( play . release _title ) {
parts . push ( play . release _year ? ` ${ play . release _title } ( ${ play . release _year } ) ` : play . release _title ) ;
}
if ( play . audio _format ) parts . push ( String ( play . audio _format ) . toUpperCase ( ) ) ;
if ( play . audio _bitrate ) parts . push ( ` ${ play . audio _bitrate } kbps ` ) ;
if ( play . uploader _name ) parts . push ( ` uploaded by ${ play . uploader _name } ` ) ;
parts . push ( play . completed ? 'completed' : 'partial' ) ;
return parts . join ( ' · ' ) ;
} ,
2026-06-02 20:45:00 +03:00
durationApprox ( seconds ) {
const value = Math . max ( 0 , Number ( seconds || 0 ) ) ;
if ( value < 60 ) return ` ${ Math . floor ( value ) } s ` ;
if ( value < 3600 ) return ` ${ Math . floor ( value / 60 ) } m ` ;
if ( value < 86400 ) return ` ${ Math . floor ( value / 3600 ) } h ${ Math . floor ( ( value % 3600 ) / 60 ) } m ` ;
return ` ${ Math . floor ( value / 86400 ) } d ${ Math . floor ( ( value % 86400 ) / 3600 ) } h ` ;
} ,
2026-05-26 00:19:11 +03:00
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 ;
} ,
2026-06-03 03:39:16 +03:00
reviewTotalAll ( ) {
const explicit = Number ( this . reviews . total _all || 0 ) ;
if ( explicit > 0 || Object . prototype . hasOwnProperty . call ( this . reviews || { } , 'total_all' ) ) return explicit ;
return ( this . reviews . status _counts || [ ] ) . reduce ( ( sum , row ) => sum + Number ( row . count || 0 ) , 0 ) ;
} ,
2026-05-26 00:19:11 +03:00
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 %}