tangled
alpha
login
or
join now
tokono.ma
/
diffuse
5
fork
atom
A music player that connects to your cloud/distributed storage.
5
fork
atom
overview
issues
4
pulls
pipelines
feat: scrobbling
Steven Vandevelde
3 days ago
c93231cc
3bf383c5
+839
-9
13 changed files
expand all
collapse all
unified
split
deno.jsonc
src
_data
facets.yaml
_includes
layouts
diffuse.vto
common
facets
foundation.js
components
configurator
scrobbles
element.js
engine
audio
element.js
types.d.ts
orchestrator
queue-audio
element.js
scrobble-audio
element.js
supplement
last.fm
element.js
types.d.ts
facets
scrobble
last.fm
index.html
index.inline.js
+1
deno.jsonc
···
24
24
"@js-temporal/polyfill": "npm:@js-temporal/polyfill@^0.5.1",
25
25
"@mary/ds-queue": "jsr:@mary/ds-queue@^0.1.3",
26
26
"@noble/ciphers": "npm:@noble/ciphers@^2.1.1",
27
27
+
"@noble/hashes": "npm:@noble/hashes@^2.0.1",
27
28
"@okikio/transferables": "jsr:@okikio/transferables@^1.0.2",
28
29
"@orama/orama": "npm:@orama/orama@^3.1.18",
29
30
"@phosphor-icons/web": "npm:@phosphor-icons/web@^2.1.2",
+5
src/_data/facets.yaml
···
10
10
featured: true
11
11
desc: >
12
12
Automatically put tracks into the queue.
13
13
+
- url: "facets/scrobble/last.fm/index.html"
14
14
+
title: "Scrobble / Last.fm"
15
15
+
category: Data
16
16
+
desc: >
17
17
+
Enable Last.fm scrobbling.
13
18
- url: "facets/tools/export-import/index.html"
14
19
title: "Tools / Export & Import"
15
20
category: Data
-7
src/_includes/layouts/diffuse.vto
···
28
28
<meta name="msapplication-TileColor" content="#8a90a9" />
29
29
<meta name="theme-color" content="#8a90a9" />
30
30
31
31
-
<!-- Preload items so they're ready before first paint (prevents flash during view transitions) -->
32
32
-
<link rel="preload" as="font" type="font/woff2" crossorigin href="fonts/InterVariable.woff2" />
33
33
-
<link rel="preload" as="font" type="font/woff2" crossorigin href="fonts/InterVariable-Italic.woff2" />
34
34
-
<link rel="preload" as="font" type="font/woff2" crossorigin href="fonts/CommitMonoVariable.woff2" />
35
35
-
<link rel="preload" as="font" type="font/woff2" crossorigin href="vendor/@phosphor-icons/bold/Phosphor-Bold.woff2" />
36
36
-
<link rel="preload" as="font" type="font/woff2" crossorigin href="vendor/@phosphor-icons/fill/Phosphor-Fill.woff2" />
37
37
-
38
31
<!-- Styles -->
39
32
{{ for url of styles }}
40
33
<link rel="stylesheet" href="{{ url }}" />
+39
src/common/facets/foundation.js
···
13
13
import ScopedTracksOrchestrator from "~/components/orchestrator/scoped-tracks/element.js";
14
14
import FavouritesOrchestrator from "~/components/orchestrator/favourites/element.js";
15
15
import MediaSessionOrchestrator from "~/components/orchestrator/media-session/element.js";
16
16
+
import ScrobbleAudioOrchestrator from "~/components/orchestrator/scrobble-audio/element.js";
16
17
import SourcesOrchestrator from "~/components/orchestrator/sources/element.js";
18
18
+
import ScrobbleConfigurator from "~/components/configurator/scrobbles/element.js";
17
19
18
20
/**
19
21
* @import { DiffuseElement } from "@toko/diffuse/common/element.js";
···
29
31
GROUP,
30
32
31
33
features: {
34
34
+
audioScrobbling,
32
35
fillQueueAutomatically,
33
36
playAudioFromQueue,
34
37
processInputs,
···
36
39
},
37
40
38
41
// Elements
42
42
+
configurator: {
43
43
+
scrobbles,
44
44
+
},
39
45
engine: {
40
46
audio,
41
47
queue,
···
51
57
queueAudio,
52
58
processTracks,
53
59
scopedTracks,
60
60
+
scrobbleAudio,
54
61
sources,
55
62
},
56
63
processor: {
···
64
71
65
72
// 📦️
66
73
74
74
+
function audioScrobbling() {
75
75
+
return {
76
76
+
configurator: {
77
77
+
scrobbles: scrobbles(),
78
78
+
},
79
79
+
orchestrator: {
80
80
+
scrobbleAudio: scrobbleAudio(),
81
81
+
},
82
82
+
};
83
83
+
}
84
84
+
67
85
function fillQueueAutomatically() {
68
86
return {
69
87
engine: {
···
122
140
}
123
141
124
142
// 🥡
143
143
+
144
144
+
// Configurators
145
145
+
function scrobbles() {
146
146
+
const sc = new ScrobbleConfigurator();
147
147
+
sc.setAttribute("group", GROUP);
148
148
+
sc.setAttribute("id", "scrobbles");
149
149
+
150
150
+
return findExistingOrAdd(sc);
151
151
+
}
125
152
126
153
// Engines
127
154
function audio() {
···
285
312
sto.setAttribute("search-processor-selector", s.selector);
286
313
287
314
return findExistingOrAdd(sto);
315
315
+
}
316
316
+
317
317
+
function scrobbleAudio() {
318
318
+
const a = audio();
319
319
+
const sc = scrobbles();
320
320
+
321
321
+
const sao = new ScrobbleAudioOrchestrator();
322
322
+
sao.setAttribute("group", GROUP);
323
323
+
sao.setAttribute("audio-engine-selector", a.selector);
324
324
+
sao.setAttribute("scrobbles-selector", sc.selector);
325
325
+
326
326
+
return findExistingOrAdd(sao);
288
327
}
289
328
290
329
function sources() {
+72
src/components/configurator/scrobbles/element.js
···
1
1
+
import { DiffuseElement } from "~/common/element.js";
2
2
+
3
3
+
/**
4
4
+
* @import {Track} from "~/definitions/types.d.ts"
5
5
+
* @import {ScrobbleActions, ScrobbleElement} from "~/components/supplement/types.d.ts"
6
6
+
*/
7
7
+
8
8
+
////////////////////////////////////////////
9
9
+
// ELEMENT
10
10
+
////////////////////////////////////////////
11
11
+
12
12
+
/**
13
13
+
* @implements {ScrobbleActions}
14
14
+
*/
15
15
+
class ScrobbleConfigurator extends DiffuseElement {
16
16
+
static NAME = "diffuse/configurator/scrobbles";
17
17
+
18
18
+
// SCROBBLE ACTIONS
19
19
+
20
20
+
/**
21
21
+
* @param {Track} track
22
22
+
*/
23
23
+
async nowPlaying(track) {
24
24
+
return await Promise.all(
25
25
+
this.#activeScrobblers().map((s) => s.nowPlaying(track)),
26
26
+
);
27
27
+
}
28
28
+
29
29
+
/**
30
30
+
* @param {Track} track
31
31
+
* @param {number} startedAt Unix timestamp in milliseconds
32
32
+
*/
33
33
+
async scrobble(track, startedAt) {
34
34
+
return await Promise.all(
35
35
+
this.#activeScrobblers().map((s) => s.scrobble(track, startedAt)),
36
36
+
);
37
37
+
}
38
38
+
39
39
+
// MISC
40
40
+
41
41
+
/**
42
42
+
* All child scrobble elements, regardless of authentication state.
43
43
+
*
44
44
+
* @returns {ScrobbleElement[]}
45
45
+
*/
46
46
+
scrobblers() {
47
47
+
return Array.from(this.root().children).flatMap((el) => {
48
48
+
if (!("isAuthenticated" in el && "nowPlaying" in el)) return [];
49
49
+
return [/** @type {ScrobbleElement} */ (/** @type {unknown} */ (el))];
50
50
+
});
51
51
+
}
52
52
+
53
53
+
/**
54
54
+
* Child scrobble elements that are currently authenticated.
55
55
+
*
56
56
+
* @returns {ScrobbleElement[]}
57
57
+
*/
58
58
+
#activeScrobblers() {
59
59
+
return this.scrobblers().filter((s) => s.isAuthenticated());
60
60
+
}
61
61
+
}
62
62
+
63
63
+
export default ScrobbleConfigurator;
64
64
+
65
65
+
////////////////////////////////////////////
66
66
+
// REGISTER
67
67
+
////////////////////////////////////////////
68
68
+
69
69
+
export const CLASS = ScrobbleConfigurator;
70
70
+
export const NAME = "dc-scrobbles";
71
71
+
72
72
+
customElements.define(NAME, ScrobbleConfigurator);
+1
src/components/engine/audio/element.js
···
288
288
isPreload: a.isPreload,
289
289
mimeType: a.mimeType,
290
290
progress: a.progress,
291
291
+
track: a.track,
291
292
url,
292
293
};
293
294
});
+2
src/components/engine/audio/types.d.ts
···
1
1
import type { Signal, SignalReader } from "~/common/signal.d.ts";
2
2
+
import type { Track } from "~/definitions/types.d.ts";
2
3
3
4
export type Actions = {
4
5
adjustVolume: (_: { audioId?: string; volume: number }) => void;
···
35
36
* Initial progress
36
37
*/
37
38
progress?: number;
39
39
+
track: Track;
38
40
};
39
41
40
42
export type AudioState = {
+8
-2
src/components/orchestrator/queue-audio/element.js
···
101
101
// TODO: Take URL expiration timestamp into account
102
102
// TODO: Add support for seeking streams
103
103
// (requires a lot of code, decoding audio frames, etc.)
104
104
-
const activeAudio = activeItem && resolvedUri
105
105
-
? [{ id: activeItem.id, isPreload: false, ...resolvedUri }]
104
104
+
const activeAudio = activeTrack && resolvedUri
105
105
+
? [{
106
106
+
id: activeTrack.id,
107
107
+
isPreload: false,
108
108
+
track: activeTrack,
109
109
+
...resolvedUri,
110
110
+
}]
106
111
: [];
107
112
108
113
audio.supply({
···
131
136
id: nextItem.id,
132
137
isPreload: true,
133
138
url: nextUrl,
139
139
+
track: nextTrack,
134
140
}],
135
141
});
136
142
}, 30_000);
+204
src/components/orchestrator/scrobble-audio/element.js
···
1
1
+
import { BroadcastableDiffuseElement, query } from "~/common/element.js";
2
2
+
3
3
+
/**
4
4
+
* @import {ScrobbleElement} from "~/components/supplement/types.d.ts"
5
5
+
* @import {Track} from "~/definitions/types.d.ts"
6
6
+
*/
7
7
+
8
8
+
////////////////////////////////////////////
9
9
+
// ELEMENT
10
10
+
////////////////////////////////////////////
11
11
+
12
12
+
/**
13
13
+
* Connects the audio engine with the scrobble configurator.
14
14
+
*
15
15
+
* Calls `nowPlaying` when a track starts and `scrobble` once the user
16
16
+
* has listened long enough per the last.fm rules:
17
17
+
* - Track must be at least 30 seconds long.
18
18
+
* - User must have listened to at least min(duration / 2, 4 minutes).
19
19
+
*/
20
20
+
class ScrobbleAudioOrchestrator extends BroadcastableDiffuseElement {
21
21
+
static NAME = "diffuse/orchestrator/scrobble-audio";
22
22
+
23
23
+
// LIFECYCLE
24
24
+
25
25
+
/** @override */
26
26
+
async connectedCallback() {
27
27
+
if (this.hasAttribute("group")) {
28
28
+
this.broadcast(this.identifier, {});
29
29
+
}
30
30
+
31
31
+
super.connectedCallback();
32
32
+
33
33
+
/** @type {import("~/components/engine/audio/element.js").CLASS} */
34
34
+
this.audio = query(this, "audio-engine-selector");
35
35
+
36
36
+
/** @type {ScrobbleElement} */
37
37
+
this.scrobbles = query(this, "scrobbles-selector");
38
38
+
39
39
+
await customElements.whenDefined(this.audio.localName);
40
40
+
await customElements.whenDefined(this.scrobbles.localName);
41
41
+
42
42
+
this.effect(() => this.#monitorAudio());
43
43
+
}
44
44
+
45
45
+
/** @override */
46
46
+
disconnectedCallback() {
47
47
+
super.disconnectedCallback();
48
48
+
this.#stopTimer();
49
49
+
}
50
50
+
51
51
+
// TRACK STATE
52
52
+
// Resets whenever the active (non-preload) audio item changes.
53
53
+
54
54
+
/** @type {string | null} */
55
55
+
#trackId = null;
56
56
+
57
57
+
/** @type {Track | null} */
58
58
+
#activeTrack = null;
59
59
+
60
60
+
/** @type {number | null} Date.now() when track first started, used as the scrobble timestamp. */
61
61
+
#startedAt = null;
62
62
+
63
63
+
/** Whether `nowPlaying` has been called for the current track. */
64
64
+
#nowPlayingSent = false;
65
65
+
66
66
+
/** Whether `scrobble` has been called for the current track. */
67
67
+
#scrobbled = false;
68
68
+
69
69
+
// TIMER STATE
70
70
+
// Accumulates actual listening time (pauses don't count).
71
71
+
72
72
+
/** Accumulated listening time in ms before the last pause. */
73
73
+
#listenedMs = 0;
74
74
+
75
75
+
/** Date.now() when the timer was last resumed; null when paused. */
76
76
+
#timerResumedAt = /** @type {number | null} */ (null);
77
77
+
78
78
+
/** @type {number | null} */
79
79
+
#intervalId = null;
80
80
+
81
81
+
// EFFECT
82
82
+
83
83
+
/**
84
84
+
* Reacts to audio item changes and playback state.
85
85
+
* Detects track changes, resets state, and starts/stops the listening timer.
86
86
+
*/
87
87
+
#monitorAudio() {
88
88
+
if (!this.audio) return;
89
89
+
90
90
+
const active = this.audio.items().find((item) => !item.isPreload);
91
91
+
const id = active?.id ?? null;
92
92
+
93
93
+
// Detect track change
94
94
+
if (id !== this.#trackId) {
95
95
+
this.#stopTimer();
96
96
+
97
97
+
this.#trackId = id;
98
98
+
this.#activeTrack = active?.track ?? null;
99
99
+
this.#startedAt = id ? Date.now() : null;
100
100
+
this.#nowPlayingSent = false;
101
101
+
this.#scrobbled = false;
102
102
+
this.#listenedMs = 0;
103
103
+
}
104
104
+
105
105
+
if (!id) return;
106
106
+
107
107
+
const isPlaying = this.audio.state(id)?.isPlaying() ?? false;
108
108
+
109
109
+
if (isPlaying) {
110
110
+
this.#startTimer();
111
111
+
112
112
+
if (!this.#nowPlayingSent) {
113
113
+
this.#nowPlayingSent = true;
114
114
+
this.#sendNowPlaying(id);
115
115
+
}
116
116
+
} else {
117
117
+
this.#stopTimer();
118
118
+
}
119
119
+
}
120
120
+
121
121
+
// TIMER
122
122
+
123
123
+
#startTimer() {
124
124
+
if (this.#timerResumedAt !== null) return;
125
125
+
126
126
+
this.#timerResumedAt = Date.now();
127
127
+
this.#intervalId = setInterval(() => this.#checkScrobble(), 1_000);
128
128
+
}
129
129
+
130
130
+
#stopTimer() {
131
131
+
if (this.#timerResumedAt !== null) {
132
132
+
this.#listenedMs += Date.now() - this.#timerResumedAt;
133
133
+
this.#timerResumedAt = null;
134
134
+
}
135
135
+
136
136
+
if (this.#intervalId !== null) {
137
137
+
clearInterval(this.#intervalId);
138
138
+
this.#intervalId = null;
139
139
+
}
140
140
+
}
141
141
+
142
142
+
#totalListenedMs() {
143
143
+
return this.#listenedMs +
144
144
+
(this.#timerResumedAt !== null ? Date.now() - this.#timerResumedAt : 0);
145
145
+
}
146
146
+
147
147
+
// SCROBBLING
148
148
+
149
149
+
/**
150
150
+
* @param {string} id
151
151
+
*/
152
152
+
async #sendNowPlaying(id) {
153
153
+
if (!(await this.isLeader())) return;
154
154
+
if (this.#trackId !== id || !this.#activeTrack) return;
155
155
+
156
156
+
try {
157
157
+
await this.scrobbles?.nowPlaying(this.#activeTrack);
158
158
+
} catch (err) {
159
159
+
console.warn("scrobble: nowPlaying failed", err);
160
160
+
}
161
161
+
}
162
162
+
163
163
+
async #checkScrobble() {
164
164
+
if (this.#scrobbled) return;
165
165
+
166
166
+
const id = this.#trackId;
167
167
+
if (!id || !this.#startedAt || !this.#activeTrack) return;
168
168
+
169
169
+
const durationSec = this.audio?.state(id)?.duration() ?? 0;
170
170
+
171
171
+
// last.fm: track must be at least 30 seconds
172
172
+
if (durationSec < 30) return;
173
173
+
174
174
+
// last.fm: must have listened to min(half the track, 4 minutes)
175
175
+
const listenedSec = this.#totalListenedMs() / 1000;
176
176
+
if (listenedSec < Math.min(durationSec / 2, 240)) return;
177
177
+
178
178
+
this.#scrobbled = true;
179
179
+
180
180
+
if (!(await this.isLeader())) return;
181
181
+
if (this.#trackId !== id) return;
182
182
+
183
183
+
const track = this.#activeTrack;
184
184
+
const startedAt = this.#startedAt;
185
185
+
186
186
+
try {
187
187
+
await this.scrobbles?.scrobble(track, startedAt);
188
188
+
} catch (err) {
189
189
+
console.warn("scrobble: scrobble failed", err);
190
190
+
this.#scrobbled = false;
191
191
+
}
192
192
+
}
193
193
+
}
194
194
+
195
195
+
export default ScrobbleAudioOrchestrator;
196
196
+
197
197
+
////////////////////////////////////////////
198
198
+
// REGISTER
199
199
+
////////////////////////////////////////////
200
200
+
201
201
+
export const CLASS = ScrobbleAudioOrchestrator;
202
202
+
export const NAME = "do-scrobble-audio";
203
203
+
204
204
+
customElements.define(NAME, ScrobbleAudioOrchestrator);
+246
src/components/supplement/last.fm/element.js
···
1
1
+
import { md5 } from "@noble/hashes/legacy.js";
2
2
+
import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
3
3
+
4
4
+
import { DiffuseElement } from "~/common/element.js";
5
5
+
import { computed, signal } from "~/common/signal.js";
6
6
+
7
7
+
/**
8
8
+
* @import {Track} from "~/definitions/types.d.ts"
9
9
+
* @import {ScrobbleElement} from "../types.d.ts"
10
10
+
*/
11
11
+
12
12
+
////////////////////////////////////////////
13
13
+
// CONSTANTS
14
14
+
////////////////////////////////////////////
15
15
+
16
16
+
const LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/";
17
17
+
const LASTFM_AUTH_URL = "https://www.last.fm/api/auth/";
18
18
+
const STORAGE_KEY = "diffuse/supplement/last.fm/session";
19
19
+
const PENDING_TOKEN_KEY = "diffuse/supplement/last.fm/pending-token";
20
20
+
21
21
+
const DEFAULT_API_KEY = "4f0fe85b67baef8bb7d008a8754a95e5";
22
22
+
const DEFAULT_API_SECRET = "0cec3ca0f58e04a5082f1131aba1e0d3";
23
23
+
24
24
+
////////////////////////////////////////////
25
25
+
// ELEMENT
26
26
+
////////////////////////////////////////////
27
27
+
28
28
+
/**
29
29
+
* @implements {ScrobbleElement}
30
30
+
*/
31
31
+
class LastFmSupplement extends DiffuseElement {
32
32
+
static NAME = "diffuse/supplement/last.fm";
33
33
+
34
34
+
get #apiKey() {
35
35
+
return this.getAttribute("api-key") ?? DEFAULT_API_KEY;
36
36
+
}
37
37
+
38
38
+
get #apiSecret() {
39
39
+
return this.getAttribute("api-secret") ?? DEFAULT_API_SECRET;
40
40
+
}
41
41
+
42
42
+
// SIGNALS
43
43
+
44
44
+
#sessionKey = signal(/** @type {string | null} */ (null));
45
45
+
#handle = signal(/** @type {string | null} */ (null));
46
46
+
47
47
+
// STATE
48
48
+
49
49
+
isAuthenticated = computed(() => this.#sessionKey.value !== null);
50
50
+
handle = this.#handle.get;
51
51
+
52
52
+
// LIFECYCLE
53
53
+
54
54
+
/** @override */
55
55
+
connectedCallback() {
56
56
+
super.connectedCallback();
57
57
+
this.#tryRestore();
58
58
+
}
59
59
+
60
60
+
async #tryRestore() {
61
61
+
await this.whenConnected();
62
62
+
63
63
+
// Check for a pending token in sessionStorage (returning from auth redirect)
64
64
+
const pendingToken = sessionStorage.getItem(PENDING_TOKEN_KEY);
65
65
+
66
66
+
if (pendingToken) {
67
67
+
sessionStorage.removeItem(PENDING_TOKEN_KEY);
68
68
+
69
69
+
try {
70
70
+
const session = await this.#getSession(pendingToken);
71
71
+
this.#setSession(session);
72
72
+
} catch (err) {
73
73
+
console.warn("last.fm: failed to exchange token for session", err);
74
74
+
}
75
75
+
76
76
+
return;
77
77
+
}
78
78
+
79
79
+
// Restore an existing session from localStorage
80
80
+
const stored = localStorage.getItem(STORAGE_KEY);
81
81
+
82
82
+
if (stored) {
83
83
+
try {
84
84
+
const { key, name: handle } = JSON.parse(stored);
85
85
+
this.#sessionKey.value = key;
86
86
+
this.#handle.value = handle;
87
87
+
} catch {
88
88
+
localStorage.removeItem(STORAGE_KEY);
89
89
+
}
90
90
+
}
91
91
+
}
92
92
+
93
93
+
// AUTH
94
94
+
95
95
+
/**
96
96
+
* Initiate the last.fm auth flow.
97
97
+
* Requests a token and redirects the browser to the authorization page.
98
98
+
*/
99
99
+
async signIn() {
100
100
+
const token = await this.#getToken();
101
101
+
102
102
+
sessionStorage.setItem(PENDING_TOKEN_KEY, token);
103
103
+
104
104
+
const callbackUrl = location.origin + location.pathname + location.search;
105
105
+
const authUrl = new URL(LASTFM_AUTH_URL);
106
106
+
authUrl.searchParams.set("api_key", this.#apiKey);
107
107
+
authUrl.searchParams.set("token", token);
108
108
+
authUrl.searchParams.set("cb", callbackUrl);
109
109
+
110
110
+
location.assign(authUrl.toString());
111
111
+
}
112
112
+
113
113
+
/**
114
114
+
* Clear the stored session.
115
115
+
*/
116
116
+
signOut() {
117
117
+
this.#sessionKey.value = null;
118
118
+
this.#handle.value = null;
119
119
+
localStorage.removeItem(STORAGE_KEY);
120
120
+
}
121
121
+
122
122
+
/** @param {{ key: string, name: string }} session */
123
123
+
#setSession({ key, name: handle }) {
124
124
+
this.#sessionKey.value = key;
125
125
+
this.#handle.value = handle;
126
126
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({ key, name: handle }));
127
127
+
}
128
128
+
129
129
+
// SCROBBLE ACTIONS
130
130
+
131
131
+
/**
132
132
+
* @param {Track} track
133
133
+
*/
134
134
+
async nowPlaying(track) {
135
135
+
const tags = track.tags ?? {};
136
136
+
/** @type {Record<string, string>} */
137
137
+
const params = {};
138
138
+
139
139
+
if (tags.title) params.track = tags.title;
140
140
+
if (tags.artist) params.artist = tags.artist;
141
141
+
if (tags.album) params.album = tags.album;
142
142
+
if (tags.albumartist) params.albumArtist = tags.albumartist;
143
143
+
if (tags.track?.no != null) params.trackNumber = String(tags.track.no);
144
144
+
if (track.stats?.duration != null) {
145
145
+
params.duration = String(Math.round(track.stats.duration / 1000));
146
146
+
}
147
147
+
148
148
+
return this.#authenticatedCall("track.updateNowPlaying", params);
149
149
+
}
150
150
+
151
151
+
/**
152
152
+
* @param {Track} track
153
153
+
* @param {number} startedAt Unix timestamp in milliseconds
154
154
+
*/
155
155
+
async scrobble(track, startedAt) {
156
156
+
const tags = track.tags ?? {};
157
157
+
/** @type {Record<string, string>} */
158
158
+
const params = {
159
159
+
timestamp: String(Math.floor(startedAt / 1000)),
160
160
+
};
161
161
+
162
162
+
if (tags.title) params.track = tags.title;
163
163
+
if (tags.artist) params.artist = tags.artist;
164
164
+
if (tags.album) params.album = tags.album;
165
165
+
if (tags.albumartist) params.albumArtist = tags.albumartist;
166
166
+
if (tags.track?.no != null) params.trackNumber = String(tags.track.no);
167
167
+
if (track.stats?.duration != null) {
168
168
+
params.duration = String(Math.round(track.stats.duration / 1000));
169
169
+
}
170
170
+
171
171
+
return this.#authenticatedCall("track.scrobble", params);
172
172
+
}
173
173
+
174
174
+
// API
175
175
+
176
176
+
/**
177
177
+
* Sign a set of API parameters (excluding `format` and `callback`).
178
178
+
*
179
179
+
* @param {Record<string, string>} params
180
180
+
* @returns {string} MD5 hex digest
181
181
+
*/
182
182
+
#sign(params) {
183
183
+
const str = Object.keys(params)
184
184
+
.sort()
185
185
+
.map((k) => k + params[k])
186
186
+
.join("");
187
187
+
return bytesToHex(md5(utf8ToBytes(str + this.#apiSecret)));
188
188
+
}
189
189
+
190
190
+
/**
191
191
+
* @param {string} method
192
192
+
* @param {Record<string, string>} [params]
193
193
+
* @returns {Promise<any>}
194
194
+
*/
195
195
+
async #call(method, params = {}) {
196
196
+
const allParams = { ...params, api_key: this.#apiKey, method };
197
197
+
const api_sig = this.#sign(allParams);
198
198
+
const body = new URLSearchParams({ ...allParams, api_sig, format: "json" });
199
199
+
200
200
+
const response = await fetch(LASTFM_API_URL, { method: "POST", body });
201
201
+
const data = await response.json();
202
202
+
203
203
+
if (data.error) {
204
204
+
throw new Error(`last.fm error ${data.error}: ${data.message}`);
205
205
+
}
206
206
+
207
207
+
return data;
208
208
+
}
209
209
+
210
210
+
/**
211
211
+
* @param {string} method
212
212
+
* @param {Record<string, string>} [params]
213
213
+
* @returns {Promise<any>}
214
214
+
*/
215
215
+
async #authenticatedCall(method, params = {}) {
216
216
+
const sk = this.#sessionKey.value;
217
217
+
if (!sk) throw new Error("Not authenticated with last.fm");
218
218
+
return this.#call(method, { ...params, sk });
219
219
+
}
220
220
+
221
221
+
/** @returns {Promise<string>} */
222
222
+
async #getToken() {
223
223
+
const data = await this.#call("auth.getToken");
224
224
+
return data.token;
225
225
+
}
226
226
+
227
227
+
/**
228
228
+
* @param {string} token
229
229
+
* @returns {Promise<{ key: string, name: string }>}
230
230
+
*/
231
231
+
async #getSession(token) {
232
232
+
const data = await this.#call("auth.getSession", { token });
233
233
+
return data.session;
234
234
+
}
235
235
+
}
236
236
+
237
237
+
export default LastFmSupplement;
238
238
+
239
239
+
////////////////////////////////////////////
240
240
+
// REGISTER
241
241
+
////////////////////////////////////////////
242
242
+
243
243
+
export const CLASS = LastFmSupplement;
244
244
+
export const NAME = "ds-lastfm";
245
245
+
246
246
+
customElements.define(NAME, CLASS);
+18
src/components/supplement/types.d.ts
···
1
1
+
import type { DiffuseElement } from "~/common/element.js";
2
2
+
import type { SignalReader } from "~/common/signal.d.ts";
3
3
+
import type { Track } from "~/definitions/types.d.ts";
4
4
+
5
5
+
export type ScrobbleElement = DiffuseElement & ScrobbleActions & {
6
6
+
isAuthenticated: SignalReader<boolean>;
7
7
+
handle: SignalReader<string | null>;
8
8
+
};
9
9
+
10
10
+
export type ScrobbleActions = {
11
11
+
nowPlaying(track: Track): Promise<unknown>;
12
12
+
13
13
+
/**
14
14
+
* @param {Track} track
15
15
+
* @param {number} startedAt Unix timestamp in milliseconds
16
16
+
*/
17
17
+
scrobble(track: Track, startedAt: number): Promise<unknown>;
18
18
+
};
+93
src/facets/scrobble/last.fm/index.html
···
1
1
+
<link rel="stylesheet" href="vendor/@awesome.me/webawesome/styles/themes/default.css" />
2
2
+
3
3
+
<main>
4
4
+
<wa-card>
5
5
+
<div slot="header" class="card-header">
6
6
+
<strong>Last.fm</strong>
7
7
+
<!--<wa-button id="settings-btn" appearance="plain" size="small" aria-label="API credentials">
8
8
+
<wa-icon name="gear"></wa-icon>
9
9
+
</wa-button>-->
10
10
+
</div>
11
11
+
12
12
+
<div id="state-connect" class="card-body">
13
13
+
<p>Connect your Last.fm account to start scrobbling.</p>
14
14
+
<wa-button id="sign-in-btn" variant="brand" appearance="filled">
15
15
+
<wa-icon slot="prefix" name="plug"></wa-icon>
16
16
+
Connect
17
17
+
</wa-button>
18
18
+
</div>
19
19
+
20
20
+
<div id="state-connected" class="card-body" hidden>
21
21
+
<p id="handle-paragraph" hidden>Connected as <strong id="handle-text"></strong>.</p>
22
22
+
<wa-button id="sign-out-btn" variant="neutral" appearance="outlined" hidden>
23
23
+
<wa-icon slot="prefix" name="plug-slash"></wa-icon>
24
24
+
Disconnect
25
25
+
</wa-button>
26
26
+
</div>
27
27
+
</wa-card>
28
28
+
</main>
29
29
+
30
30
+
<wa-drawer id="credentials-drawer" label="API Credentials" placement="end">
31
31
+
<div class="drawer-body">
32
32
+
<wa-input id="api-key-input" label="API Key" placeholder="Default"></wa-input>
33
33
+
<wa-input
34
34
+
id="api-secret-input"
35
35
+
label="API Secret"
36
36
+
type="password"
37
37
+
placeholder="Default"
38
38
+
></wa-input>
39
39
+
</div>
40
40
+
<div slot="footer" class="drawer-footer">
41
41
+
<wa-button id="save-creds-btn" variant="brand" appearance="filled">Save</wa-button>
42
42
+
<wa-button id="reset-creds-btn" variant="neutral" appearance="outlined"
43
43
+
>Reset to defaults</wa-button
44
44
+
>
45
45
+
</div>
46
46
+
</wa-drawer>
47
47
+
48
48
+
<style>
49
49
+
body {
50
50
+
display: flex;
51
51
+
align-items: center;
52
52
+
justify-content: center;
53
53
+
min-height: 100dvh;
54
54
+
margin: 0;
55
55
+
}
56
56
+
57
57
+
wa-card {
58
58
+
width: min(360px, calc(100vw - 2rem));
59
59
+
}
60
60
+
61
61
+
.card-header {
62
62
+
display: flex;
63
63
+
align-items: center;
64
64
+
justify-content: space-between;
65
65
+
}
66
66
+
67
67
+
.card-body {
68
68
+
display: flex;
69
69
+
flex-direction: column;
70
70
+
gap: var(--wa-space-m);
71
71
+
}
72
72
+
73
73
+
.drawer-body {
74
74
+
display: flex;
75
75
+
flex-direction: column;
76
76
+
gap: var(--wa-space-m);
77
77
+
}
78
78
+
79
79
+
.drawer-footer {
80
80
+
display: flex;
81
81
+
gap: var(--wa-space-s);
82
82
+
}
83
83
+
84
84
+
[hidden] {
85
85
+
display: none !important;
86
86
+
}
87
87
+
88
88
+
p {
89
89
+
margin: 0;
90
90
+
}
91
91
+
</style>
92
92
+
93
93
+
<script type="module" src="./index.inline.js"></script>
+150
src/facets/scrobble/last.fm/index.inline.js
···
1
1
+
import "@awesome.me/webawesome/dist/components/card/card.js";
2
2
+
import "@awesome.me/webawesome/dist/components/button/button.js";
3
3
+
import "@awesome.me/webawesome/dist/components/drawer/drawer.js";
4
4
+
import "@awesome.me/webawesome/dist/components/input/input.js";
5
5
+
import "@awesome.me/webawesome/dist/components/icon/icon.js";
6
6
+
7
7
+
import "~/common/webawesome/detect-dark.js";
8
8
+
9
9
+
import LastFmSupplement from "~/components/supplement/last.fm/element.js";
10
10
+
import { effect } from "~/common/signal.js";
11
11
+
12
12
+
/**
13
13
+
* @import { default as WaDrawer } from "@awesome.me/webawesome/dist/components/drawer/drawer.js"
14
14
+
* @import { default as WaInput } from "@awesome.me/webawesome/dist/components/input/input.js"
15
15
+
*/
16
16
+
17
17
+
////////////////////////////////////////////
18
18
+
// SETUP
19
19
+
////////////////////////////////////////////
20
20
+
21
21
+
const CREDS_KEY = "diffuse/supplement/last.fm/credentials";
22
22
+
23
23
+
/** @returns {{ apiKey: string, apiSecret: string } | null} */
24
24
+
function loadCredentials() {
25
25
+
try {
26
26
+
return JSON.parse(localStorage.getItem(CREDS_KEY) ?? "null");
27
27
+
} catch {
28
28
+
return null;
29
29
+
}
30
30
+
}
31
31
+
32
32
+
// Find existing or create new ds-lastfm element
33
33
+
let lastFm = /** @type {LastFmSupplement | null} */ (
34
34
+
document.body.querySelector("ds-lastfm")
35
35
+
);
36
36
+
37
37
+
if (!lastFm) {
38
38
+
lastFm = new LastFmSupplement();
39
39
+
const creds = loadCredentials();
40
40
+
if (creds) {
41
41
+
lastFm.setAttribute("api-key", creds.apiKey);
42
42
+
lastFm.setAttribute("api-secret", creds.apiSecret);
43
43
+
}
44
44
+
document.body.append(lastFm);
45
45
+
}
46
46
+
47
47
+
await customElements.whenDefined(lastFm.localName);
48
48
+
49
49
+
////////////////////////////////////////////
50
50
+
// ELEMENTS
51
51
+
////////////////////////////////////////////
52
52
+
53
53
+
const stateConnect = /** @type {HTMLElement} */ (
54
54
+
document.querySelector("#state-connect")
55
55
+
);
56
56
+
const stateConnected = /** @type {HTMLElement} */ (
57
57
+
document.querySelector("#state-connected")
58
58
+
);
59
59
+
const handleParagraph = /** @type {HTMLElement} */ (
60
60
+
document.querySelector("#handle-paragraph")
61
61
+
);
62
62
+
const handleText = /** @type {HTMLElement} */ (
63
63
+
document.querySelector("#handle-text")
64
64
+
);
65
65
+
66
66
+
const settingsBtn = /** @type {HTMLElement} */ (
67
67
+
document.querySelector("#settings-btn")
68
68
+
);
69
69
+
70
70
+
const signInBtn = /** @type {HTMLElement} */ (
71
71
+
document.querySelector("#sign-in-btn")
72
72
+
);
73
73
+
74
74
+
const signOutBtn = /** @type {HTMLElement} */ (
75
75
+
document.querySelector("#sign-out-btn")
76
76
+
);
77
77
+
78
78
+
const credentialsDrawer = /** @type {WaDrawer} */ (
79
79
+
document.querySelector("#credentials-drawer")
80
80
+
);
81
81
+
82
82
+
const apiKeyInput = /** @type {WaInput} */ (
83
83
+
document.querySelector("#api-key-input")
84
84
+
);
85
85
+
86
86
+
const apiSecretInput = /** @type {WaInput} */ (
87
87
+
document.querySelector("#api-secret-input")
88
88
+
);
89
89
+
90
90
+
const saveCredsBtn = /** @type {HTMLElement} */ (
91
91
+
document.querySelector("#save-creds-btn")
92
92
+
);
93
93
+
94
94
+
const resetCredsBtn = /** @type {HTMLElement} */ (
95
95
+
document.querySelector("#reset-creds-btn")
96
96
+
);
97
97
+
98
98
+
// Pre-fill drawer inputs with stored credentials
99
99
+
const existingCreds = loadCredentials();
100
100
+
if (existingCreds) {
101
101
+
apiKeyInput.value = existingCreds.apiKey;
102
102
+
apiSecretInput.value = existingCreds.apiSecret;
103
103
+
}
104
104
+
105
105
+
////////////////////////////////////////////
106
106
+
// REACTIVE UI
107
107
+
////////////////////////////////////////////
108
108
+
109
109
+
effect(() => {
110
110
+
const isAuthenticated = lastFm.isAuthenticated();
111
111
+
const handle = lastFm.handle();
112
112
+
113
113
+
stateConnect.hidden = isAuthenticated;
114
114
+
stateConnected.hidden = !isAuthenticated;
115
115
+
116
116
+
handleParagraph.hidden = !handle;
117
117
+
signOutBtn.hidden = !isAuthenticated;
118
118
+
if (handle) handleText.textContent = handle;
119
119
+
});
120
120
+
121
121
+
////////////////////////////////////////////
122
122
+
// ACTIONS
123
123
+
////////////////////////////////////////////
124
124
+
125
125
+
settingsBtn?.addEventListener("click", (e) => {
126
126
+
e.stopPropagation();
127
127
+
credentialsDrawer.open = true;
128
128
+
});
129
129
+
130
130
+
signInBtn.onclick = () => lastFm.signIn();
131
131
+
132
132
+
signOutBtn.onclick = () => lastFm.signOut();
133
133
+
134
134
+
saveCredsBtn.onclick = () => {
135
135
+
const apiKey = apiKeyInput.value?.trim();
136
136
+
const apiSecret = apiSecretInput.value?.trim();
137
137
+
if (!apiKey || !apiSecret) return;
138
138
+
139
139
+
localStorage.setItem(CREDS_KEY, JSON.stringify({ apiKey, apiSecret }));
140
140
+
lastFm.setAttribute("api-key", apiKey);
141
141
+
lastFm.setAttribute("api-secret", apiSecret);
142
142
+
};
143
143
+
144
144
+
resetCredsBtn.onclick = () => {
145
145
+
localStorage.removeItem(CREDS_KEY);
146
146
+
lastFm.removeAttribute("api-key");
147
147
+
lastFm.removeAttribute("api-secret");
148
148
+
apiKeyInput.value = "";
149
149
+
apiSecretInput.value = "";
150
150
+
};