Experiment to rebuild Diffuse using web applets.
1import { SubsonicAPI, type Child } from "subsonic-api";
2import * as URI from "uri-js";
3
4import type { Consult, ConsultGrouping, GroupConsult, Track } from "@applets/core/types.d.ts";
5import { SCHEME } from "./constants.ts";
6import {
7 autoTypeToTrackKind,
8 buildURI,
9 consultServer,
10 createClient,
11 groupTracksByServer,
12 loadServers,
13 parseURI,
14 serverId,
15 serversFromTracks,
16} from "./common.ts";
17import { expose, transfer } from "@scripts/common.ts";
18
19////////////////////////////////////////////
20// ACTIONS
21////////////////////////////////////////////
22const actions = expose({
23 consult,
24 contextualize,
25 groupConsult,
26 list,
27 resolve,
28});
29
30export type Actions = typeof actions;
31
32// Actions
33
34async function consult(fileUriOrScheme: string): Promise<Consult> {
35 if (!fileUriOrScheme.includes(":")) return { supported: true, consult: "undetermined" };
36
37 const parsed = parseURI(fileUriOrScheme);
38 if (!parsed) return { supported: true, consult: "undetermined" };
39
40 const consult = await consultServer(parsed.server);
41 return { supported: true, consult };
42}
43
44async function contextualize(tracks: Track[]) {
45 return serversFromTracks(tracks);
46}
47
48async function groupConsult(tracks: Track[]): Promise<GroupConsult> {
49 const groups = groupTracksByServer(tracks);
50
51 const promises = Object.entries(groups).map(async ([serverId, { server, tracks }]) => {
52 const available = await consultServer(server);
53 const grouping: ConsultGrouping = available
54 ? { available, tracks }
55 : { available, reason: "Server ping failed" };
56
57 return {
58 key: `${SCHEME}:${serverId}`,
59 grouping,
60 };
61 });
62
63 const entries = (await Promise.all(promises)).map((entry) => [entry.key, entry.grouping]);
64 const obj = Object.fromEntries(entries);
65
66 return transfer(obj);
67}
68
69async function list(cachedTracks: Track[] = []) {
70 const cache = cachedTracks.reduce((acc: Record<string, Record<string, Track>>, t: Track) => {
71 const parsed = parseURI(t.uri);
72 if (!parsed || !parsed.path) return acc;
73
74 const sid = serverId(parsed?.server);
75 const trk = { [URI.unescapeComponent(parsed.path)]: t };
76
77 return { ...acc, [sid]: acc[sid] ? { ...acc[sid], ...trk } : trk };
78 }, {});
79
80 async function search(client: SubsonicAPI, offset = 0): Promise<Child[]> {
81 const result = await client.search3({
82 query: "",
83 artistCount: 0,
84 albumCount: 0,
85 songCount: 1000,
86 songOffset: offset,
87 });
88
89 const songs = result.searchResult3.song || [];
90
91 if (songs.length === 1000) {
92 const moreSongs = await search(client, offset + 1000);
93 return [...songs, ...moreSongs];
94 }
95
96 return songs;
97 }
98
99 const servers = await loadServers();
100 const promises = Object.values(servers).map(async (server) => {
101 const client = createClient(server);
102 const sid = serverId(server);
103 const list = await search(client, 0);
104
105 return list
106 .filter((song) => !song.isVideo)
107 .map((song) => {
108 const path = song.path
109 ? song.path.startsWith("/")
110 ? song.path
111 : `/${song.path}`
112 : undefined;
113 const fromCache = path ? cache[sid]?.[path] : undefined;
114 if (fromCache) return fromCache;
115
116 const track: Track = {
117 id: crypto.randomUUID(),
118 kind: autoTypeToTrackKind(song.type),
119 uri: buildURI(server, { songId: song.id, path }),
120
121 stats: {
122 bitrate: song.bitRate,
123 duration: song.duration,
124 },
125 tags: {
126 album: song.album,
127 artist: song.artist,
128 disc: { no: song.discNumber || 1 },
129 genre: song.genre,
130 title: song.title,
131 track: { no: song.track || 1 },
132 year: song.year,
133 },
134 };
135
136 return track;
137 });
138 });
139
140 const tracks = (await Promise.all(promises)).flat(1);
141 return transfer(tracks);
142}
143
144async function resolve({ uri }: { method: string; uri: string }) {
145 const parsed = parseURI(uri);
146 if (!parsed) return undefined;
147
148 const client = createClient(parsed.server);
149 const songId = parsed.songId;
150 if (!songId) return undefined;
151
152 // TODO:
153 // const expiresInSeconds = 60 * 60 * 24 * 7; // 7 days
154 // const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds;
155
156 const url = await client
157 .download({
158 id: songId,
159 format: "raw",
160 })
161 .then((a) => a.blob())
162 .then((blob) => URL.createObjectURL(blob));
163
164 // NOTE:
165 // First idea was to get the URL for the download and use that instead.
166 // Problem is, more often than not, servers don't allow for CORS Range requests,
167 // so it's basically useless.
168
169 return { expiresAt: Infinity, url };
170}