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