4962 lines
206 KiB
HTML
4962 lines
206 KiB
HTML
{% 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: 244px;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
[x-cloak] { display: none !important; }
|
|
|
|
html, body {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-width: 1180px;
|
|
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, 1fr);
|
|
height: 100vh;
|
|
height: 100dvh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sidebar {
|
|
min-width: 0;
|
|
border-right: 1px solid var(--border-color);
|
|
background: var(--bg-secondary);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.brand {
|
|
padding: 18px 16px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.brand-title {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 8px;
|
|
font-size: 18px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.version {
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.admin-user {
|
|
margin-top: 8px;
|
|
color: var(--text-subdued);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.nav-group {
|
|
padding: 12px;
|
|
}
|
|
|
|
.nav-label {
|
|
padding: 8px 8px 6px;
|
|
color: var(--text-subdued);
|
|
font-size: 10px;
|
|
font-weight: 800;
|
|
letter-spacing: 0;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.nav-btn {
|
|
width: 100%;
|
|
height: 38px;
|
|
padding: 0 10px;
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
display: grid;
|
|
grid-template-columns: 18px minmax(0, 1fr) auto;
|
|
align-items: center;
|
|
gap: 10px;
|
|
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: 16px;
|
|
height: 16px;
|
|
}
|
|
|
|
.nav-count {
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.main {
|
|
min-width: 0;
|
|
min-height: 0;
|
|
overflow: auto;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.topbar {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 5;
|
|
height: 64px;
|
|
padding: 12px 18px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
background: rgba(18, 18, 18, 0.96);
|
|
backdrop-filter: blur(12px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
}
|
|
|
|
.page-title {
|
|
min-width: 0;
|
|
}
|
|
|
|
.page-title h1 {
|
|
margin: 0;
|
|
font-size: 20px;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.page-title p {
|
|
margin: 4px 0 0;
|
|
color: var(--text-subdued);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.top-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.content {
|
|
padding: 18px;
|
|
}
|
|
|
|
.stats-strip {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
|
gap: 8px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.stat-cell {
|
|
min-width: 0;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 18px;
|
|
font-weight: 800;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.stat-label {
|
|
margin-top: 2px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.panel {
|
|
min-width: 0;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.panel-head {
|
|
min-height: 56px;
|
|
padding: 12px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.panel-title {
|
|
min-width: 0;
|
|
}
|
|
|
|
.panel-title strong {
|
|
display: block;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.panel-title span {
|
|
display: block;
|
|
margin-top: 2px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.btn,
|
|
.icon-btn,
|
|
.seg-btn {
|
|
height: 32px;
|
|
border-radius: 6px;
|
|
background: var(--bg-elevated);
|
|
color: var(--text-secondary);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 7px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btn {
|
|
padding: 0 12px;
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.icon-btn {
|
|
width: 32px;
|
|
padding: 0;
|
|
}
|
|
|
|
.btn:hover,
|
|
.icon-btn:hover,
|
|
.seg-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.btn.primary {
|
|
background: var(--accent);
|
|
color: #07120b;
|
|
}
|
|
|
|
.btn.primary:hover {
|
|
background: var(--accent-hover);
|
|
}
|
|
|
|
.btn.danger {
|
|
background: rgba(239, 98, 98, 0.18);
|
|
color: #ffb8b8;
|
|
}
|
|
|
|
.btn.warn {
|
|
background: rgba(241, 184, 75, 0.16);
|
|
color: #ffd891;
|
|
}
|
|
|
|
.btn:disabled,
|
|
.icon-btn:disabled,
|
|
.seg-btn:disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.segmented {
|
|
display: inline-flex;
|
|
padding: 3px;
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.seg-btn {
|
|
min-width: 76px;
|
|
height: 28px;
|
|
padding: 0 10px;
|
|
background: transparent;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.seg-btn.active {
|
|
background: var(--bg-elevated);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.search {
|
|
width: 260px;
|
|
height: 32px;
|
|
padding: 0 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
outline: none;
|
|
}
|
|
|
|
.search:focus {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.action-strip {
|
|
min-height: 44px;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
}
|
|
|
|
.selection-summary {
|
|
color: var(--text-subdued);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.table-wrap {
|
|
max-height: 560px;
|
|
overflow: auto;
|
|
}
|
|
|
|
.review-table-wrap {
|
|
max-height: calc(100vh - 318px);
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
table-layout: fixed;
|
|
}
|
|
|
|
th {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 2;
|
|
height: 34px;
|
|
padding: 0 10px;
|
|
background: var(--bg-secondary);
|
|
color: var(--text-subdued);
|
|
border-bottom: 1px solid var(--border-color);
|
|
font-size: 11px;
|
|
font-weight: 800;
|
|
text-align: left;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
td {
|
|
height: 54px;
|
|
padding: 8px 10px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
tr {
|
|
cursor: default;
|
|
}
|
|
|
|
tbody tr:hover {
|
|
background: rgba(255, 255, 255, 0.035);
|
|
}
|
|
|
|
.col-check { width: 38px; }
|
|
.col-status { width: 112px; }
|
|
.col-type { width: 120px; }
|
|
.col-confidence { width: 88px; }
|
|
.col-tags { width: 250px; }
|
|
.col-time { width: 132px; }
|
|
|
|
.users-table-wrap {
|
|
max-height: calc(100vh - 300px);
|
|
}
|
|
|
|
.users-table .user-status-column { width: 116px; }
|
|
.users-table .user-role-column { width: 110px; }
|
|
.users-table .user-active-column { width: 112px; }
|
|
.users-table .user-seen-column { width: 170px; }
|
|
|
|
.check {
|
|
width: 16px;
|
|
height: 16px;
|
|
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: 3px;
|
|
color: var(--text-subdued);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
height: 22px;
|
|
padding: 0 8px;
|
|
border-radius: 999px;
|
|
background: var(--bg-elevated);
|
|
color: var(--text-secondary);
|
|
font-size: 11px;
|
|
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: 5px;
|
|
flex-wrap: wrap;
|
|
max-height: 42px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
height: 20px;
|
|
padding: 0 7px;
|
|
border-radius: 999px;
|
|
font-size: 10px;
|
|
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; }
|
|
.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: 1px solid rgba(90, 167, 255, 0.22); }
|
|
.tag.metadata-release-file { background: rgba(29, 185, 84, 0.1); color: #a7eabd; border: 1px solid rgba(29, 185, 84, 0.2); }
|
|
.tag.metadata-release-review { background: rgba(241, 184, 75, 0.12); color: #ffe1a6; border: 1px solid rgba(241, 184, 75, 0.22); }
|
|
|
|
.jobs-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 8px;
|
|
padding: 12px;
|
|
}
|
|
|
|
.metadata-backfill-options {
|
|
display: grid;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
padding: 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.metadata-backfill-options .option-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
.metadata-backfill-options label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
min-height: 26px;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.metadata-backfill-options input {
|
|
width: auto;
|
|
min-height: auto;
|
|
}
|
|
|
|
.metadata-backfill-options .mode-row {
|
|
display: flex;
|
|
gap: 14px;
|
|
flex-wrap: wrap;
|
|
padding-top: 4px;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
}
|
|
|
|
.jobs-page {
|
|
display: grid;
|
|
grid-template-columns: minmax(720px, 1fr) 380px;
|
|
grid-template-rows: minmax(310px, 1fr) minmax(260px, 0.85fr);
|
|
gap: 14px;
|
|
align-items: stretch;
|
|
height: calc(100vh - 100px);
|
|
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;
|
|
}
|
|
|
|
.jobs-page .jobs-list-panel .panel-head {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.job-table .job-column { width: 32%; }
|
|
.job-table .state-column { width: 12%; }
|
|
.job-table .schedule-column { width: 22%; }
|
|
.job-table .runs-column { width: 24%; }
|
|
.job-table .actions-column { width: 10%; }
|
|
|
|
.job-table .toolbar {
|
|
flex-wrap: nowrap;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.inline-runs {
|
|
display: flex;
|
|
flex-wrap: nowrap;
|
|
gap: 5px;
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.run-chip {
|
|
gap: 5px;
|
|
max-width: 70px;
|
|
height: 20px;
|
|
padding: 0 7px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.task-form {
|
|
position: sticky;
|
|
top: 82px;
|
|
grid-column: 2;
|
|
grid-row: 1 / span 2;
|
|
align-self: start;
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-height: calc(100vh - 100px);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.task-form-body {
|
|
min-height: 0;
|
|
overflow: auto;
|
|
padding: 12px;
|
|
}
|
|
|
|
.job-detail-description {
|
|
margin-bottom: 10px;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.job-facts {
|
|
display: grid;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.job-fact {
|
|
display: grid;
|
|
grid-template-columns: 58px minmax(0, 1fr);
|
|
gap: 8px;
|
|
align-items: center;
|
|
min-height: 28px;
|
|
padding: 6px 8px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 7px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.job-fact span:first-child {
|
|
color: var(--text-subdued);
|
|
font-size: 10px;
|
|
font-weight: 850;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.job-fact span:last-child {
|
|
min-width: 0;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.job-param-note {
|
|
margin: -2px 0 10px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.job-run-pager {
|
|
min-height: 34px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
padding: 8px 0 0;
|
|
}
|
|
|
|
.job-run-pager .toolbar {
|
|
flex-wrap: nowrap;
|
|
}
|
|
|
|
.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: 12px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.run-log-output {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 210px;
|
|
margin: 0;
|
|
padding: 14px 16px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: #0b0d0f;
|
|
color: #dce6ef;
|
|
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
overflow: auto;
|
|
tab-size: 4;
|
|
}
|
|
|
|
.job-card {
|
|
min-width: 0;
|
|
padding: 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
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: 8px;
|
|
}
|
|
|
|
.job-name {
|
|
min-width: 0;
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
font-weight: 850;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.job-desc {
|
|
height: 32px;
|
|
margin-top: 6px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
line-height: 16px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.job-meta {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 6px;
|
|
margin-top: 8px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.runs {
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
.run-row {
|
|
display: grid;
|
|
grid-template-columns: 76px 96px minmax(0, 1fr) 72px;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-height: 42px;
|
|
padding: 6px 12px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.run-row:hover {
|
|
background: rgba(255, 255, 255, 0.035);
|
|
}
|
|
|
|
.library-shell {
|
|
display: block;
|
|
}
|
|
|
|
.settings-page {
|
|
max-width: 1440px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.settings-layout {
|
|
display: grid;
|
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
|
grid-template-areas:
|
|
"access access access access oidc oidc oidc oidc oidc oidc oidc oidc"
|
|
"agent agent agent agent agent agent agent agent agentstatus agentstatus agentstatus agentstatus"
|
|
"similarity similarity similarity similarity similarity similarity similarity similarity similaritystatus similaritystatus similaritystatus similaritystatus"
|
|
"federation federation federation federation federation federation federation federation federation federation federation federation"
|
|
"lastfm lastfm lastfm lastfm lastfm lastfm lastfm lastfm developer developer developer developer"
|
|
"actions actions actions actions actions actions actions actions actions actions actions actions";
|
|
gap: 16px;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.settings-column {
|
|
display: contents;
|
|
}
|
|
|
|
.settings-section {
|
|
min-width: 0;
|
|
margin: 0;
|
|
}
|
|
.settings-access { grid-area: access; }
|
|
.settings-oidc { grid-area: oidc; }
|
|
.settings-agent { grid-area: agent; }
|
|
.settings-agent-status { grid-area: agentstatus; }
|
|
.settings-similarity { grid-area: similarity; }
|
|
.settings-similarity-status { grid-area: similaritystatus; }
|
|
.settings-federation { grid-area: federation; }
|
|
.settings-lastfm { grid-area: lastfm; }
|
|
.settings-developer { grid-area: developer; }
|
|
.settings-section-narrow { border-left: 2px solid rgba(29, 185, 84, 0.55); }
|
|
|
|
.settings-access .settings-grid,
|
|
.settings-developer .settings-grid {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.settings-section-narrow .panel-head {
|
|
background: rgba(29, 185, 84, 0.035);
|
|
}
|
|
|
|
.settings-actions {
|
|
grid-area: actions;
|
|
position: sticky;
|
|
bottom: 0;
|
|
z-index: 5;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: rgba(35, 35, 35, 0.96);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.settings-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 14px 16px;
|
|
padding: 16px;
|
|
}
|
|
|
|
.settings-card {
|
|
padding: 14px;
|
|
}
|
|
|
|
.setting-field {
|
|
min-width: 0;
|
|
max-width: 480px;
|
|
}
|
|
|
|
.setting-field.settings-short { max-width: 150px; }
|
|
.settings-wide { max-width: 680px; }
|
|
|
|
.setting-field label,
|
|
.setting-toggle label {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
margin-bottom: 7px;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.setting-field input,
|
|
.setting-field select {
|
|
width: 100%;
|
|
height: 34px;
|
|
padding: 0 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
outline: none;
|
|
}
|
|
|
|
.setting-field textarea {
|
|
width: 100%;
|
|
min-height: 68px;
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
outline: none;
|
|
resize: vertical;
|
|
}
|
|
|
|
.setting-field input:focus,
|
|
.setting-field select:focus,
|
|
.setting-field textarea:focus {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.setting-toggle {
|
|
min-height: 68px;
|
|
padding: 11px 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.setting-toggle-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
}
|
|
|
|
.setting-toggle-row span {
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.setting-toggle input,
|
|
.setting-toggle-row input[type="checkbox"] {
|
|
flex: 0 0 auto;
|
|
width: 18px;
|
|
height: 18px;
|
|
padding: 0;
|
|
accent-color: var(--accent);
|
|
}
|
|
|
|
.setting-help {
|
|
margin-top: 6px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
line-height: 1.45;
|
|
max-width: 68ch;
|
|
}
|
|
|
|
.source-pill {
|
|
flex: 0 0 auto;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
height: 18px;
|
|
padding: 0 6px;
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
color: var(--text-subdued);
|
|
font-size: 10px;
|
|
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;
|
|
}
|
|
|
|
.settings-federation-body {
|
|
display: grid;
|
|
grid-template-columns: minmax(360px, 4fr) minmax(580px, 8fr);
|
|
gap: 20px;
|
|
padding: 16px;
|
|
}
|
|
|
|
.settings-federation-body > .settings-grid,
|
|
.settings-federation-body > .probe-body {
|
|
padding: 0;
|
|
}
|
|
|
|
.federation-status-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.transport-log {
|
|
grid-column: 1 / -1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.transport-log-head {
|
|
display: flex;
|
|
align-items: end;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin: 14px 0 7px;
|
|
}
|
|
|
|
.transport-log-head strong { font-size: 12px; }
|
|
.transport-log-head span { color: var(--text-subdued); font-size: 11px; }
|
|
|
|
.transport-log-scroll {
|
|
max-height: 210px;
|
|
overflow: auto;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 7px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.transport-row {
|
|
display: grid;
|
|
grid-template-columns: 68px minmax(100px, 1.2fr) 84px 72px 54px 64px 64px 66px 66px 66px;
|
|
gap: 8px;
|
|
align-items: center;
|
|
min-width: 850px;
|
|
min-height: 30px;
|
|
padding: 5px 9px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
|
color: var(--text-secondary);
|
|
font: 11px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
font-variant-numeric: tabular-nums;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.transport-row:last-child { border-bottom: 0; }
|
|
.transport-row.transport-header {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
color: var(--text-subdued);
|
|
background: var(--bg-elevated);
|
|
font-size: 10px;
|
|
font-weight: 850;
|
|
text-transform: uppercase;
|
|
}
|
|
.transport-row > span { overflow: hidden; text-overflow: ellipsis; }
|
|
.transport-number { text-align: right; }
|
|
|
|
@media (max-width: 1000px) {
|
|
.settings-layout {
|
|
grid-template-columns: 1fr;
|
|
grid-template-areas:
|
|
"access"
|
|
"oidc"
|
|
"agent"
|
|
"agentstatus"
|
|
"similarity"
|
|
"similaritystatus"
|
|
"federation"
|
|
"lastfm"
|
|
"developer"
|
|
"actions";
|
|
}
|
|
.settings-federation-body { grid-template-columns: 1fr; }
|
|
}
|
|
|
|
@media (max-width: 700px) {
|
|
.settings-grid,
|
|
.federation-status-grid { grid-template-columns: 1fr; }
|
|
.setting-field { max-width: none; }
|
|
}
|
|
|
|
.settings-note {
|
|
padding: 14px;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.probe-body {
|
|
padding: 16px;
|
|
}
|
|
|
|
.probe-intro {
|
|
margin: 0 0 12px;
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.probe-table {
|
|
display: grid;
|
|
gap: 7px;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.probe-row {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
align-items: baseline;
|
|
gap: 12px;
|
|
min-height: 22px;
|
|
}
|
|
|
|
.probe-row strong {
|
|
max-width: 210px;
|
|
overflow: hidden;
|
|
text-align: right;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.similarity-profile-details {
|
|
margin-top: 12px;
|
|
padding-top: 12px;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
.similarity-profile-details > span {
|
|
display: block;
|
|
margin-bottom: 5px;
|
|
color: var(--text-subdued);
|
|
font-size: 10px;
|
|
font-weight: 800;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.similarity-profile-details pre {
|
|
margin: 0;
|
|
color: var(--text-secondary);
|
|
font: inherit;
|
|
font-size: 11px;
|
|
line-height: 1.45;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.library-row {
|
|
display: grid;
|
|
grid-template-columns: 38px minmax(0, 1fr) 300px 130px;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-height: 58px;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
|
}
|
|
|
|
.library-row:hover,
|
|
.library-row.active {
|
|
background: rgba(255, 255, 255, 0.035);
|
|
}
|
|
|
|
.field {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.field label {
|
|
display: block;
|
|
margin-bottom: 6px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
font-weight: 800;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.field input,
|
|
.field textarea,
|
|
.field select {
|
|
width: 100%;
|
|
min-height: 34px;
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
resize: vertical;
|
|
outline: none;
|
|
}
|
|
|
|
.field textarea {
|
|
min-height: 110px;
|
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.image-editor {
|
|
display: grid;
|
|
grid-template-columns: 150px minmax(0, 1fr);
|
|
gap: 14px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.image-preview {
|
|
width: 150px;
|
|
aspect-ratio: 1;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
display: grid;
|
|
place-items: center;
|
|
overflow: hidden;
|
|
color: var(--text-subdued);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.image-preview img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.cover-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(86px, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
.cover-option {
|
|
min-width: 0;
|
|
padding: 6px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
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: 5px;
|
|
display: block;
|
|
}
|
|
|
|
.cover-option span {
|
|
display: block;
|
|
margin-top: 5px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.artist-tags {
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.run-log-output.is-following {
|
|
border-color: rgba(29, 185, 84, 0.36);
|
|
}
|
|
|
|
.metadata-tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
max-height: 112px;
|
|
overflow: auto;
|
|
padding: 8px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.metadata-tags .tag {
|
|
height: auto;
|
|
min-height: 24px;
|
|
gap: 5px;
|
|
padding: 4px 8px;
|
|
}
|
|
|
|
.metadata-tags small {
|
|
font-size: 9px;
|
|
font-weight: 800;
|
|
opacity: 0.72;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.metadata-empty {
|
|
min-height: 34px;
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.artist-picker {
|
|
position: relative;
|
|
}
|
|
|
|
.artist-picker input {
|
|
width: 100%;
|
|
}
|
|
|
|
.artist-results {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
top: calc(100% + 5px);
|
|
z-index: 5;
|
|
max-height: 242px;
|
|
overflow: auto;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-elevated);
|
|
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.38);
|
|
}
|
|
|
|
.artist-result {
|
|
width: 100%;
|
|
min-height: 34px;
|
|
padding: 8px 10px;
|
|
border: 0;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.artist-result small {
|
|
display: block;
|
|
margin-top: 2px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.artist-result:hover,
|
|
.artist-result:focus {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.artist-result:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.tag button {
|
|
width: 16px;
|
|
height: 16px;
|
|
margin-left: 5px;
|
|
border: 0;
|
|
border-radius: 999px;
|
|
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, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.release-track-search-row {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.release-track-list {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.release-track-head,
|
|
.release-track-row {
|
|
display: grid;
|
|
grid-template-columns: 72px 82px minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, .9fr) 70px 36px;
|
|
gap: 8px;
|
|
align-items: center;
|
|
padding: 8px;
|
|
}
|
|
|
|
.release-track-head {
|
|
min-height: 34px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
color: var(--text-subdued);
|
|
font-size: 10px;
|
|
font-weight: 850;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.release-track-row {
|
|
min-height: 48px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
|
}
|
|
|
|
.release-track-row:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.release-track-row:hover {
|
|
background: rgba(255, 255, 255, 0.03);
|
|
}
|
|
|
|
.release-track-row input {
|
|
width: 100%;
|
|
height: 30px;
|
|
min-height: 30px;
|
|
padding: 0 8px;
|
|
}
|
|
|
|
.release-track-title,
|
|
.release-track-meta {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.release-track-title {
|
|
color: var(--text-primary);
|
|
font-size: 12px;
|
|
font-weight: 750;
|
|
}
|
|
|
|
.release-track-meta {
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.image-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.file-name {
|
|
max-width: 280px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.cover-option.active {
|
|
border-color: rgba(29, 185, 84, 0.8);
|
|
box-shadow: 0 0 0 1px rgba(29, 185, 84, 0.32);
|
|
}
|
|
|
|
.muted {
|
|
color: var(--text-subdued);
|
|
}
|
|
|
|
.empty {
|
|
padding: 28px;
|
|
color: var(--text-subdued);
|
|
text-align: center;
|
|
}
|
|
|
|
.toast {
|
|
position: fixed;
|
|
right: 18px;
|
|
bottom: 18px;
|
|
z-index: 20;
|
|
max-width: 420px;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-elevated);
|
|
color: var(--text-primary);
|
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.loading-mask {
|
|
position: absolute;
|
|
inset: 64px 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: 28px;
|
|
background: rgba(0, 0, 0, 0.62);
|
|
}
|
|
|
|
.modal {
|
|
width: min(760px, calc(100vw - 80px));
|
|
max-height: calc(100vh - 80px);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-secondary);
|
|
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.56);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.modal-head {
|
|
min-height: 58px;
|
|
padding: 12px 14px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.modal-body {
|
|
max-height: calc(100vh - 154px);
|
|
padding: 14px;
|
|
overflow: auto;
|
|
}
|
|
|
|
.user-modal {
|
|
width: min(860px, calc(100vw - 80px));
|
|
}
|
|
|
|
.user-modal-tabs {
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.user-summary-grid,
|
|
.user-profile-facts {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 8px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.user-summary-card,
|
|
.user-profile-facts > div {
|
|
min-width: 0;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.user-summary-card span,
|
|
.user-profile-facts span {
|
|
display: block;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.user-summary-card strong,
|
|
.user-profile-facts strong {
|
|
display: block;
|
|
overflow: hidden;
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.user-stats-grid {
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
}
|
|
|
|
.user-activity-list {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.user-activity-row {
|
|
min-width: 0;
|
|
padding: 8px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
display: grid;
|
|
grid-template-columns: 52px minmax(0, 1fr) auto;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.user-activity-cover {
|
|
width: 52px;
|
|
height: 52px;
|
|
border-radius: 4px;
|
|
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: 24px;
|
|
height: 24px;
|
|
}
|
|
|
|
.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: 13px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.user-activity-meta {
|
|
margin-top: 3px;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.user-activity-sub {
|
|
margin-top: 3px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.user-activity-time {
|
|
min-width: 118px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
text-align: right;
|
|
}
|
|
|
|
.user-activity-time strong {
|
|
display: block;
|
|
color: var(--text-primary);
|
|
font-size: 12px;
|
|
}
|
|
</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>
|
|
<button class="nav-btn" :class="{active: activeView === 'reviews'}" @click="openReviews()">
|
|
<i data-lucide="inbox"></i>
|
|
<span>Review Queue</span>
|
|
<span class="nav-count" x-text="reviewTotalAll()"></span>
|
|
</button>
|
|
<button class="nav-btn" :class="{active: activeView === 'jobs'}" @click="openJobs()">
|
|
<i data-lucide="calendar-clock"></i>
|
|
<span>Tasks</span>
|
|
<span class="nav-count" x-text="jobs.length || 0"></span>
|
|
</button>
|
|
<button class="nav-btn" :class="{active: activeView === 'library'}" @click="openLibrary(libraryKind)">
|
|
<i data-lucide="library"></i>
|
|
<span>Library Workbench</span>
|
|
<span class="nav-count" x-text="fmt(stats.tracks || 0)"></span>
|
|
</button>
|
|
<button class="nav-btn" :class="{active: activeView === 'tools'}" @click="openTools()">
|
|
<i data-lucide="wrench"></i>
|
|
<span>Future Tools</span>
|
|
</button>
|
|
<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>
|
|
<button class="nav-btn" :class="{active: activeView === 'settings'}" @click="openSettings()">
|
|
<i data-lucide="settings"></i>
|
|
<span>Settings</span>
|
|
<span class="nav-count" x-text="settings.lastfm_scrobbling_configured ? 'ok' : ''"></span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="nav-group">
|
|
<div class="nav-label">Entities</div>
|
|
<button class="nav-btn" @click="openLibrary('artists')">
|
|
<i data-lucide="mic-2"></i>
|
|
<span>Artists</span>
|
|
<span class="nav-count" x-text="fmt(libraryOverview.artists || 0)"></span>
|
|
</button>
|
|
<button class="nav-btn" @click="openLibrary('releases')">
|
|
<i data-lucide="disc-3"></i>
|
|
<span>Releases</span>
|
|
<span class="nav-count" x-text="fmt(libraryOverview.releases || 0)"></span>
|
|
</button>
|
|
<button class="nav-btn" @click="openLibrary('playlists')">
|
|
<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()" x-show="activeView !== 'settings'">
|
|
<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">
|
|
<div class="stat-cell" :title="cell.title || ''">
|
|
<div class="stat-value" x-text="cell.display || fmt(cell.value)"></div>
|
|
<div class="stat-label" x-text="cell.label"></div>
|
|
</div>
|
|
</template>
|
|
</section>
|
|
|
|
<div>
|
|
<section class="panel jobs-list-panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Pending Reviews</strong>
|
|
<span x-text="reviewPanelSubtitle()"></span>
|
|
</div>
|
|
<div class="toolbar">
|
|
<div class="segmented">
|
|
<template x-for="status in reviewStatuses" :key="status.value">
|
|
<button class="seg-btn" :class="{active: reviewFilter.status === status.value}" @click="setReviewStatus(status.value)">
|
|
<span x-text="status.label"></span>
|
|
<span class="muted" x-text="status.value ? statusCount(status.value) : reviewTotalAll()"></span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
<input class="search" placeholder="Search queue" x-model="reviewFilter.search" @input.debounce.350ms="loadReviews()" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="action-strip">
|
|
<div class="toolbar">
|
|
<button class="btn" @click="selectVisibleReviews()">
|
|
<i data-lucide="check-square"></i>
|
|
Select visible
|
|
</button>
|
|
<button class="btn" @click="selectReviewFilter()" :disabled="reviews.total === 0">
|
|
<i data-lucide="list-checks"></i>
|
|
<span x-text="`Select all (${fmt(reviews.total)})`"></span>
|
|
</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>
|
|
<template x-for="job in jobs" :key="job.name">
|
|
<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">
|
|
<template x-for="run in (job.recent_runs || []).slice(0, 3)" :key="run.id">
|
|
<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">
|
|
<button class="icon-btn" type="button" @click.stop="runJob(job)" :disabled="Boolean(job.launching)" title="Run now">
|
|
<i data-lucide="play"></i>
|
|
</button>
|
|
<button class="icon-btn" type="button" @click.stop="toggleJob(job)" :title="job.enabled ? 'Disable' : 'Enable'">
|
|
<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>
|
|
<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>
|
|
</div>
|
|
<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>
|
|
<label><input type="checkbox" x-model="metadataBackfillOptions.musicbrainz_tags" /> MusicBrainz tags</label>
|
|
</div>
|
|
<div class="mode-row">
|
|
<label><input type="radio" value="fill_missing" x-model="metadataBackfillOptions.mode" /> Fill missing only</label>
|
|
<label><input type="radio" value="overwrite" x-model="metadataBackfillOptions.mode" /> Overwrite existing values</label>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
<div class="job-param-note" x-show="activeJob && !jobHasParameterForm(activeJob)">
|
|
This task has no manual parameters.
|
|
</div>
|
|
<div class="toolbar" style="margin-bottom:12px">
|
|
<button class="btn primary" type="button" @click="runJob(activeJob)">
|
|
<i data-lucide="play"></i>
|
|
<span x-text="isMetadataBackfillJob(activeJob) ? 'Run with options' : 'Run now'"></span>
|
|
</button>
|
|
<button class="btn" type="button" @click="toggleJob(activeJob)">
|
|
<i data-lucide="power"></i>
|
|
Toggle
|
|
</button>
|
|
</div>
|
|
<div class="field">
|
|
<label>Recent Runs</label>
|
|
<div class="runs">
|
|
<template x-for="run in pagedVisibleRuns()" :key="run.id">
|
|
<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>
|
|
<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>
|
|
</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>
|
|
<button class="btn" type="button" x-show="!runLogAutoScroll" @click="enableRunLogFollow()">
|
|
<i data-lucide="chevrons-down"></i>
|
|
Follow
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="run-log-body">
|
|
<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>
|
|
<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">
|
|
<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>
|
|
<button class="seg-btn" :class="{active: libraryKind === 'tracks'}" @click="openLibrary('tracks')">Tracks</button>
|
|
<button class="seg-btn" :class="{active: libraryKind === 'playlists'}" @click="openLibrary('playlists')">Playlists</button>
|
|
</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 primary" x-show="libraryKind === 'releases'" @click="openReleaseCreator()">
|
|
<i data-lucide="plus"></i>
|
|
New release
|
|
</button>
|
|
<button class="btn warn" @click="mockAction('Merge wizard will open from this action slot')">
|
|
<i data-lucide="git-merge"></i>
|
|
Merge
|
|
</button>
|
|
<button class="btn danger" @click="bulkLibrary('delete')" :disabled="selectedLibraryCount() === 0">
|
|
<i data-lucide="trash-2"></i>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<template x-for="item in library.items" :key="item.kind + item.id">
|
|
<div class="library-row" :class="{active: activeLibraryItem && activeLibraryItem.id === item.id && activeLibraryItem.kind === item.kind}" @click="openEditor(item)">
|
|
<input class="check" type="checkbox" :checked="isLibrarySelected(item)" @click.stop @change="toggleLibrary(item)" />
|
|
<div>
|
|
<div class="primary-line" x-text="item.title"></div>
|
|
<div class="secondary-line" x-text="item.subtitle || item.kind"></div>
|
|
</div>
|
|
<div class="tags">
|
|
<template x-for="tag in item.tags" :key="tag.kind + tag.label">
|
|
<span class="tag" :class="tag.kind" x-text="tag.label"></span>
|
|
</template>
|
|
</div>
|
|
<span class="badge" :class="item.is_hidden ? 'disabled' : 'ok'" x-text="item.is_hidden ? 'hidden' : 'visible'"></span>
|
|
</div>
|
|
</template>
|
|
<div class="empty" x-show="!libraryLoading && library.items.length === 0">No library rows in this filter</div>
|
|
</div>
|
|
<div class="action-strip">
|
|
<span class="selection-summary" x-text="libraryRangeText()"></span>
|
|
<div class="toolbar">
|
|
<select class="search" style="width:92px" x-model.number="library.limit" @change="library.offset = 0; loadLibrary(false)">
|
|
<option value="25">25</option>
|
|
<option value="40">40</option>
|
|
<option value="80">80</option>
|
|
<option value="120">120</option>
|
|
</select>
|
|
<button class="btn" @click="pageLibrary(-1)" :disabled="library.offset === 0">Previous</button>
|
|
<button class="btn" @click="pageLibrary(1)" :disabled="library.offset + library.limit >= library.total">Next</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content" x-show="activeView === '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>
|
|
|
|
<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>
|
|
|
|
<div class="content" x-show="activeView === 'settings'">
|
|
<div class="settings-page">
|
|
<form class="settings-layout" @submit.prevent="saveSettings()">
|
|
<div class="settings-column">
|
|
<section class="panel settings-section settings-section-wide settings-oidc">
|
|
<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 class="setting-help">Register this exact redirect URL in your identity provider.</div>
|
|
</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 class="setting-help">Comma-separated identity-provider groups whose members receive administrator access.</div>
|
|
</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 class="setting-help">Comma-separated groups allowed to sign in. Leave empty to allow any authenticated OIDC user.</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel settings-section settings-section-wide settings-agent">
|
|
<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 settings-short">
|
|
<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 class="setting-help">Maximum number of inbox items processed at the same time. Higher values use more CPU and LLM capacity.</div>
|
|
</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 class="setting-help">Base URL of an OpenAI-compatible service. The agent sends chat requests to its <code>/v1/chat/completions</code> endpoint.</div>
|
|
</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 class="setting-help">Model identifier sent to the configured LLM service, for example the name exposed by your local model server.</div>
|
|
</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 class="setting-help">Complete HTTP Authorization value expected by the LLM endpoint, for example <code>Bearer …</code>.</div>
|
|
</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 class="setting-help">Minimum confidence required to accept generated metadata automatically. Lower-confidence results are sent for review.</div>
|
|
</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 class="setting-help">Maximum model context budget in tokens. Reduce it for smaller models or increase it when processing large batches.</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel settings-section settings-section-wide settings-similarity">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Similarity Search</strong>
|
|
<span>Local audio embeddings and federated nearest-track search</span>
|
|
</div>
|
|
</div>
|
|
<div class="settings-grid">
|
|
<div class="setting-toggle settings-wide">
|
|
<label>
|
|
<span>Similarity search enabled</span>
|
|
<span class="source-pill" :class="sourceClass('similarity_enabled')" x-text="settingSource('similarity_enabled')"></span>
|
|
</label>
|
|
<div class="setting-toggle-row">
|
|
<span x-text="settingsDraft.similarity_enabled ? 'Enabled for this instance' : 'Disabled'"></span>
|
|
<input type="checkbox" x-model="settingsDraft.similarity_enabled" />
|
|
</div>
|
|
<div class="setting-help">Builds an audio fingerprint index for finding musically similar tracks. When federation is enabled, compatible peers can also participate in searches.</div>
|
|
</div>
|
|
<div class="setting-field settings-wide">
|
|
<label>
|
|
<span>Embedding model</span>
|
|
<span class="source-pill" :class="sourceClass('similarity_model')" x-text="settingSource('similarity_model')"></span>
|
|
</label>
|
|
<select x-model="settingsDraft.similarity_model">
|
|
<template x-for="model in similarityStatus.models || []" :key="model.id">
|
|
<option :value="model.id" x-text="`${model.id} · ${model.dimensions}d`"></option>
|
|
</template>
|
|
</select>
|
|
<div class="setting-help">
|
|
<span>The model converts audio into vectors used for comparison. Changing it rebuilds the search index.</span>
|
|
<span x-show="(similarityStatus.models || []).find(model => model.id === settingsDraft.similarity_model)?.license" x-text="' License: ' + (similarityStatus.models || []).find(model => model.id === settingsDraft.similarity_model)?.license"></span>
|
|
</div>
|
|
</div>
|
|
<div class="setting-field settings-wide">
|
|
<label>
|
|
<span>Preprocessing profile</span>
|
|
<span class="source-pill" :class="sourceClass('similarity_profile')" x-text="settingSource('similarity_profile')"></span>
|
|
</label>
|
|
<select x-model="settingsDraft.similarity_profile">
|
|
<template x-for="profile in similarityStatus.profiles || []" :key="profile.id">
|
|
<option :value="profile.id" x-text="profile.title"></option>
|
|
</template>
|
|
</select>
|
|
<div class="setting-help">Controls how audio is decoded and normalized before comparison. Changing it rebuilds the search index.</div>
|
|
</div>
|
|
<div class="setting-field settings-short">
|
|
<label>
|
|
<span>Background workers</span>
|
|
<span class="source-pill" :class="sourceClass('similarity_workers')" x-text="settingSource('similarity_workers')"></span>
|
|
</label>
|
|
<input type="number" min="1" max="16" step="1" x-model="settingsDraft.similarity_workers" />
|
|
<div class="setting-help">Number of tracks indexed in parallel. Higher values finish sooner but use more CPU and memory.</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="settings-column settings-side">
|
|
<section class="panel settings-section settings-section-narrow settings-access">
|
|
<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 settings-section settings-lastfm">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Last.fm Integration</strong>
|
|
<span>Metadata enrichment and scrobbling credentials</span>
|
|
</div>
|
|
<span class="badge" :class="settings.lastfm_scrobbling_configured ? 'ok' : 'disabled'" x-text="settings.lastfm_scrobbling_configured ? 'Last.fm configured' : 'Last.fm missing'"></span>
|
|
</div>
|
|
<div class="settings-grid">
|
|
<div class="setting-field">
|
|
<label>
|
|
<span>{{ t.settings_lastfm_api_key }}</span>
|
|
<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" />
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel settings-section settings-developer">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Developer API</strong>
|
|
<span>Interactive API documentation</span>
|
|
</div>
|
|
</div>
|
|
<div class="settings-grid">
|
|
<div class="setting-toggle settings-wide">
|
|
<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 ? 'Available at /swagger/' : 'Disabled' "></span>
|
|
<input type="checkbox" x-model="settingsDraft.swagger_enabled" />
|
|
</div>
|
|
<div class="setting-help">Exposes interactive API documentation at <code>/swagger/</code> for developers and integrations.</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel settings-section settings-section-full settings-federation">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Federation</strong>
|
|
<span>Publish this library into the furumi P2P network</span>
|
|
</div>
|
|
<span class="badge" :class="federationStatus.node && federationStatus.node.running ? 'ok' : 'disabled'" x-text="federationStatus.node && federationStatus.node.running ? 'running' : 'stopped'"></span>
|
|
</div>
|
|
<div class="settings-federation-body">
|
|
<div class="settings-grid">
|
|
<div class="setting-toggle">
|
|
<label>
|
|
<span>Federation enabled</span>
|
|
<span class="source-pill" :class="sourceClass('federation_enabled')" x-text="settingSource('federation_enabled')"></span>
|
|
</label>
|
|
<div class="setting-toggle-row">
|
|
<span x-text="settingsDraft.federation_enabled ? 'Enabled' : 'Disabled'"></span>
|
|
<input type="checkbox" x-model="settingsDraft.federation_enabled" />
|
|
</div>
|
|
<div class="setting-help">Lets other peers in this logical network discover the visible library and request audio streams from this instance.</div>
|
|
</div>
|
|
<div class="setting-field settings-wide">
|
|
<label>
|
|
<span>Network ID (shared secret)</span>
|
|
<span class="source-pill" :class="sourceClass('federation_network_id')" x-text="settingSource('federation_network_id')"></span>
|
|
</label>
|
|
<input x-model="settingsDraft.federation_network_id" placeholder="my-crew-music-7f3a" autocomplete="off" />
|
|
<div class="setting-help">Peers with the same Network ID form one isolated logical network and discover each other automatically. You may choose any private value; use the exact same value on every peer that should join this network.</div>
|
|
</div>
|
|
<div class="setting-field settings-wide">
|
|
<label>
|
|
<span>Save federated tracks on play</span>
|
|
<span class="source-pill" :class="sourceClass('federation_save_on_listen')" x-text="settingSource('federation_save_on_listen')"></span>
|
|
</label>
|
|
<div class="setting-toggle-row">
|
|
<span x-text="settingsDraft.federation_save_on_listen ? 'Import into the shared library' : 'Use temporary cache'"></span>
|
|
<input type="checkbox" x-model="settingsDraft.federation_save_on_listen" />
|
|
</div>
|
|
<div class="setting-help">When enabled, a federated track is permanently imported after playback and becomes available to every local user. Otherwise it remains only in the temporary cache. Imported peer metadata is trusted as provided and does not enter AI review.</div>
|
|
</div>
|
|
</div>
|
|
<div class="probe-body" x-show="federationStatus.node">
|
|
<div class="federation-status-grid">
|
|
<div class="probe-table" x-show="federationStatus.node && federationStatus.node.running">
|
|
<div class="probe-row"><span>Endpoint</span><strong x-text="fedShort(federationStatus.node && federationStatus.node.endpoint_id)"></strong></div>
|
|
<div class="probe-row"><span>Network</span><strong x-text="(federationStatus.node && federationStatus.node.network) || '-'"></strong></div>
|
|
<div class="probe-row"><span>Connected peers</span><strong x-text="federationStatus.node && federationStatus.node.connected_peers ? federationStatus.node.connected_peers.length : 0"></strong></div>
|
|
<div class="probe-row"><span>Known contacts</span><strong x-text="(federationStatus.node && federationStatus.node.known_contacts) ?? '-'"></strong></div>
|
|
<div class="probe-row"><span>Similarity routing peers</span><strong x-text="(federationStatus.node && federationStatus.node.similarity_routing_peers) ?? '-'"></strong></div>
|
|
<div class="probe-row"><span>Published items</span><strong x-text="(federationStatus.node && federationStatus.node.published_items) ?? '-'"></strong></div>
|
|
<div class="probe-row"><span>Last sync</span><strong x-text="federationStatus.last_sync || 'not yet'"></strong></div>
|
|
</div>
|
|
<div class="probe-table" x-show="fedTransport().total_samples > 0">
|
|
<div class="probe-row">
|
|
<span>Transport path</span>
|
|
<strong>
|
|
<span class="badge" :class="fedPathBadge(fedTransport().last_path)" x-text="fedTransport().last_path || 'unknown'"></span>
|
|
</strong>
|
|
</div>
|
|
<div class="probe-row"><span>RTT</span><strong x-text="fedRtt(fedTransport().last_rtt_ms)"></strong></div>
|
|
<div class="probe-row"><span>Path samples</span><strong x-text="`${fedTransport().direct_samples || 0} direct · ${fedTransport().relay_samples || 0} relay · ${fedTransport().custom_samples || 0} custom · ${fedTransport().unknown_samples || 0} unknown`"></strong></div>
|
|
<div class="probe-row"><span>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().similarity_samples || 0} similarity · ${fedTransport().sync_samples || 0} sync`"></strong></div>
|
|
<div class="probe-row"><span>Last peer</span><strong x-text="fedShort(fedTransport().last_peer)"></strong></div>
|
|
</div>
|
|
<div class="transport-log" x-show="fedTransport().last && fedTransport().last.length">
|
|
<div class="transport-log-head">
|
|
<strong>Recent transport operations</strong>
|
|
<span>Newest first · updates automatically</span>
|
|
</div>
|
|
<div class="transport-log-scroll">
|
|
<div class="transport-row transport-header">
|
|
<span>Time</span><span>User / peer</span><span>Protocol</span><span>Direction</span><span>Phase</span><span>Path</span><span class="transport-number">RTT</span><span class="transport-number">TX</span><span class="transport-number">RX</span><span class="transport-number">Lost</span>
|
|
</div>
|
|
<template x-for="(sample, index) in fedTransport().last" :key="`${sample.at}-${sample.protocol}-${sample.direction}-${sample.phase}-${sample.peer_id}-${index}`">
|
|
<div class="transport-row">
|
|
<span :title="sample.at" x-text="formatTransportTime(sample.at)"></span>
|
|
<span :title="sample.user_name || sample.peer_id" x-text="sample.user_name || fedShort(sample.peer_id)"></span>
|
|
<span x-text="sample.protocol || '-'"></span>
|
|
<span x-text="sample.direction || '-'"></span>
|
|
<span x-text="sample.phase || '-'"></span>
|
|
<span x-text="sample.selected_path || 'unknown'"></span>
|
|
<span class="transport-number" x-text="fedRtt(sample.selected_rtt_ms)"></span>
|
|
<span class="transport-number" x-text="formatBytes(sample.total_tx_bytes || 0)"></span>
|
|
<span class="transport-number" x-text="formatBytes(sample.total_rx_bytes || 0)"></span>
|
|
<span class="transport-number" x-text="formatBytes(sample.lost_bytes || 0)"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p class="probe-intro muted" x-show="federationStatus.last_error" x-text="federationStatus.last_error"></p>
|
|
<div class="toolbar" style="margin-top:14px; flex-wrap:wrap; gap:8px">
|
|
<button class="btn" type="button" @click="fedSyncNow()" :disabled="federationLoading || !(federationStatus.node && federationStatus.node.running)">
|
|
<i data-lucide="upload-cloud"></i>
|
|
Publish now
|
|
</button>
|
|
<button class="btn" type="button" @click="fedShowTicket()" :disabled="federationLoading || !(federationStatus.node && federationStatus.node.running)">
|
|
<i data-lucide="ticket"></i>
|
|
Show ticket
|
|
</button>
|
|
</div>
|
|
<div class="setting-field settings-wide" x-show="federationTicket" style="margin-top:10px">
|
|
<label>Connection ticket (share with a peer)</label>
|
|
<textarea readonly rows="3" style="width:100%; font-family:monospace; font-size:11px" x-text="federationTicket"></textarea>
|
|
</div>
|
|
<div class="setting-field settings-wide" x-show="federationStatus.node && federationStatus.node.running" style="margin-top:10px">
|
|
<label>Connect to a peer by ticket</label>
|
|
<div style="display:flex; gap:8px">
|
|
<input x-model="fedConnectTicket" placeholder="fnet..." style="flex:1" autocomplete="off" />
|
|
<button class="btn" type="button" @click="fedConnect()" :disabled="federationLoading || !fedConnectTicket.trim()">Connect</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel settings-section settings-section-narrow settings-similarity-status">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Search Index Statistics</strong>
|
|
<span>Similarity model, indexing progress, and storage</span>
|
|
</div>
|
|
<span class="badge" :class="similarityBadge()" x-text="similarityStatus.status?.phase || 'disabled'"></span>
|
|
</div>
|
|
<div class="probe-body">
|
|
<div class="probe-table">
|
|
<div class="probe-row"><span>Model</span><strong x-text="similarityStatus.status?.model || settingsDraft.similarity_model || '-' "></strong></div>
|
|
<div class="probe-row"><span>Active profile</span><strong :title="similarityStatus.status?.active_profile || ''" x-text="similarityStatus.status?.active_profile ? similarityStatus.status.active_profile.slice(0, 22) + '…' : 'not ready'"></strong></div>
|
|
<div class="probe-row" x-show="similarityStatus.status?.target_profile && similarityStatus.status?.target_profile !== similarityStatus.status?.active_profile">
|
|
<span>Processing profile</span><strong :title="similarityStatus.status?.target_profile || ''" x-text="similarityStatus.status?.target_profile?.slice(0, 22) + '…'"></strong>
|
|
</div>
|
|
<div class="probe-row"><span>Visible tracks</span><strong x-text="similarityStatus.status?.total_tracks ?? 0"></strong></div>
|
|
<div class="probe-row"><span>Processed</span><strong x-text="`${similarityStatus.status?.completed_tracks || 0} · ${similarityProgress()}%`"></strong></div>
|
|
<div class="probe-row"><span>Failed</span><strong x-text="similarityStatus.status?.failed_tracks || 0"></strong></div>
|
|
<div class="probe-row"><span>Stored</span><strong x-text="`${similarityStatus.status?.stored_vectors || 0} vectors · ${formatBytes(similarityStatus.status?.stored_bytes || 0)}`"></strong></div>
|
|
<div class="probe-row" x-show="similarityStatus.status?.current_track"><span>Current track</span><strong x-text="similarityStatus.status?.current_track"></strong></div>
|
|
</div>
|
|
<div class="similarity-profile-details">
|
|
<span>Selected preprocessing profile</span>
|
|
<pre x-text="selectedSimilarityProfile()?.details || 'Profile information is not available.'"></pre>
|
|
</div>
|
|
<div style="height:6px;background:rgba(255,255,255,.08);border-radius:999px;overflow:hidden;margin-top:12px" x-show="similarityStatus.status?.phase === 'processing'">
|
|
<div style="height:100%;background:var(--accent);transition:width .25s" :style="`width:${similarityProgress()}%`"></div>
|
|
</div>
|
|
<p class="probe-intro muted" x-show="similarityStatus.status?.last_error" x-text="similarityStatus.status?.last_error"></p>
|
|
<div class="toolbar" style="margin-top:14px;flex-wrap:wrap;gap:8px">
|
|
<button class="btn danger" type="button" @click="clearSimilarityEmbeddings()" :disabled="similarityLoading || !(similarityStatus.status?.stored_vectors > 0)">
|
|
<i data-lucide="trash-2"></i>
|
|
Clear all embeddings
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel settings-section settings-section-narrow settings-agent-status">
|
|
<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>
|
|
<div class="toolbar">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<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>
|
|
<button class="seg-btn" :class="{active: userModalTab === 'activity'}" @click="userModalTab = 'activity'">Activity</button>
|
|
<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>
|
|
<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="" aria-hidden="true" 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>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<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>
|
|
<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>
|
|
<div class="toolbar">
|
|
<button class="btn primary" x-show="activeReview?.status === 'pending'" @click="approveActiveReview()">
|
|
<i data-lucide="check"></i>
|
|
Approve
|
|
</button>
|
|
<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="editorTitle()"></strong>
|
|
<span x-text="editorSubtitle()"></span>
|
|
</div>
|
|
<button class="icon-btn" @click="editorOpen = false">
|
|
<i data-lucide="x"></i>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body" x-show="activeLibraryItem">
|
|
<div class="empty" x-show="editorLoading">Loading editor...</div>
|
|
<div x-show="!editorLoading">
|
|
<div class="field">
|
|
<label x-text="isArtistEditor() ? 'Artist name' : (isReleaseEditor() ? 'Release title' : (isTrackEditor() ? 'Track title' : 'Title'))"></label>
|
|
<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>
|
|
|
|
<div class="field" x-show="isReleaseEditor()">
|
|
<label>Release tracks</label>
|
|
<div class="release-track-search-row">
|
|
<div class="artist-picker">
|
|
<input class="search" placeholder="Search track" x-model="releaseTrackSearch" @input.debounce.300ms="searchReleaseTracks()" @keydown.enter.prevent="addBestReleaseTrack()" @keydown.escape="clearReleaseTrackSearch()" />
|
|
<div class="artist-results" x-show="releaseTrackSearchOpen()" x-transition>
|
|
<template x-for="track in availableReleaseTrackResults()" :key="track.id">
|
|
<button class="artist-result" type="button" @click="addReleaseTrack(track)">
|
|
<span x-text="track.title"></span>
|
|
<small x-text="releaseTrackSearchMeta(track)"></small>
|
|
</button>
|
|
</template>
|
|
<div class="artist-result muted" x-show="releaseTrackSearchLoading">Searching...</div>
|
|
<div class="artist-result muted" x-show="!releaseTrackSearchLoading && availableReleaseTrackResults().length === 0">No matching tracks</div>
|
|
</div>
|
|
</div>
|
|
<button class="btn" type="button" @click="addBestReleaseTrack()" :disabled="!releaseTrackSearch.trim()">
|
|
<i data-lucide="plus"></i>
|
|
Add
|
|
</button>
|
|
</div>
|
|
<div class="release-track-list" x-show="releaseTracks().length">
|
|
<div class="release-track-head">
|
|
<span>Disc</span>
|
|
<span>Track #</span>
|
|
<span>Title</span>
|
|
<span>Artists</span>
|
|
<span>Current release</span>
|
|
<span>Time</span>
|
|
<span></span>
|
|
</div>
|
|
<template x-for="track in releaseTracks()" :key="track.id">
|
|
<div class="release-track-row">
|
|
<input type="number" min="1" max="999" x-model="track.disc_number" />
|
|
<input type="number" min="1" max="9999" x-model="track.track_number" />
|
|
<div class="release-track-title" x-text="track.title"></div>
|
|
<div class="release-track-meta" x-text="track.artists || 'Unknown artist'"></div>
|
|
<div class="release-track-meta" x-text="releaseTrackOrigin(track)"></div>
|
|
<div class="release-track-meta" x-text="trackDuration(track.duration_seconds)"></div>
|
|
<button class="icon-btn" type="button" @click="removeReleaseTrack(track.id)" title="Remove from release">
|
|
<i data-lucide="x"></i>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="empty" x-show="!releaseTracks().length">No tracks attached</div>
|
|
</div>
|
|
|
|
<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>
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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...' : (editorIsNewRelease() ? 'Create' : 'Save')"></span>
|
|
</button>
|
|
<button class="btn danger" x-show="!editorIsNewRelease()" @click="deleteLibraryItem(activeLibraryItem)" :disabled="editorSaving || editorImageUploading">
|
|
<i data-lucide="trash-2"></i>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</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: {},
|
|
runtime: { agent: {}, storage: [], node: {} },
|
|
libraryOverview: {},
|
|
reviews: { items: [], total: 0, total_all: 0, limit: 80, offset: 0, status_counts: [] },
|
|
users: { items: [], total: 0, limit: 40, offset: 0, online_count: 0 },
|
|
usersLoading: false,
|
|
userSearch: '',
|
|
userModalOpen: false,
|
|
userModalTab: 'overview',
|
|
activeUserDetail: null,
|
|
reviewFilter: { status: null, search: '' },
|
|
selectedReviewIds: {},
|
|
reviewSelectionScope: 'ids',
|
|
activeReview: null,
|
|
reviewDraft: {
|
|
title: '',
|
|
artist: '',
|
|
album: '',
|
|
year: '',
|
|
track_number: '',
|
|
genre: '',
|
|
featured_artists: '',
|
|
release_type: 'album',
|
|
notes: ''
|
|
},
|
|
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: [],
|
|
activeJobRuns: [],
|
|
activeJobRunsName: null,
|
|
activeJobName: null,
|
|
activeRunDetail: null,
|
|
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,
|
|
musicbrainz_tags: true,
|
|
mode: 'fill_missing'
|
|
},
|
|
artworkBackfillOptions: {
|
|
mode: 'missing'
|
|
},
|
|
libraryKind: 'artists',
|
|
librarySearch: '',
|
|
library: { items: [], total: 0, limit: 40, offset: 0 },
|
|
selectedLibraryIds: {},
|
|
librarySelectionScope: 'ids',
|
|
activeLibraryItem: null,
|
|
editorOpen: false,
|
|
editorLoading: false,
|
|
editorSaving: false,
|
|
editorImageUploading: false,
|
|
editorImageFile: null,
|
|
editorArtistToAdd: '',
|
|
editorReleaseToAdd: '',
|
|
releaseTrackSearch: '',
|
|
releaseTrackSearchResults: [],
|
|
releaseTrackSearchLoading: false,
|
|
releaseTrackSearchToken: 0,
|
|
editorDetail: null,
|
|
editorDraft: { title: '', hidden: 'false', release_type: 'album', year: '', release_id: null, track_number: '', disc_number: '', artist_ids: [], release_tracks: [] },
|
|
settings: { values: {}, sources: {}, lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false },
|
|
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: '',
|
|
lastfm_shared_secret: '',
|
|
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: '',
|
|
federation_enabled: false,
|
|
federation_network_id: '',
|
|
federation_save_on_listen: false,
|
|
similarity_enabled: false,
|
|
similarity_model: 'discogs-effnet-bsdynamic-1',
|
|
similarity_profile: 'furumi-full-track-v1',
|
|
similarity_workers: '1'
|
|
},
|
|
settingsProbe: { status: 'idle', ok: false },
|
|
settingsProbeLoading: false,
|
|
federationStatus: {},
|
|
federationLoading: false,
|
|
federationTicket: '',
|
|
fedConnectTicket: '',
|
|
similarityStatus: { status: { phase: 'disabled' }, models: [], profiles: [] },
|
|
similarityLoading: false,
|
|
settingsSaving: false,
|
|
routeReady: false,
|
|
poller: null,
|
|
|
|
async init() {
|
|
this.applyRouteFromHash();
|
|
await this.refreshAll();
|
|
this.routeReady = true;
|
|
this.activateCurrentView(false);
|
|
window.addEventListener('hashchange', () => {
|
|
this.applyRouteFromHash();
|
|
this.activateCurrentView(false);
|
|
});
|
|
this.poller = setInterval(() => this.poll(), 4000);
|
|
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 || {};
|
|
this.runtime = data.runtime || this.runtime;
|
|
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;
|
|
await this.loadSettings(false);
|
|
await this.loadLibrary(false);
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.loading = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async poll() {
|
|
if (this.loading) return;
|
|
if (this.activeView === 'jobs') {
|
|
await Promise.allSettled([
|
|
this.loadJobs(false),
|
|
this.activeJobName ? this.loadRunsForJob(this.activeJobName, false) : Promise.resolve(),
|
|
this.refreshActiveRunDetail()
|
|
]);
|
|
} else if (this.activeView === 'users') {
|
|
await Promise.allSettled([this.loadUsers(false)]);
|
|
} else if (this.activeView === 'settings') {
|
|
await Promise.allSettled([
|
|
this.loadFederation(false),
|
|
this.loadSimilarity(false)
|
|
]);
|
|
} else {
|
|
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
|
|
}
|
|
},
|
|
|
|
applyRouteFromHash() {
|
|
const raw = (window.location.hash || '#reviews').replace(/^#\/?/, '');
|
|
const parts = raw.split('/').filter(Boolean);
|
|
const view = parts[0] || 'reviews';
|
|
if (view === 'reviews') {
|
|
const nextStatus = parts[1] || null;
|
|
if (this.reviewFilter.status !== nextStatus) this.clearReviewSelection();
|
|
this.activeView = 'reviews';
|
|
this.reviewFilter.status = nextStatus;
|
|
} else if (view === 'jobs') {
|
|
this.activeView = 'jobs';
|
|
if (parts[1]) {
|
|
const nextJobName = decodeURIComponent(parts[1]);
|
|
if (this.activeJobName !== nextJobName) {
|
|
this.activeJobRuns = [];
|
|
this.activeJobRunsName = nextJobName;
|
|
this.recentRunsPage = 0;
|
|
}
|
|
this.activeJobName = nextJobName;
|
|
}
|
|
} else if (view === 'library') {
|
|
const nextKind = ['artists', 'releases', 'tracks', 'playlists'].includes(parts[1]) ? parts[1] : 'artists';
|
|
if (this.libraryKind !== nextKind) this.clearLibrarySelection();
|
|
this.activeView = 'library';
|
|
this.libraryKind = nextKind;
|
|
} else if (view === 'settings') {
|
|
this.activeView = 'settings';
|
|
} else if (view === 'users') {
|
|
this.activeView = 'users';
|
|
} 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();
|
|
await Promise.allSettled([
|
|
this.loadFederation(false),
|
|
this.loadSimilarity(false)
|
|
]);
|
|
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
|
await this.loadSettingsProbe(false);
|
|
}
|
|
} else if (this.activeView === 'users') {
|
|
if (updateRoute) this.setRoute('#users');
|
|
await this.loadUsers(false);
|
|
} else if (this.activeView === 'tools' && updateRoute) {
|
|
this.setRoute('#tools');
|
|
}
|
|
},
|
|
|
|
openReviews(status = null) {
|
|
if (this.reviewFilter.status !== status) this.clearReviewSelection();
|
|
this.activeView = 'reviews';
|
|
this.reviewFilter.status = status;
|
|
this.setRoute(status ? `#reviews/${status}` : '#reviews');
|
|
this.loadReviews();
|
|
},
|
|
|
|
openJobs(name = this.activeJobName) {
|
|
this.activeView = 'jobs';
|
|
if (name && this.activeJobName !== name) {
|
|
this.activeJobRuns = [];
|
|
this.activeJobRunsName = name;
|
|
this.recentRunsPage = 0;
|
|
this.activeJobName = name;
|
|
} else if (name) {
|
|
this.activeJobName = name;
|
|
}
|
|
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';
|
|
const nextKind = ['artists', 'releases', 'tracks', 'playlists'].includes(kind) ? kind : 'artists';
|
|
if (this.libraryKind !== nextKind) this.clearLibrarySelection();
|
|
this.libraryKind = nextKind;
|
|
this.setRoute(`#library/${this.libraryKind}`);
|
|
this.loadLibrary();
|
|
},
|
|
|
|
openTools() {
|
|
this.activeView = 'tools';
|
|
this.setRoute('#tools');
|
|
},
|
|
|
|
openUsers() {
|
|
this.activeView = 'users';
|
|
this.setRoute('#users');
|
|
this.loadUsers();
|
|
},
|
|
|
|
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()}`);
|
|
if (resetOffset) this.clearReviewSelection();
|
|
} 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()}`);
|
|
if (resetOffset) this.clearLibrarySelection();
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.libraryLoading = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
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';
|
|
this.activeUserDetail = { user, stats: {}, recent_plays: [] };
|
|
this.userModalOpen = true;
|
|
try {
|
|
this.activeUserDetail = await this.request(`${this.apiBase}/users/${user.id}`);
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async loadSettings(showErrors = true) {
|
|
try {
|
|
this.settings = await this.request(`${this.apiBase}/settings`);
|
|
this.settingsDraft = Object.assign({}, this.settingsDraft, this.settings.values || {});
|
|
} catch (error) {
|
|
if (showErrors) this.showToast(error.message);
|
|
} finally {
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async saveSettings() {
|
|
if (this.settingsSaving) return;
|
|
const networkSimilarityWasEnabled = Boolean(
|
|
this.settings.values?.similarity_enabled
|
|
&& this.settings.values?.federation_enabled
|
|
);
|
|
const networkSimilarityWillBeEnabled = Boolean(
|
|
this.settingsDraft.similarity_enabled
|
|
&& this.settingsDraft.federation_enabled
|
|
);
|
|
if (!networkSimilarityWasEnabled && networkSimilarityWillBeEnabled) {
|
|
const accepted = window.confirm(
|
|
'Similarity searches send an anonymized embedding of the selected track to federation peers. '
|
|
+ 'Peers do not receive a user identity, but the query can technically reveal what is being searched. Enable this for the instance?'
|
|
);
|
|
if (!accepted) return;
|
|
}
|
|
this.settingsSaving = true;
|
|
try {
|
|
await this.request(`${this.apiBase}/settings`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(this.settingsDraft)
|
|
});
|
|
await this.loadSettings(false);
|
|
await this.loadFederation(false);
|
|
await this.loadSimilarity(false);
|
|
this.showToast('Settings saved');
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.settingsSaving = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async openSettings() {
|
|
this.activeView = 'settings';
|
|
this.setRoute('#settings');
|
|
await this.loadSettings();
|
|
await this.loadFederation(false);
|
|
await this.loadSimilarity(false);
|
|
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
|
await this.loadSettingsProbe(false);
|
|
}
|
|
},
|
|
|
|
async loadFederation(showErrors = true) {
|
|
this.federationLoading = true;
|
|
try {
|
|
this.federationStatus = await this.request(`${this.apiBase}/federation`);
|
|
} catch (error) {
|
|
if (showErrors) this.showToast(error.message);
|
|
} finally {
|
|
this.federationLoading = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async loadSimilarity(showErrors = true) {
|
|
this.similarityLoading = true;
|
|
try {
|
|
this.similarityStatus = await this.request(`${this.apiBase}/similarity`);
|
|
} catch (error) {
|
|
if (showErrors) this.showToast(error.message);
|
|
} finally {
|
|
this.similarityLoading = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async clearSimilarityEmbeddings() {
|
|
if (!window.confirm('Delete embeddings for every model and preprocessing profile? Audio files are not affected.')) return;
|
|
this.similarityLoading = true;
|
|
try {
|
|
await this.request(`${this.apiBase}/similarity/clear`, {
|
|
method: 'POST',
|
|
body: '{}'
|
|
});
|
|
await this.loadSimilarity(false);
|
|
this.showToast('All embeddings cleared');
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.similarityLoading = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
selectedSimilarityProfile() {
|
|
return (this.similarityStatus.profiles || []).find(
|
|
profile => profile.id === this.settingsDraft.similarity_profile
|
|
) || null;
|
|
},
|
|
|
|
similarityBadge() {
|
|
const phase = this.similarityStatus.status?.phase || 'disabled';
|
|
if (phase === 'ready') return 'ok';
|
|
if (phase === 'error') return 'failed';
|
|
if (['downloading', 'loading', 'processing'].includes(phase)) return 'running';
|
|
return 'disabled';
|
|
},
|
|
|
|
similarityProgress() {
|
|
const status = this.similarityStatus.status || {};
|
|
const total = Number(status.total_tracks || 0);
|
|
const completed = Number(status.completed_tracks || 0);
|
|
return total > 0 ? Math.min(100, Math.round(completed * 100 / total)) : 0;
|
|
},
|
|
|
|
async fedSyncNow() {
|
|
this.federationLoading = true;
|
|
try {
|
|
this.federationStatus = await this.request(`${this.apiBase}/federation/sync`, { method: 'POST', body: '{}' });
|
|
this.showToast('Library published to the federation');
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.federationLoading = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async fedShowTicket() {
|
|
this.federationLoading = true;
|
|
try {
|
|
const data = await this.request(`${this.apiBase}/federation/ticket`);
|
|
this.federationTicket = data.ticket || '';
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.federationLoading = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async fedConnect() {
|
|
this.federationLoading = true;
|
|
try {
|
|
const data = await this.request(`${this.apiBase}/federation/connect`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ticket: this.fedConnectTicket.trim() })
|
|
});
|
|
this.fedConnectTicket = '';
|
|
this.showToast(`Connected to ${(data.connected || '').slice(0, 12)}…`);
|
|
await this.loadFederation(false);
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.federationLoading = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
fedShort(id) {
|
|
return id ? `${id.slice(0, 12)}…` : '-';
|
|
},
|
|
|
|
fedTransport() {
|
|
return (this.federationStatus.node && this.federationStatus.node.transport) || {};
|
|
},
|
|
|
|
fedPathBadge(path) {
|
|
if (path === 'direct') return 'ok';
|
|
if (path === 'relay') return 'pending';
|
|
if (path === 'custom') return 'running';
|
|
return 'disabled';
|
|
},
|
|
|
|
fedRtt(ms) {
|
|
return ms != null ? `${Math.round(Number(ms))} ms` : '-';
|
|
},
|
|
|
|
formatTransportTime(value) {
|
|
const date = new Date(value);
|
|
if (!value || Number.isNaN(date.getTime())) return '-';
|
|
return date.toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false
|
|
});
|
|
},
|
|
|
|
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();
|
|
},
|
|
|
|
setReviewStatus(status) {
|
|
this.openReviews(status);
|
|
},
|
|
|
|
openReview(row) {
|
|
this.activeReview = row;
|
|
this.reviewDraft = Object.assign({
|
|
title: '',
|
|
artist: '',
|
|
album: '',
|
|
year: '',
|
|
track_number: '',
|
|
genre: '',
|
|
featured_artists: '',
|
|
release_type: 'album',
|
|
notes: ''
|
|
}, row.normalized || {});
|
|
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`);
|
|
this.clearReviewSelection();
|
|
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;
|
|
},
|
|
|
|
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);
|
|
}
|
|
},
|
|
|
|
async runJob(job) {
|
|
if (!job) return;
|
|
if (job.launching) return;
|
|
job.launching = true;
|
|
try {
|
|
const result = this.isMetadataBackfillJob(job)
|
|
? await this.request(`${this.apiBase}/jobs/metadata_backfill/run-options`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(this.metadataBackfillPayload())
|
|
})
|
|
: this.isArtworkBackfillJob(job)
|
|
? await this.request(`${this.apiBase}/jobs/artwork_backfill/run-options`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(this.artworkBackfillPayload())
|
|
})
|
|
: await this.request(`${this.apiBase}/jobs/${encodeURIComponent(job.name)}/run`, { method: 'POST' });
|
|
this.showToast(`Run #${result.run_id} started`);
|
|
this.activeJobName = job.name;
|
|
this.recentRunsPage = 0;
|
|
await this.loadJobs(false);
|
|
await this.loadRunsForJob(job.name);
|
|
await this.loadRunDetail({ id: result.run_id, job_name: job.name }, { silent: true });
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
job.launching = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
isMetadataBackfillJob(job) {
|
|
return job && job.name === 'metadata_backfill';
|
|
},
|
|
|
|
isArtworkBackfillJob(job) {
|
|
return job && job.name === 'artwork_backfill';
|
|
},
|
|
|
|
jobHasParameterForm(job) {
|
|
return this.isMetadataBackfillJob(job) || this.isArtworkBackfillJob(job);
|
|
},
|
|
|
|
metadataBackfillPayload() {
|
|
const options = this.metadataBackfillOptions || {};
|
|
return {
|
|
audio_bitrate: Boolean(options.audio_bitrate),
|
|
audio_sample_rate: Boolean(options.audio_sample_rate),
|
|
audio_bit_depth: Boolean(options.audio_bit_depth),
|
|
duration_seconds: Boolean(options.duration_seconds),
|
|
local_genres: Boolean(options.local_genres),
|
|
lastfm_tags: Boolean(options.lastfm_tags),
|
|
musicbrainz_tags: Boolean(options.musicbrainz_tags),
|
|
overwrite: options.mode === 'overwrite'
|
|
};
|
|
},
|
|
|
|
artworkBackfillPayload() {
|
|
const options = this.artworkBackfillOptions || {};
|
|
return {
|
|
overwrite_existing: options.mode === 'overwrite'
|
|
};
|
|
},
|
|
|
|
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;
|
|
this.setRoute(`#jobs/${encodeURIComponent(name)}`);
|
|
this.activeReview = null;
|
|
this.activeRunDetail = null;
|
|
this.activeJobRuns = [];
|
|
this.activeJobRunsName = name;
|
|
this.recentRunsPage = 0;
|
|
this.runLogAutoScroll = true;
|
|
await this.loadRunsForJob(name);
|
|
},
|
|
|
|
async loadRunsForJob(name, showErrors = true) {
|
|
try {
|
|
const data = await this.request(`${this.apiBase}/jobs/${encodeURIComponent(name)}/runs`);
|
|
const runs = data.runs || [];
|
|
const job = this.jobs.find(item => item.name === name);
|
|
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);
|
|
}
|
|
} catch (error) {
|
|
if (showErrors) this.showToast(error.message);
|
|
}
|
|
},
|
|
|
|
async loadRunDetail(run, options = {}) {
|
|
if (!run) return;
|
|
this.activeReview = null;
|
|
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();
|
|
try {
|
|
this.activeRunDetail = await this.request(`${this.apiBase}/jobs/${encodeURIComponent(run.job_name)}/runs/${run.id}`);
|
|
this.activeJobName = run.job_name;
|
|
if (previousJobName !== run.job_name) this.recentRunsPage = 0;
|
|
if (!preserveScroll || !sameRun) this.runLogAutoScroll = true;
|
|
this.$nextTick(() => {
|
|
if (shouldFollow) this.scrollRunLogToBottom(true);
|
|
});
|
|
} catch (error) {
|
|
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;
|
|
}
|
|
},
|
|
|
|
enableRunLogFollow() {
|
|
this.runLogAutoScroll = true;
|
|
this.$nextTick(() => this.scrollRunLogToBottom(true));
|
|
},
|
|
|
|
visibleRuns() {
|
|
if (this.activeJobName && this.activeJobRunsName === this.activeJobName) {
|
|
return this.activeJobRuns || [];
|
|
}
|
|
const job = this.activeJob;
|
|
return job ? (job.recent_runs || []) : this.recentRuns;
|
|
},
|
|
|
|
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}`;
|
|
},
|
|
|
|
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 || '',
|
|
hidden: item.is_hidden ? 'true' : 'false',
|
|
release_type: 'album',
|
|
year: '',
|
|
release_id: null,
|
|
track_number: '',
|
|
disc_number: '',
|
|
artist_ids: [],
|
|
release_tracks: []
|
|
};
|
|
this.editorDetail = null;
|
|
this.editorImageFile = null;
|
|
this.editorArtistToAdd = '';
|
|
this.editorReleaseToAdd = '';
|
|
this.clearReleaseTrackSearch();
|
|
this.editorOpen = true;
|
|
this.loadEditorDetail(item);
|
|
},
|
|
|
|
openReleaseCreator() {
|
|
this.libraryKind = 'releases';
|
|
this.openEditor({
|
|
id: 0,
|
|
kind: 'releases',
|
|
title: '',
|
|
subtitle: 'New release',
|
|
is_hidden: false,
|
|
tags: []
|
|
});
|
|
},
|
|
|
|
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 || '',
|
|
release_id: detail.release_id || null,
|
|
track_number: detail.track_number || '',
|
|
disc_number: detail.disc_number || '',
|
|
artist_ids: Array.isArray(detail.selected_artist_ids) ? detail.selected_artist_ids.slice() : [],
|
|
release_tracks: Array.isArray(detail.release_tracks) ? detail.release_tracks.map(track => this.normalizeReleaseTrack(track)) : []
|
|
};
|
|
this.editorImageFile = null;
|
|
this.editorArtistToAdd = '';
|
|
this.editorReleaseToAdd = '';
|
|
this.clearReleaseTrackSearch();
|
|
} 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';
|
|
},
|
|
|
|
isTrackEditor() {
|
|
return this.activeLibraryItem && this.activeLibraryItem.kind === 'tracks';
|
|
},
|
|
|
|
editorIsNewRelease() {
|
|
return this.isReleaseEditor() && Number(this.activeLibraryItem.id || 0) === 0;
|
|
},
|
|
|
|
editorTitle() {
|
|
if (this.editorIsNewRelease()) return 'New release';
|
|
return this.activeLibraryItem?.title || 'Editor';
|
|
},
|
|
|
|
editorSubtitle() {
|
|
if (this.editorIsNewRelease()) return 'Create release and attach tracks';
|
|
return this.activeLibraryItem?.kind || 'Library entity';
|
|
},
|
|
|
|
canEditLibraryImage() {
|
|
return this.isArtistEditor() || (this.isReleaseEditor() && !this.editorIsNewRelease());
|
|
},
|
|
|
|
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(' / ');
|
|
},
|
|
|
|
editorCanSave() {
|
|
if (!this.activeLibraryItem || !this.editorDetail || this.editorLoading || this.editorSaving) return false;
|
|
if (!String(this.editorDraft.title || '').trim()) return false;
|
|
if (this.isTrackEditor() && !this.editorDraft.release_id) return false;
|
|
return true;
|
|
},
|
|
|
|
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() {
|
|
return (this.isReleaseEditor() || this.isTrackEditor()) && String(this.editorArtistToAdd || '').trim().length > 0;
|
|
},
|
|
|
|
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));
|
|
},
|
|
|
|
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];
|
|
if (!release) return false;
|
|
this.editorDraft.release_id = Number(release.id);
|
|
this.editorReleaseToAdd = '';
|
|
return true;
|
|
},
|
|
|
|
normalizeReleaseTrack(track = {}) {
|
|
const trackNumber = track.track_number;
|
|
const discNumber = track.disc_number;
|
|
return {
|
|
id: Number(track.id),
|
|
title: track.title || `Track #${track.id}`,
|
|
artists: track.artists || '',
|
|
release_id: track.release_id == null ? null : Number(track.release_id),
|
|
release_title: track.release_title || '',
|
|
track_number: trackNumber == null ? '' : String(trackNumber),
|
|
disc_number: discNumber == null ? '' : String(discNumber),
|
|
duration_seconds: Number(track.duration_seconds || 0),
|
|
is_hidden: Boolean(track.is_hidden)
|
|
};
|
|
},
|
|
|
|
releaseTracks() {
|
|
if (!Array.isArray(this.editorDraft.release_tracks)) {
|
|
this.editorDraft.release_tracks = [];
|
|
}
|
|
return this.editorDraft.release_tracks;
|
|
},
|
|
|
|
releaseTrackPayload() {
|
|
return this.releaseTracks().map(track => ({
|
|
id: Number(track.id),
|
|
track_number: track.track_number || '',
|
|
disc_number: track.disc_number || ''
|
|
}));
|
|
},
|
|
|
|
releaseTrackIds() {
|
|
return new Set(this.releaseTracks().map(track => Number(track.id)));
|
|
},
|
|
|
|
releaseTrackSearchOpen() {
|
|
return this.isReleaseEditor() && String(this.releaseTrackSearch || '').trim().length > 0;
|
|
},
|
|
|
|
availableReleaseTrackResults() {
|
|
const selected = this.releaseTrackIds();
|
|
return (this.releaseTrackSearchResults || []).filter(track => !selected.has(Number(track.id)));
|
|
},
|
|
|
|
clearReleaseTrackSearch() {
|
|
this.releaseTrackSearch = '';
|
|
this.releaseTrackSearchResults = [];
|
|
this.releaseTrackSearchLoading = false;
|
|
this.releaseTrackSearchToken += 1;
|
|
},
|
|
|
|
async searchReleaseTracks() {
|
|
const query = String(this.releaseTrackSearch || '').trim();
|
|
if (!query) {
|
|
this.releaseTrackSearchResults = [];
|
|
this.releaseTrackSearchLoading = false;
|
|
return;
|
|
}
|
|
const token = this.releaseTrackSearchToken + 1;
|
|
this.releaseTrackSearchToken = token;
|
|
this.releaseTrackSearchLoading = true;
|
|
try {
|
|
const params = new URLSearchParams({ search: query, limit: '16' });
|
|
const rows = await this.request(`${this.apiBase}/library/tracks/search?${params.toString()}`);
|
|
if (this.releaseTrackSearchToken !== token) return;
|
|
this.releaseTrackSearchResults = Array.isArray(rows) ? rows.map(track => this.normalizeReleaseTrack(track)) : [];
|
|
} catch (error) {
|
|
if (this.releaseTrackSearchToken === token) this.showToast(error.message);
|
|
} finally {
|
|
if (this.releaseTrackSearchToken === token) {
|
|
this.releaseTrackSearchLoading = false;
|
|
this.icons();
|
|
}
|
|
}
|
|
},
|
|
|
|
async addBestReleaseTrack() {
|
|
if (!String(this.releaseTrackSearch || '').trim()) return;
|
|
if (!this.availableReleaseTrackResults().length && !this.releaseTrackSearchLoading) {
|
|
await this.searchReleaseTracks();
|
|
}
|
|
const track = this.availableReleaseTrackResults()[0];
|
|
if (!track) {
|
|
this.showToast('Choose a track from search results');
|
|
return;
|
|
}
|
|
this.addReleaseTrack(track);
|
|
},
|
|
|
|
addReleaseTrack(track) {
|
|
if (!track) return;
|
|
const normalized = this.normalizeReleaseTrack(track);
|
|
if (this.releaseTrackIds().has(Number(normalized.id))) {
|
|
this.showToast('Track already in release');
|
|
return;
|
|
}
|
|
this.editorDraft.release_tracks = this.releaseTracks().concat([normalized]);
|
|
this.clearReleaseTrackSearch();
|
|
this.$nextTick(() => this.icons());
|
|
},
|
|
|
|
removeReleaseTrack(id) {
|
|
this.editorDraft.release_tracks = this.releaseTracks().filter(track => Number(track.id) !== Number(id));
|
|
},
|
|
|
|
releaseTrackOrigin(track) {
|
|
const releaseId = Number(track && track.release_id ? track.release_id : 0);
|
|
const currentId = Number(this.activeLibraryItem && this.activeLibraryItem.id ? this.activeLibraryItem.id : 0);
|
|
if (releaseId && releaseId === currentId) return this.editorDraft.title || track.release_title || 'This release';
|
|
if (track && track.release_title) return track.release_title;
|
|
if (releaseId) return `Missing release #${releaseId}`;
|
|
return 'No release';
|
|
},
|
|
|
|
releaseTrackSearchMeta(track) {
|
|
const parts = [];
|
|
if (track.artists) parts.push(track.artists);
|
|
parts.push(this.releaseTrackOrigin(track));
|
|
const number = this.releaseTrackNumberLabel(track);
|
|
if (number) parts.push(number);
|
|
return parts.join(' / ');
|
|
},
|
|
|
|
releaseTrackNumberLabel(track) {
|
|
const disc = String((track && track.disc_number) || '').trim();
|
|
const number = String((track && track.track_number) || '').trim();
|
|
if (disc && number) return `D${disc} #${number}`;
|
|
if (number) return `#${number}`;
|
|
if (disc) return `D${disc}`;
|
|
return '';
|
|
},
|
|
|
|
trackDuration(seconds) {
|
|
const total = Math.round(Number(seconds || 0));
|
|
if (!total) return '-';
|
|
const minutes = Math.floor(total / 60);
|
|
const rest = String(total % 60).padStart(2, '0');
|
|
return `${minutes}:${rest}`;
|
|
},
|
|
|
|
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);
|
|
},
|
|
|
|
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;
|
|
return this.currentLibrarySelectionKeys().length;
|
|
},
|
|
|
|
currentLibrarySelectionKeys() {
|
|
return Object.keys(this.selectedLibraryIds).filter(key => key.startsWith(`${this.libraryKind}:`));
|
|
},
|
|
|
|
async bulkLibrary(action) {
|
|
const count = this.selectedLibraryCount();
|
|
if (!count) return;
|
|
if (action === 'delete' && !confirm(`Delete ${count} ${this.libraryKind}?`)) return;
|
|
const ids = this.currentLibrarySelectionKeys()
|
|
.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`);
|
|
this.clearLibrarySelection();
|
|
await this.loadLibrary(false);
|
|
await this.refreshCountsOnly();
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
}
|
|
},
|
|
|
|
async saveLibraryItem() {
|
|
if (this.isTrackEditor() && String(this.editorReleaseToAdd || '').trim()) {
|
|
if (!this.selectEditorRelease()) {
|
|
this.showToast('Choose a release from search results');
|
|
return;
|
|
}
|
|
}
|
|
if (!this.editorCanSave()) return;
|
|
this.editorSaving = true;
|
|
try {
|
|
const wasNewRelease = this.editorIsNewRelease();
|
|
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,
|
|
hidden: this.editorDraft.hidden === 'true',
|
|
release_type: this.editorDraft.release_type || null,
|
|
year: this.editorDraft.year || '',
|
|
release_id: this.editorDraft.release_id ? Number(this.editorDraft.release_id) : null,
|
|
track_number: this.editorDraft.track_number || '',
|
|
disc_number: this.editorDraft.disc_number || '',
|
|
artist_ids: this.editorDraft.artist_ids || [],
|
|
release_tracks: this.isReleaseEditor() ? this.releaseTrackPayload() : null
|
|
})
|
|
});
|
|
this.replaceLibraryItem(updated);
|
|
this.activeLibraryItem = updated;
|
|
if (this.editorDetail) this.editorDetail.item = updated;
|
|
if (this.isReleaseEditor()) await this.loadEditorDetail(updated);
|
|
this.showToast(wasNewRelease ? 'Release created' : 'Saved');
|
|
await this.refreshCountsOnly();
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.editorSaving = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
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) {
|
|
let replaced = false;
|
|
this.library.items = this.library.items.map(item => {
|
|
if (item.kind === updated.kind && Number(item.id) === Number(updated.id)) {
|
|
replaced = true;
|
|
return updated;
|
|
}
|
|
return item;
|
|
});
|
|
if (!replaced && updated.kind === this.libraryKind) {
|
|
this.library.items = [updated].concat(this.library.items || []);
|
|
this.library.total = Number(this.library.total || 0) + 1;
|
|
}
|
|
},
|
|
|
|
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);
|
|
},
|
|
|
|
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);
|
|
},
|
|
|
|
statCells() {
|
|
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') || {};
|
|
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 },
|
|
{
|
|
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'}`
|
|
}
|
|
];
|
|
},
|
|
|
|
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]}`;
|
|
},
|
|
|
|
pageTitle() {
|
|
if (this.activeView === 'library') return 'Library Workbench';
|
|
if (this.activeView === 'jobs') return 'Tasks';
|
|
if (this.activeView === 'tools') return 'Future Tools';
|
|
if (this.activeView === 'settings') return 'Settings';
|
|
if (this.activeView === 'users') return 'Users';
|
|
return 'Review Queue';
|
|
},
|
|
|
|
pageSubtitle() {
|
|
if (this.activeView === 'library') return 'Fast entity control surface for artists, releases, tracks, and playlists';
|
|
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';
|
|
if (this.activeView === 'settings') return 'Application configuration and external API credentials';
|
|
if (this.activeView === 'users') return 'Account status, live presence, and per-user listening statistics';
|
|
return 'Full-screen review triage with filter-aware bulk actions';
|
|
},
|
|
|
|
reviewPanelSubtitle() {
|
|
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}`;
|
|
},
|
|
|
|
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`;
|
|
},
|
|
|
|
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) }
|
|
];
|
|
},
|
|
|
|
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(' · ');
|
|
},
|
|
|
|
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`;
|
|
},
|
|
|
|
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)}`;
|
|
},
|
|
|
|
cronLabel(job) {
|
|
const value = job && job.cron_expression ? String(job.cron_expression).trim() : '';
|
|
return value || 'manual only';
|
|
},
|
|
|
|
statusCount(status) {
|
|
const row = (this.reviews.status_counts || []).find(item => item.status === status);
|
|
return row ? row.count : 0;
|
|
},
|
|
|
|
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);
|
|
},
|
|
|
|
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) {
|
|
return `#${run.id}`;
|
|
},
|
|
|
|
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 %}
|