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