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

Configure Feed

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

at svelte 209 lines 6.1 kB view raw
1import { env } from '$env/dynamic/private'; 2import sharp from 'sharp'; 3import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; 4import { get, writable } from 'svelte/store'; 5import * as Navidrome from './navidrome'; 6 7const DID = 'did:plc:dfl62fgb7wtjj3fcbb72naae'; 8const PDS = 'https://zwsp.xyz'; 9const LAST_TRACK_FILE = `${env.WEBSITE_DATA_DIR}/last_track.json`; 10const COVER_ART_CACHE_DIR = `${env.WEBSITE_DATA_DIR}/cover_art_cache`; 11 12type LastTrack = { 13 name: string; 14 artist: string; 15 album: string; 16 image: string | null; // Single image URL 17 link: string | null; 18 when: number; 19 shareUrl?: string; 20 shareId?: string; 21 status: 'playing' | 'played'; 22}; 23const lastTrack = writable<LastTrack | null>(null); 24 25// Ensure cache directory exists 26const ensureCacheDir = async () => { 27 try { 28 await mkdir(COVER_ART_CACHE_DIR, { recursive: true }); 29 } catch (err) { 30 // Directory might already exist, ignore error 31 } 32}; 33 34// Helper to fetch, optimize, and cache an image 35const fetchAndCacheImage = async (url: string, id: string): Promise<string | null> => { 36 const cacheFile = `${COVER_ART_CACHE_DIR}/${id}.webp`; 37 38 // Check if already cached 39 try { 40 await stat(cacheFile); 41 return `/cover_art/${id}.webp`; 42 } catch { 43 // Not cached, try to fetch 44 } 45 46 try { 47 const response = await fetch(url); 48 49 if (!response.ok) { 50 return null; 51 } 52 53 const imageData = await response.arrayBuffer(); 54 55 const sharpImg = sharp(imageData).resize({ width: 92 }).webp({ quality: 80 }); 56 const optimizedImage = await sharpImg.toBuffer(); 57 sharpImg.destroy(); 58 59 await writeFile(cacheFile, optimizedImage); 60 61 return `/cover_art/${id}.webp`; 62 } catch (err) { 63 console.log(`Failed to fetch/cache image for ${id}:`, err); 64 return null; 65 } 66}; 67 68// Fetch and cache MusicBrainz cover art 69const fetchAndCacheCoverArt = async (releaseMbId: string): Promise<string | null> => { 70 const mbUrl = `https://coverartarchive.org/release/${releaseMbId}/front-250`; 71 return fetchAndCacheImage(mbUrl, releaseMbId); 72}; 73 74// Fetch and cache YouTube thumbnail 75const fetchAndCacheYouTubeThumbnail = async (originUrl: string | null | undefined): Promise<string | null> => { 76 if (!originUrl) return null; 77 78 try { 79 let videoId: string | null = null; 80 if (originUrl.includes('youtube.com') || originUrl.includes('music.youtube.com')) { 81 videoId = new URL(originUrl).searchParams.get('v'); 82 } else if (originUrl.includes('youtu.be')) { 83 videoId = originUrl.split('youtu.be/')[1]?.split('?')[0]; 84 } 85 if (videoId) { 86 const ytUrl = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`; 87 return fetchAndCacheImage(ytUrl, videoId); 88 } 89 } catch { } 90 91 return null; 92}; 93 94// Get cover art with caching 95const getCoverArt = async ( 96 releaseMbId: string | null | undefined, 97 originUrl: string | null | undefined 98): Promise<string | null> => { 99 // Try MusicBrainz first (with caching) 100 if (releaseMbId) { 101 const mbImage = await fetchAndCacheCoverArt(releaseMbId); 102 if (mbImage) return mbImage; 103 } 104 105 // Fall back to YouTube thumbnail (with caching) 106 return fetchAndCacheYouTubeThumbnail(originUrl); 107}; 108 109export const getLastTrack = async () => { 110 try { 111 const data = await readFile(LAST_TRACK_FILE, 'utf8'); 112 lastTrack.set(JSON.parse(data)); 113 } catch (why) { 114 console.log('could not read last track: ', why); 115 lastTrack.set(null); 116 } 117}; 118 119const joinArtists = (artists: any[]) => { 120 if (!artists || artists.length === 0) return null; 121 const uniqueArtists = [...new Set(artists.map((a) => a.artistName))]; 122 return uniqueArtists.join(', '); 123}; 124 125export const updateNowPlayingTrack = async () => { 126 await ensureCacheDir(); 127 128 try { 129 let track: any = null; 130 let when: number = Date.now(); 131 let status: 'playing' | 'played' = 'played'; 132 133 try { 134 const statusRes = await fetch( 135 `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=fm.teal.alpha.actor.status&rkey=self` 136 ); 137 if (statusRes.ok) { 138 const statusData = await statusRes.json(); 139 if (statusData.value?.item) { 140 const metadata = statusData.value; 141 track = statusData.value.item; 142 if (track.playedTime) when = new Date(track.playedTime).getTime(); 143 status = 144 Date.now() / 1000 >= parseInt(metadata.time) + track.duration ? 'played' : 'playing'; 145 } 146 } 147 } catch (err) { 148 console.log('could not fetch teal status:', err); 149 } 150 151 if (!track) return; 152 153 // Check if track changed to handle share links 154 const currentData = get(lastTrack); 155 const isNewTrack = !currentData || currentData.name !== track.trackName || currentData.artist !== (joinArtists(track.artists) ?? 'Unknown Artist'); 156 let shareId = currentData?.shareId; 157 let link = track.originUrl ?? (track.recordingMbId ? `https://musicbrainz.org/recording/${track.recordingMbId}` : null); 158 159 let shareUrl = undefined; 160 if (isNewTrack) { 161 // Try to create new share link (no need to delete old one, they expire) 162 try { 163 const songId = await Navidrome.findSong(track.trackName, joinArtists(track.artists) ?? ''); 164 if (songId) { 165 const newShareId = await Navidrome.createShareLink(songId, `${track.trackName}`); 166 if (newShareId) { 167 shareId = newShareId; 168 shareUrl = Navidrome.getShareUrl(newShareId); 169 } else { 170 shareId = undefined; 171 } 172 } else { 173 shareId = undefined; 174 } 175 } catch (err) { 176 console.error('Failed to handle Navidrome share:', err); 177 shareId = undefined; 178 } 179 } else { 180 // Keep existing Navidrome link if we have one and track hasn't changed 181 if (shareId) { 182 shareUrl = Navidrome.getShareUrl(shareId); 183 } 184 } 185 186 const coverArt = await getCoverArt(track.releaseMbId, track.originUrl); 187 188 const data: LastTrack = { 189 name: track.trackName, 190 artist: joinArtists(track.artists) ?? 'Unknown Artist', 191 album: track.releaseName ?? 'Unknown Album', 192 image: coverArt, 193 link: link, 194 when: when, 195 shareUrl: shareUrl, 196 shareId: shareId, 197 status: status 198 }; 199 200 lastTrack.set(data); 201 await writeFile(LAST_TRACK_FILE, JSON.stringify(data), 'utf8'); 202 } catch (why) { 203 console.log('could not fetch teal fm: ', why); 204 } 205}; 206 207export const getNowPlayingTrack = () => { 208 return get(lastTrack); 209};