Shows some quick stats about your teal.fm records. Kind of like Spotify Wrapped
at main 12 kB view raw
1<script setup lang="ts"> 2import {ref} from "vue"; 3import { 4 CompositeDidDocumentResolver, 5 CompositeHandleResolver, 6 DohJsonHandleResolver, 7 PlcDidDocumentResolver, 8 WebDidDocumentResolver, 9 WellKnownHandleResolver, 10} from "@atcute/identity-resolver"; 11import {AtpAgent} from "@atproto/api"; 12import * as TID from "@atcute/tid"; 13 14//Should be using the lexicons to generate types... 15// Types for fm.teal.alpha.feed.play 16interface PlayArtist { 17 artistMbId?: string; 18 artistName: string; 19} 20interface PlayValue { 21 $type?: "fm.teal.alpha.feed.play"; 22 artists?: PlayArtist[]; 23 // legacy support: earlier records may use artistNames 24 artistNames?: string[]; 25 trackName: string; 26 playedTime: string; // ISO string 27 releaseMbId?: string; 28 releaseName?: string; 29 recordingMbId?: string; 30 submissionClientAgent?: string; 31} 32interface ListRecord<T> { 33 uri: string; 34 cid: string; 35 value: T; 36} 37 38// handle resolution 39const handleResolver = new CompositeHandleResolver({ 40 strategy: "race", 41 methods: { 42 dns: new DohJsonHandleResolver({ 43 dohUrl: "https://mozilla.cloudflare-dns.com/dns-query", 44 }), 45 http: new WellKnownHandleResolver(), 46 }, 47}); 48 49const docResolver = new CompositeDidDocumentResolver({ 50 methods: { 51 plc: new PlcDidDocumentResolver(), 52 web: new WebDidDocumentResolver(), 53 }, 54}); 55 56const userHandle = ref(""); 57const loading = ref(false); 58const artists = ref<{ name: string; plays: number }[]>([]); 59const tracks = ref<{ name: string; artist: string; plays: number }[]>([]); 60const topDays = ref<{ date: string; plays: number }[]>([]); 61const totalSongs = ref(0); 62const errorMessage = ref<string | null>(null); 63 64// Advanced controls 65const topLimit = ref<number>(25); 66const startDate = ref<string | null>(null); // format: YYYY-MM-DD (local) 67const endDate = ref<string | null>(null); 68 69const formatNumber = (n: number) => n.toLocaleString(); 70 71// Returns YYYY-MM-DD in the browser's local time zone 72const localDayKey = (iso?: string): string | null => { 73 if (!iso) return null; 74 const d = new Date(iso); 75 if (isNaN(d.getTime())) return null; 76 const y = d.getFullYear(); 77 const m = (d.getMonth() + 1).toString().padStart(2, "0"); 78 const day = d.getDate().toString().padStart(2, "0"); 79 return `${y}-${m}-${day}`; 80}; 81 82const lookup = async () => { 83 loading.value = true; 84 //Clear values 85 errorMessage.value = null; 86 artists.value = []; 87 tracks.value = []; 88 topDays.value = []; 89 totalSongs.value = 0; 90 91 try { 92 const did = await handleResolver.resolve( 93 userHandle.value as `${string}.${string}`, 94 ); 95 96 if (did == undefined) { 97 throw new Error("expected handle to resolve"); 98 } 99 console.log(did); // did:plc:ewvi7nxzyoun6zhxrhs64oiz 100 101 const doc = await docResolver.resolve(did); 102 console.log(doc); 103 104 const endpoint = doc.service?.[0]?.serviceEndpoint as string | undefined; 105 if (!endpoint) { 106 throw new Error("could not resolve service endpoint for DID"); 107 } 108 const agent = new AtpAgent({ 109 service: endpoint, 110 }); 111 let cursor: string | null = null; 112 let totalCount = 0; 113 let inner_tracks: { name: string; artist: string; plays: number }[] = []; 114 let inner_artists: { name: string; plays: number }[] = []; 115 const dayCountMap = new Map<string, number>(); 116 117 //If dates are set want to reverse 118 const reverse: boolean = startDate.value !== null && endDate.value !== null; 119 if (reverse) { 120 //Couple checks on dates 121 if(endDate.value === null) { 122 endDate.value = new Date().toISOString().split('T')[0]; 123 } 124 125 if (startDate.value !== null && startDate.value > endDate.value) { 126 throw new Error("Start date must be before end date"); 127 } 128 129 //Get the tid of start time to filter in reverse since 130 const startDateTime = new Date(startDate.value + 'T00:00:00.000'); 131 const micros = startDateTime.getTime() * 1000; // convert ms to µs 132 //Might as well use a lucky number for clock id 133 cursor = TID.create(micros, 23); 134 } 135 136 let response = await agent.com.atproto.repo.listRecords({ 137 repo: did, 138 collection: "fm.teal.alpha.feed.play", 139 limit: 100, 140 reverse: reverse, 141 ...(cursor ? { cursor } : {}), 142 }); 143 144 145 // Process pages incrementally while fetching them 146 let shouldStop = false; 147 let endDateTime = new Date(endDate.value + 'T23:59:59.999'); 148 while (true) { 149 // break 150 const records = (response.data.records as unknown as ListRecord<PlayValue>[]) ?? []; 151 totalCount += records.length; 152 153 for (const play of records) { 154 // spot-check if play is valid 155 if (!play.value || !play.value.trackName) { 156 continue; 157 } 158 159 if(endDateTime <= new Date(play.value.playedTime) ) { 160 console.log("End of the line"); 161 shouldStop = true; 162 break; 163 } 164 165 166 // Aggregate by artist(s) 167 if (play.value?.artists) { 168 for (const artist of play.value?.artists) { 169 let alreadyPlayed = inner_artists.find( 170 (a) => a.name === artist.artistName, 171 ); 172 if (!alreadyPlayed) { 173 inner_artists.push({ 174 name: artist.artistName, 175 plays: 1, 176 }); 177 } else { 178 alreadyPlayed.plays++; 179 } 180 } 181 } else if (play.value?.artistNames) { 182 // old version of lexicon 183 for (const arist of play.value?.artistNames) { 184 let alreadyPlayed = inner_artists.find( 185 (a) => a.name === arist, 186 ); 187 if (!alreadyPlayed) { 188 inner_artists.push({name: arist, plays: 1}); 189 } else { 190 alreadyPlayed.plays++; 191 } 192 } 193 } 194 195 // Aggregate by track 196 let alreadyPlayed = inner_tracks.find( 197 (a) => a.name === play.value.trackName, 198 ); 199 if (!alreadyPlayed && play?.value) { 200 const primaryArtist = play.value.artists?.[0]?.artistName ?? play.value.artistNames?.[0] ?? "Unknown Artist"; 201 inner_tracks.push({ 202 name: play.value.trackName, 203 artist: primaryArtist, 204 plays: 1, 205 }); 206 } else if (alreadyPlayed) { 207 alreadyPlayed.plays++; 208 } 209 210 // Aggregate by local day using playedTime 211 const key = localDayKey(play.value?.playedTime); 212 if (key) { 213 dayCountMap.set(key, (dayCountMap.get(key) ?? 0) + 1); 214 } 215 } 216 217 // update reactive values incrementally (top N) 218 artists.value = inner_artists 219 .sort((a, b) => b.plays - a.plays) 220 .slice(0, topLimit.value); 221 tracks.value = inner_tracks 222 .sort((a, b) => b.plays - a.plays) 223 .slice(0, topLimit.value); 224 totalSongs.value = totalCount; 225 226 // compute top N days 227 topDays.value = Array.from(dayCountMap.entries()) 228 .map(([date, plays]) => ({date, plays})) 229 .sort((a, b) => b.plays - a.plays) 230 .slice(0, topLimit.value); 231 232 // update cursor and continue unless we've passed the start boundary 233 cursor = response.data.cursor ?? null; 234 if(shouldStop || cursor === null) { 235 break; 236 } 237 238 response = await agent.com.atproto.repo.listRecords({ 239 repo: did, 240 collection: "fm.teal.alpha.feed.play", 241 limit: 100, 242 reverse: reverse, 243 ...(cursor ? { cursor } : {}), 244 }); 245 } 246 } catch (error: unknown) { 247 errorMessage.value = error instanceof Error ? error.message : String(error); 248 throw error; 249 } finally { 250 loading.value = false; 251 } 252}; 253</script> 254 255<template> 256 <div class="container mx-auto p-4 text-center"> 257 <h1 258 class="text-5xl font-bold mb-2 bg-gradient-to-r from-teal-400 to-teal-600 text-transparent bg-clip-text" 259 > 260 teal.fm wrapped 261 </h1> 262 <p class="text-sm text-gray-500 mb-8"> 263 Mostly not affiliated with teal.fm 264 </p> 265 <form @submit.prevent="lookup"> 266 267 <div class="join w-full justify-center"> 268 <input 269 v-model="userHandle" 270 type="text" 271 placeholder="alice.bsky.social" 272 class="input input-bordered join-item w-1/2 max-w-xs" 273 /> 274 <button 275 type="submit" 276 class="btn join-item bg-teal-500 hover:bg-teal-600 text-white" 277 > 278 That's a wrap 279 </button> 280 </div> 281 282 <!-- Advanced menu --> 283 <div class="mt-4"> 284 <div class="collapse bg-base-200"> 285 <input type="checkbox" /> 286 <div class="collapse-title text-md font-medium"> 287 Advanced options 288 </div> 289 <div class="collapse-content"> 290 <div class="flex flex-col md:flex-row gap-4 items-center justify-center"> 291 <label class="form-control w-full max-w-xs"> 292 <div class="label"> 293 <span class="label-text">Top records to show</span> 294 </div> 295 <input type="number" min="1" max="100" v-model.number="topLimit" class="input input-bordered w-full max-w-xs" /> 296 </label> 297 <label class="form-control w-full max-w-xs"> 298 <div class="label"> 299 <span class="label-text">Start date</span> 300 </div> 301 <input type="date" v-model="startDate" class="input input-bordered w-full max-w-xs" /> 302 </label> 303 <label class="form-control w-full max-w-xs"> 304 <div class="label"> 305 <span class="label-text">End date</span> 306 </div> 307 <input type="date" v-model="endDate" class="input input-bordered w-full max-w-xs" /> 308 </label> 309 </div> 310 <p class="text-xs text-gray-500 mt-2">Date range start is start of day, end is end of day</p> 311 </div> 312 </div> 313 </div> 314 315 <span class="text-red-500" v-if="errorMessage">{{errorMessage}}</span> 316 </form> 317 <div class="w-full justify-center"> 318 <span 319 v-if="loading" 320 class="loading loading-dots loading-lg mt-8" 321 ></span> 322 <div v-if="tracks.length > 0" class="mt-8"> 323 <h2 class="text-2xl font-bold mb-4"> 324 Top Songs out of {{ formatNumber(totalSongs) }} 325 </h2> 326 <div class="overflow-x-auto"> 327 <table class="table w-full"> 328 <thead> 329 <tr> 330 <th>Rank</th> 331 <th>Plays</th> 332 <th>Song</th> 333 </tr> 334 </thead> 335 <tbody> 336 <tr v-for="(track, idx) in tracks" :key="track.name"> 337 <td>{{ idx + 1 }}.</td> 338 <td>{{ formatNumber(track.plays) }}</td> 339 <td>{{ track.name }} by {{ track.artist }}</td> 340 </tr> 341 </tbody> 342 </table> 343 </div> 344 </div> 345 <div v-if="artists.length > 0" class="mt-8"> 346 <h2 class="text-2xl font-bold mb-4">Top Artists</h2> 347 <div class="overflow-x-auto"> 348 <table class="table w-full"> 349 <thead> 350 <tr> 351 <th>Rank</th> 352 <th>Plays</th> 353 <th>Artist</th> 354 </tr> 355 </thead> 356 <tbody> 357 <tr v-for="(artist, idx) in artists" :key="artist.name"> 358 <td>{{ idx + 1 }}.</td> 359 <td>{{ formatNumber(artist.plays) }}</td> 360 <td>{{ artist.name }}</td> 361 </tr> 362 </tbody> 363 </table> 364 </div> 365 </div> 366 <div v-if="topDays.length > 0" class="mt-8"> 367 <h2 class="text-2xl font-bold mb-4">Top Days (Most Songs Played)</h2> 368 <div class="overflow-x-auto"> 369 <table class="table w-full"> 370 <thead> 371 <tr> 372 <th>Rank</th> 373 <th>Plays</th> 374 <th>Date (yyyy-mm-dd)</th> 375 </tr> 376 </thead> 377 <tbody> 378 <tr v-for="(day, idx) in topDays" :key="day.date"> 379 <td>{{ idx + 1 }}.</td> 380 <td>{{ formatNumber(day.plays) }}</td> 381 <td>{{ day.date }}</td> 382 </tr> 383 </tbody> 384 </table> 385 </div> 386 </div> 387 </div> 388 </div> 389</template> 390 391<style scoped> 392.container { 393 min-height: 50vh; 394 display: flex; 395 flex-direction: column; 396 justify-content: center; 397} 398</style>