data endpoint for entity 90008 (aka. a website)
0
fork

Configure Feed

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

add share link to music

dawn 197116d9 da19da6c

+175 -6
+39 -3
eunomia/src/lib/lastfm.ts
··· 1 1 import { env } from '$env/dynamic/private'; 2 2 import sharp from 'sharp'; 3 3 import { get, writable } from 'svelte/store'; 4 + import * as Navidrome from './navidrome'; 4 5 5 6 const DID = 'did:plc:dfl62fgb7wtjj3fcbb72naae'; 6 7 const PDS = 'https://zwsp.xyz'; ··· 14 15 image: string | null; // Single image URL 15 16 link: string | null; 16 17 when: number; 18 + shareUrl?: string; 19 + shareId?: string; 17 20 status: 'playing' | 'played'; 18 21 }; 19 22 const lastTrack = writable<LastTrack | null>(null); ··· 146 149 147 150 if (!track) return; 148 151 152 + // Check if track changed to handle share links 153 + const currentData = get(lastTrack); 154 + const isNewTrack = !currentData || currentData.name !== track.trackName || currentData.artist !== (joinArtists(track.artists) ?? 'Unknown Artist'); 155 + let shareId = currentData?.shareId; 156 + let link = track.originUrl ?? (track.recordingMbId ? `https://musicbrainz.org/recording/${track.recordingMbId}` : null); 157 + 158 + let shareUrl = undefined; 159 + if (isNewTrack) { 160 + // Try to create new share link (no need to delete old one, they expire) 161 + try { 162 + const songId = await Navidrome.findSong(track.trackName, joinArtists(track.artists) ?? ''); 163 + if (songId) { 164 + const newShareId = await Navidrome.createShareLink(songId, `${track.trackName}`); 165 + if (newShareId) { 166 + shareId = newShareId; 167 + shareUrl = Navidrome.getShareUrl(newShareId); 168 + } else { 169 + shareId = undefined; 170 + } 171 + } else { 172 + shareId = undefined; 173 + } 174 + } catch (err) { 175 + console.error('Failed to handle Navidrome share:', err); 176 + shareId = undefined; 177 + } 178 + } else { 179 + // Keep existing Navidrome link if we have one and track hasn't changed 180 + if (shareId) { 181 + shareUrl = Navidrome.getShareUrl(shareId); 182 + } 183 + } 184 + 149 185 const coverArt = await getCoverArt(track.releaseMbId, track.originUrl); 150 186 151 187 const data: LastTrack = { ··· 153 189 artist: joinArtists(track.artists) ?? 'Unknown Artist', 154 190 album: track.releaseName ?? 'Unknown Album', 155 191 image: coverArt, 156 - link: 157 - track.originUrl ?? 158 - (track.recordingMbId ? `https://musicbrainz.org/recording/${track.recordingMbId}` : null), 192 + link: link, 159 193 when: when, 194 + shareUrl: shareUrl, 195 + shareId: shareId, 160 196 status: status 161 197 }; 162 198
+124
eunomia/src/lib/navidrome.ts
··· 1 + import { env } from '$env/dynamic/private'; 2 + import { createHash } from 'crypto'; 3 + 4 + const ndUrl = env.NAVIDROME_URL; 5 + const ndUser = env.NAVIDROME_USERNAME; 6 + const ndPass = env.NAVIDROME_PASSWORD; 7 + 8 + const CLIENT_NAME = 'Eunomia'; 9 + const API_VERSION = '1.16.1'; 10 + 11 + /** 12 + * Generates Subsonic authentication parameters. 13 + * Uses legacy token authentication: t = md5(password + salt) 14 + */ 15 + const getAuthParams = () => { 16 + if (!ndUser || !ndPass) return ''; 17 + 18 + // Generate a random salt (6 chars is enough) 19 + const salt = Math.random().toString(36).substring(2, 8); 20 + // Calculate token = md5(password + salt) 21 + const token = createHash('md5').update(ndPass + salt).digest('hex'); 22 + 23 + return `u=${encodeURIComponent(ndUser)}&t=${token}&s=${salt}&v=${API_VERSION}&c=${CLIENT_NAME}&f=json`; 24 + }; 25 + 26 + /** 27 + * Helper to perform authenticated requests to Subsonic API. 28 + */ 29 + const request = async (endpoint: string, queryParams: Record<string, string> = {}): Promise<any | null> => { 30 + if (!ndUrl) return null; 31 + 32 + const auth = getAuthParams(); 33 + const qs = new URLSearchParams(queryParams).toString(); 34 + const url = `${ndUrl}/rest/${endpoint}.view?${auth}&${qs}`; 35 + 36 + try { 37 + const res = await fetch(url); 38 + if (!res.ok) { 39 + console.error(`Subsonic request failed: ${res.status}`); 40 + return null; 41 + } 42 + 43 + const data = await res.json(); 44 + const response = data['subsonic-response']; 45 + 46 + if (response.status === 'failed') { 47 + console.error(`Subsonic API error: ${response.error?.message} (code ${response.error?.code})`); 48 + return null; 49 + } 50 + 51 + return response; 52 + } catch (err) { 53 + console.error('Subsonic request error:', err); 54 + return null; 55 + } 56 + }; 57 + 58 + /** 59 + * Search for a song by title and artist using `search3` endpoint. 60 + */ 61 + export const findSong = async ( 62 + title: string, 63 + artist: string 64 + ): Promise<string | null> => { 65 + try { 66 + // Query usually matches everything, so we try "Artist Title" 67 + const query = `${artist} ${title}`; 68 + const res = await request('search3', { 69 + query, 70 + songCount: '1', 71 + songOffset: '0' 72 + }); 73 + 74 + if (!res || !res.searchResult3 || !res.searchResult3.song) return null; 75 + 76 + const songs = res.searchResult3.song; 77 + if (!Array.isArray(songs) || songs.length === 0) return null; 78 + 79 + return songs[0].id; 80 + } catch (err) { 81 + console.error('Navidrome findSong error:', err); 82 + return null; 83 + } 84 + }; 85 + 86 + /** 87 + * Create a share link for a song using `createShare` endpoint. 88 + */ 89 + export const createShareLink = async ( 90 + songId: string, 91 + description: string = 'Generated by Eunomia' 92 + ): Promise<string | null> => { 93 + try { 94 + // Expiry: 1 day from now (in milliseconds) 95 + const expires = (Date.now() + 24 * 60 * 60 * 1000).toString(); 96 + 97 + const res = await request('createShare', { 98 + id: songId, 99 + description, 100 + expires 101 + }); 102 + 103 + if (!res || !res.shares || !res.shares.share) return null; 104 + 105 + // share key contains an array of shares, we take the one we just created (usually the last/only one returned?) 106 + // The API returns the *created* share(s). 107 + const shares = res.shares.share; 108 + const share = Array.isArray(shares) ? shares[0] : shares; 109 + 110 + // Return only the ID (e.g. "sOmeTh1ng") which is used in the URL. 111 + // Actually, Subsonic `id` for share might be numeric or string. 112 + // Navidrome creates shares with a short alphanumeric string usually. 113 + return share.id; 114 + 115 + } catch (err) { 116 + console.error('Navidrome createShareLink error:', err); 117 + return null; 118 + } 119 + }; 120 + 121 + export const getShareUrl = (shareId: string): string => { 122 + // Navidrome share URLs: /share/{id} 123 + return `${ndUrl}/share/${shareId}`; 124 + }
+12 -3
eunomia/src/routes/(site)/+page.svelte
··· 185 185 /> 186 186 <div class="flex flex-col max-w-[60ch] p-2 text-ellipsis overflow-hidden"> 187 187 <p 188 - class="text-shadow-green text-ralsei-green-light text-sm text-ellipsis text-nowrap overflow-hidden max-w-[45ch]" 188 + class="max-w-[45ch] flex flex-row gap-[0.8ch] items-baseline overflow-hidden text-shadow-green text-ralsei-green-light text-sm" 189 189 > 190 - <span class="text-sm text-shadow-white text-ralsei-white" 190 + <span class="shrink-0 text-shadow-white text-ralsei-white" 191 191 >{data.lastTrack.status === 'playing' ? 'listening to' : 'listened to'}</span 192 192 > 193 193 <a 194 194 title={data.lastTrack.name} 195 195 href={data.lastTrack.link ?? 196 196 'https://tealfm-slice.wisp.place/profile/ptr.pet/scrobbles'} 197 - class="hover:underline motion-safe:hover:animate-squiggle">{data.lastTrack.name}</a 197 + class="truncate min-w-0 hover:underline motion-safe:hover:animate-squiggle" 198 + >{data.lastTrack.name}</a 198 199 > 200 + {#if data.lastTrack.shareUrl} 201 + <a 202 + href={data.lastTrack.shareUrl} 203 + title="listen on tunes.ptr.pet" 204 + class="text-ralsei-pink-neon text-shadow-none shrink-0 hover:underline motion-safe:hover:animate-squiggle" 205 + >[listen]</a 206 + > 207 + {/if} 199 208 </p> 200 209 {#if showAlbum} 201 210 <p