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 { 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, tasks: 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", tracks };
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: Record<string, Record<string, Track>> = {};
74
75 cachedTracks.forEach((t: Track) => {
76 const parsed = parseURI(t.uri);
77 if (!parsed || !parsed.path) return;
78
79 const sid = serverId(parsed?.server);
80
81 cache[sid] ??= {};
82 cache[sid][URI.unescapeComponent(parsed.path)] = t;
83 });
84
85 async function search(client: SubsonicAPI, offset = 0): Promise<Child[]> {
86 const result = await client.search3({
87 query: "",
88 artistCount: 0,
89 albumCount: 0,
90 songCount: 1000,
91 songOffset: offset,
92 });
93
94 const songs = result.searchResult3.song || [];
95
96 if (songs.length === 1000) {
97 const moreSongs = await search(client, offset + 1000);
98 return [...songs, ...moreSongs];
99 }
100
101 return songs;
102 }
103
104 const servers = await loadServers();
105 const promises = Object.values(servers).map(async (server) => {
106 const client = createClient(server);
107 const sid = serverId(server);
108 const list = await search(client, 0);
109
110 return list
111 .filter((song) => !song.isVideo)
112 .map((song) => {
113 const path = song.path
114 ? song.path.startsWith("/")
115 ? song.path
116 : `/${song.path}`
117 : undefined;
118 const fromCache = path ? cache[sid]?.[path] : undefined;
119 if (fromCache) return fromCache;
120
121 const track: Track = {
122 id: crypto.randomUUID(),
123 kind: autoTypeToTrackKind(song.type),
124 uri: buildURI(server, { songId: song.id, path }),
125
126 stats: {
127 bitrate: song.bitRate,
128 duration: song.duration,
129 },
130 tags: {
131 album: song.album,
132 artist: song.artist,
133 disc: { no: song.discNumber || 1 },
134 genre: song.genre,
135 title: song.title,
136 track: { no: song.track || 1 },
137 year: song.year,
138 },
139 };
140
141 return track;
142 });
143 });
144
145 const tracks = (await Promise.all(promises)).flat(1);
146 return transfer(tracks);
147}
148
149async function resolve({ uri }: { method: string; uri: string }) {
150 const parsed = parseURI(uri);
151 if (!parsed) return undefined;
152
153 const client = createClient(parsed.server);
154 const songId = parsed.songId;
155 if (!songId) return undefined;
156
157 // TODO:
158 // const expiresInSeconds = 60 * 60 * 24 * 7; // 7 days
159 // const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds;
160
161 const url = await client
162 .download({
163 id: songId,
164 format: "raw",
165 })
166 .then((a) => a.url);
167
168 return { expiresAt: Infinity, url };
169}