data endpoint for entity 90008 (aka. a website)
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};