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, 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}