CORE: Added Last.FM scrobbling
Build and Publish / Build and Publish Docker Image (push) Failing after 1m42s

This commit is contained in:
Ultradesu
2026-05-27 16:40:06 +03:00
parent 1c70349df8
commit 015d75c701
17 changed files with 1083 additions and 10 deletions
+10
View File
@@ -85,6 +85,16 @@
<td><input type="checkbox" name="swagger_enabled" id="swagger_enabled" value="on"{% if swagger_enabled %} checked{% endif %}></td>
<td><span class="badge badge-{{ swagger_enabled_source }}">{{ swagger_enabled_source }}</span></td>
</tr>
<tr>
<td><label for="lastfm_api_key">{{ t.settings_lastfm_api_key }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_lastfm_api_key_help }}</span></td>
<td><input type="password" name="lastfm_api_key" id="lastfm_api_key" value="{{ lastfm_api_key }}"></td>
<td><span class="badge badge-{{ lastfm_api_key_source }}">{{ lastfm_api_key_source }}</span></td>
</tr>
<tr>
<td><label for="lastfm_shared_secret">{{ t.settings_lastfm_shared_secret }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_lastfm_shared_secret_help }}</span></td>
<td><input type="password" name="lastfm_shared_secret" id="lastfm_shared_secret" value="{{ lastfm_shared_secret }}"></td>
<td><span class="badge badge-{{ lastfm_shared_secret_source }}">{{ lastfm_shared_secret_source }}</span></td>
</tr>
</table>
<h2>{{ t.settings_agent }}</h2>
+14 -5
View File
@@ -1136,7 +1136,7 @@ tbody tr:hover {
<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_api_key_configured ? 'ok' : ''"></span>
<span class="nav-count" x-text="settings.lastfm_scrobbling_configured ? 'ok' : ''"></span>
</button>
</div>
@@ -1734,7 +1734,7 @@ tbody tr:hover {
<strong>API</strong>
<span>Developer and enrichment integrations</span>
</div>
<span class="badge" :class="settings.lastfm_api_key_configured ? 'ok' : 'disabled'" x-text="settings.lastfm_api_key_configured ? 'Last.fm configured' : 'Last.fm missing'"></span>
<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-toggle">
@@ -1750,11 +1750,19 @@ tbody tr:hover {
</div>
<div class="setting-field">
<label>
<span>Last.fm API key</span>
<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">Used by the weekly Last.fm popularity task.</div>
<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>
@@ -2093,7 +2101,7 @@ function adminV2() {
editorArtistToAdd: '',
editorDetail: null,
editorDraft: { title: '', hidden: 'false', release_type: 'album', year: '', artist_ids: [] },
settings: { values: {}, sources: {}, lastfm_api_key_configured: false },
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,
@@ -2105,6 +2113,7 @@ function adminV2() {
oidc_user_groups: '',
swagger_enabled: false,
lastfm_api_key: '',
lastfm_shared_secret: '',
agent_enabled: false,
agent_inbox_dir: '',
agent_storage_dir: '',
+144 -2
View File
@@ -30,6 +30,14 @@ const T = {
lastfmPlaycount: "{{ t.player_lastfm_playcount }}",
lastfmUpdated: "{{ t.player_lastfm_updated }}",
lastfmNotLoaded: "{{ t.player_lastfm_not_loaded }}",
lastfmProfile: "{{ t.player_lastfm_profile }}",
lastfmConnect: "{{ t.player_lastfm_connect }}",
lastfmConnected: "{{ t.player_lastfm_connected }}",
lastfmReconnect: "{{ t.player_lastfm_reconnect }}",
lastfmNotConfigured: "{{ t.player_lastfm_not_configured }}",
lastfmDisconnectConfirm: "{{ t.player_lastfm_disconnect_confirm }}",
lastfmConnectFailed: "{{ t.player_lastfm_connect_failed }}",
lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}",
trackWord: "{{ t.player_tracks_count }}",
clientIdle: "{{ t.player_client_idle }}",
active: "{{ t.player_active }}",
@@ -162,9 +170,12 @@ document.addEventListener('alpine:init', () => {
Alpine.store('user', {
profile: null,
menuOpen: false,
lastfm: { configured: false, connected: false, username: null, reauth_required: false, last_error: null },
lastfmBusy: false,
init() {
this.load();
this.loadLastfm();
},
async load() {
@@ -177,6 +188,56 @@ document.addEventListener('alpine:init', () => {
}
},
async loadLastfm() {
try {
const res = await fetch('/api/player/lastfm/status');
if (!res.ok) throw new Error('failed');
this.lastfm = await res.json();
const player = Alpine.store('player');
if (player?.isPlaying) player._sendNowPlaying();
} catch {
this.lastfm = { configured: false, connected: false, username: null, reauth_required: false, last_error: null };
}
},
lastfmLabel() {
if (!this.lastfm?.configured) return T.lastfmNotConfigured;
if (this.lastfm?.connected && this.lastfm?.reauth_required) return T.lastfmReconnect;
if (this.lastfm?.connected) {
const user = this.lastfm.username || T.unknown;
return T.lastfmConnected.replace('{user}', user);
}
return T.lastfmConnect;
},
lastfmClass() {
if (!this.lastfm?.configured) return 'not-configured';
if (this.lastfm?.connected && this.lastfm?.reauth_required) return 'needs-auth';
if (this.lastfm?.connected) return 'connected';
return 'available';
},
async handleLastfm() {
if (this.lastfmBusy) return;
if (!this.lastfm?.configured) return;
if (!this.lastfm?.connected || this.lastfm?.reauth_required) {
window.location.href = '/api/player/lastfm/connect';
return;
}
const user = this.lastfm.username || T.unknown;
if (!window.confirm(T.lastfmDisconnectConfirm.replace('{user}', user))) return;
this.lastfmBusy = true;
try {
const res = await fetch('/api/player/lastfm/disconnect', { method: 'POST' });
if (!res.ok) throw new Error(T.lastfmDisconnectFailed);
await this.loadLastfm();
} catch {
alert(T.lastfmDisconnectFailed);
} finally {
this.lastfmBusy = false;
}
},
initials() {
const name = this.profile?.name || '';
return name.trim().charAt(0) || '?';
@@ -294,6 +355,11 @@ document.addEventListener('alpine:init', () => {
progress: 0,
_saveTimer: null,
_historyRecorded: false,
_nowPlayingSent: false,
_scrobbleSent: false,
_playbackStartedAt: null,
_listenedSeconds: 0,
_lastAudioTime: 0,
init() {
audio.volume = this.volume;
@@ -302,6 +368,8 @@ document.addEventListener('alpine:init', () => {
this.currentTime = audio.currentTime;
this.duration = audio.duration || 0;
this.progress = this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0;
this._trackListenedDelta();
this._maybeScrobble();
});
audio.addEventListener('ended', () => {
@@ -309,8 +377,16 @@ document.addEventListener('alpine:init', () => {
this.next();
});
audio.addEventListener('play', () => { this.isPlaying = true; });
audio.addEventListener('pause', () => { this.isPlaying = false; });
audio.addEventListener('play', () => {
this.isPlaying = true;
if (!this._playbackStartedAt) this._playbackStartedAt = Math.floor(Date.now() / 1000);
this._lastAudioTime = audio.currentTime || 0;
this._sendNowPlaying();
});
audio.addEventListener('pause', () => {
this.isPlaying = false;
this._lastAudioTime = audio.currentTime || 0;
});
audio.addEventListener('loadedmetadata', () => {
this.duration = audio.duration || 0;
@@ -333,6 +409,7 @@ document.addEventListener('alpine:init', () => {
play(track) {
this.currentTrack = track;
this._historyRecorded = false;
this._resetPlaybackTracking();
audio.src = track.stream_url;
audio.play().catch(() => {});
this._updateMediaSession();
@@ -349,11 +426,13 @@ document.addEventListener('alpine:init', () => {
seek(time) {
audio.currentTime = time;
this._lastAudioTime = audio.currentTime || 0;
},
seekRelative(delta) {
if (!this.currentTrack) return;
audio.currentTime = Math.max(0, Math.min(audio.duration || 0, audio.currentTime + delta));
this._lastAudioTime = audio.currentTime || 0;
},
seekFromClick(event) {
@@ -541,6 +620,7 @@ document.addEventListener('alpine:init', () => {
if (currentTrack) {
this.currentTrack = currentTrack;
this._historyRecorded = false;
this._resetPlaybackTracking();
audio.src = currentTrack.stream_url;
// Seek to saved position once metadata is loaded
const seekMs = state.position_ms || 0;
@@ -573,6 +653,68 @@ document.addEventListener('alpine:init', () => {
}),
}).catch(() => {});
},
_resetPlaybackTracking() {
this._nowPlayingSent = false;
this._scrobbleSent = false;
this._playbackStartedAt = null;
this._listenedSeconds = 0;
this._lastAudioTime = 0;
},
_trackListenedDelta() {
if (!this.currentTrack) return;
const current = audio.currentTime || 0;
if (!this.isPlaying) {
this._lastAudioTime = current;
return;
}
const previous = Number.isFinite(this._lastAudioTime) ? this._lastAudioTime : current;
const delta = current - previous;
if (delta > 0 && delta < 5) {
this._listenedSeconds += delta;
}
this._lastAudioTime = current;
},
_sendNowPlaying() {
if (this._nowPlayingSent || !this.currentTrack) return;
const lastfm = Alpine.store('user')?.lastfm;
if (!lastfm?.configured || !lastfm?.connected || lastfm?.reauth_required) return;
this._nowPlayingSent = true;
fetch('/api/player/lastfm/now-playing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_id: this.currentTrack.id }),
}).catch(() => {});
},
_maybeScrobble() {
if (this._scrobbleSent || !this.currentTrack) return;
const lastfm = Alpine.store('user')?.lastfm;
if (!lastfm?.configured || !lastfm?.connected || lastfm?.reauth_required) return;
const duration = Number(this.duration || audio.duration || this.currentTrack.duration_seconds || 0);
if (!duration || duration <= 30) return;
const threshold = Math.min(duration / 2, 240);
if (this._listenedSeconds < threshold) return;
this._sendScrobble();
},
_sendScrobble() {
if (this._scrobbleSent || !this.currentTrack) return;
this._scrobbleSent = true;
fetch('/api/player/lastfm/scrobble', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_id: this.currentTrack.id,
started_at: this._playbackStartedAt || Math.floor(Date.now() / 1000),
listened_seconds: Math.floor(this._listenedSeconds),
}),
}).catch(() => {
this._scrobbleSent = false;
});
},
});
// -----------------------------------------------------------------------
+14
View File
@@ -38,6 +38,13 @@
<span class="user-stat-label">{{ t.player_listened }}</span>
</div>
</div>
<button class="lastfm-profile-action"
:class="$store.user.lastfmClass()"
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
@click="$store.user.handleLastfm()">
<span class="lastfm-dot"></span>
<span class="lastfm-profile-text" x-text="$store.user.lastfmLabel()"></span>
</button>
</div>
<div class="sidebar-header">
<h2>{{ t.player_library }}</h2>
@@ -324,6 +331,13 @@
<span class="user-stat-label">{{ t.player_listened }}</span>
</div>
</div>
<button class="lastfm-profile-action"
:class="$store.user.lastfmClass()"
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
@click="$store.user.handleLastfm()">
<span class="lastfm-dot"></span>
<span class="lastfm-profile-text" x-text="$store.user.lastfmLabel()"></span>
</button>
<button class="modal-btn modal-btn-primary mobile-account-logout"
@click="$store.user.logout()">
{{ t.player_log_out }}
+62
View File
@@ -165,6 +165,68 @@ button.user-stat:hover {
color: var(--text-subdued);
}
.lastfm-profile-action {
width: 100%;
min-height: 34px;
margin-top: 8px;
padding: 7px 9px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
text-align: left;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.lastfm-profile-action:not(:disabled):hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.lastfm-profile-action:disabled {
cursor: default;
opacity: 0.72;
}
.lastfm-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-subdued);
flex-shrink: 0;
}
.lastfm-profile-action.connected {
border-color: rgba(29, 185, 84, 0.32);
color: var(--text-primary);
}
.lastfm-profile-action.connected .lastfm-dot {
background: #1db954;
}
.lastfm-profile-action.available .lastfm-dot,
.lastfm-profile-action.needs-auth .lastfm-dot {
background: var(--accent);
}
.lastfm-profile-action.needs-auth {
border-color: rgba(255, 184, 77, 0.4);
}
.lastfm-profile-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-weight: 650;
}
.sidebar-header {
padding: 16px;
display: flex;