Experiment to rebuild Diffuse using web applets.
0

Configure Feed

Select the types of activity you want to include in your feed.

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}