Shows some quick stats about your teal.fm records. Kind of like Spotify Wrapped

update lexicons to support both old and new artist reps

Natalie B 19a77a49 59994ebf

Changed files
+215 -155
lexicons
lexicons
teal
src
components
+22 -14
lexicons/lexicons/teal/feed/defs.json
··· 5 5 "defs": { 6 6 "playView": { 7 7 "type": "object", 8 - "required": ["trackName", "artistNames"], 8 + "required": ["trackName", "artists"], 9 9 "properties": { 10 10 "trackName": { 11 11 "type": "string", ··· 26 26 "type": "integer", 27 27 "description": "The length of the track in seconds" 28 28 }, 29 - "artistNames": { 30 - "type": "array", 31 - "items": { 32 - "type": "string", 33 - "minLength": 1, 34 - "maxLength": 256, 35 - "maxGraphemes": 2560 36 - }, 37 - "description": "Array of artist names in order of original appearance." 38 - }, 39 - "artistMbIds": { 29 + "artists": { 40 30 "type": "array", 41 31 "items": { 42 - "type": "string" 32 + "type": "ref", 33 + "ref": "#artist" 43 34 }, 44 - "description": "Array of Musicbrainz artist IDs" 35 + "description": "Array of artists in order of original appearance." 45 36 }, 46 37 "releaseName": { 47 38 "type": "string", ··· 75 66 "type": "string", 76 67 "format": "datetime", 77 68 "description": "The unix timestamp of when the track was played" 69 + } 70 + } 71 + }, 72 + "artist": { 73 + "type": "object", 74 + "required": ["artistName"], 75 + "properties": { 76 + "artistName": { 77 + "type": "string", 78 + "minLength": 1, 79 + "maxLength": 256, 80 + "maxGraphemes": 2560, 81 + "description": "The name of the artist" 82 + }, 83 + "artistMbId": { 84 + "type": "string", 85 + "description": "The Musicbrainz ID of the artist" 78 86 } 79 87 } 80 88 }
+11 -3
lexicons/lexicons/teal/feed/play.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["trackName", "artistNames"], 11 + "required": ["trackName"], 12 12 "properties": { 13 13 "trackName": { 14 14 "type": "string", ··· 38 38 "maxLength": 256, 39 39 "maxGraphemes": 2560 40 40 }, 41 - "description": "Array of artist names in order of original appearance." 41 + "description": "Array of artist names in order of original appearance. Prefer using 'artists'." 42 42 }, 43 43 "artistMbIds": { 44 44 "type": "array", 45 45 "items": { 46 46 "type": "string" 47 47 }, 48 - "description": "Array of Musicbrainz artist IDs" 48 + "description": "Array of Musicbrainz artist IDs. Prefer using 'artists'." 49 + }, 50 + "artists": { 51 + "type": "array", 52 + "items": { 53 + "type": "ref", 54 + "ref": "fm.teal.alpha.feed.defs#artist" 55 + }, 56 + "description": "Array of artists in order of original appearance." 49 57 }, 50 58 "releaseName": { 51 59 "type": "string",
+182 -138
src/components/LookUp.vue
··· 1 1 <script setup lang="ts"> 2 - import {ref} from 'vue' 2 + import { ref } from "vue"; 3 3 import { 4 - CompositeDidDocumentResolver, 5 - CompositeHandleResolver, 6 - DohJsonHandleResolver, PlcDidDocumentResolver, WebDidDocumentResolver, 7 - WellKnownHandleResolver 4 + CompositeDidDocumentResolver, 5 + CompositeHandleResolver, 6 + DohJsonHandleResolver, 7 + PlcDidDocumentResolver, 8 + WebDidDocumentResolver, 9 + WellKnownHandleResolver, 8 10 } from "@atcute/identity-resolver"; 9 - import {AtpAgent} from '@atproto/api' 11 + import { AtpAgent } from "@atproto/api"; 10 12 11 13 // handle resolution 12 14 const handleResolver = new CompositeHandleResolver({ 13 - strategy: 'race', 14 - methods: { 15 - dns: new DohJsonHandleResolver({dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query'}), 16 - http: new WellKnownHandleResolver(), 17 - }, 15 + strategy: "race", 16 + methods: { 17 + dns: new DohJsonHandleResolver({ 18 + dohUrl: "https://mozilla.cloudflare-dns.com/dns-query", 19 + }), 20 + http: new WellKnownHandleResolver(), 21 + }, 18 22 }); 19 23 20 24 const docResolver = new CompositeDidDocumentResolver({ 21 - methods: { 22 - plc: new PlcDidDocumentResolver(), 23 - web: new WebDidDocumentResolver(), 24 - }, 25 + methods: { 26 + plc: new PlcDidDocumentResolver(), 27 + web: new WebDidDocumentResolver(), 28 + }, 25 29 }); 26 30 27 - const userHandle = ref('') 28 - const loading = ref(false) 29 - const artists = ref<{ name: string, plays: number }[]>([]) 30 - const tracks = ref<{ name: string, plays: number }[]>([]) 31 - const totalSongs = ref(0) 31 + const userHandle = ref(""); 32 + const loading = ref(false); 33 + const artists = ref<{ name: string; plays: number }[]>([]); 34 + const tracks = ref<{ name: string; plays: number }[]>([]); 35 + const totalSongs = ref(0); 32 36 33 37 const lookup = async () => { 34 - loading.value = true 35 - try { 36 - const did = await handleResolver.resolve(userHandle.value as `${string}.${string}`) 37 - 38 - if (did == undefined) { 39 - throw new Error('expected handle to resolve') 40 - } 41 - console.log(did) // did:plc:ewvi7nxzyoun6zhxrhs64oiz 38 + loading.value = true; 39 + try { 40 + const did = await handleResolver.resolve( 41 + userHandle.value as `${string}.${string}`, 42 + ); 42 43 43 - const doc = await docResolver.resolve(did); 44 - console.log(doc) 44 + if (did == undefined) { 45 + throw new Error("expected handle to resolve"); 46 + } 47 + console.log(did); // did:plc:ewvi7nxzyoun6zhxrhs64oiz 45 48 46 - // const handler = simpleFetchHandler({ service: }); 47 - const agent = new AtpAgent({service: doc.service[0].serviceEndpoint as string}) 48 - let cursor = ''; 49 - let plays = []; 50 - let response = await agent.com.atproto.repo.listRecords({ 51 - repo: did, 52 - collection: 'fm.teal.alpha.feed.play', 53 - limit: 100, 54 - cursor: cursor 55 - }) 49 + const doc = await docResolver.resolve(did); 50 + console.log(doc); 56 51 52 + // const handler = simpleFetchHandler({ service: }); 53 + const agent = new AtpAgent({ 54 + service: doc.service[0].serviceEndpoint as string, 55 + }); 56 + let cursor = ""; 57 + let plays = []; 58 + let response = await agent.com.atproto.repo.listRecords({ 59 + repo: did, 60 + collection: "fm.teal.alpha.feed.play", 61 + limit: 100, 62 + cursor: cursor, 63 + }); 57 64 58 - do { 59 - plays.push(...response.data.records) 60 - cursor = response.data.cursor 65 + do { 66 + plays.push(...response.data.records); 67 + cursor = response.data.cursor; 61 68 62 - if (cursor) { 63 - response = await agent.com.atproto.repo.listRecords({ 64 - repo: did, 65 - collection: 'fm.teal.alpha.feed.play', 66 - limit: 100, 67 - cursor: cursor 68 - }) 69 - } 70 - } while (cursor) 69 + if (cursor) { 70 + response = await agent.com.atproto.repo.listRecords({ 71 + repo: did, 72 + collection: "fm.teal.alpha.feed.play", 73 + limit: 100, 74 + cursor: cursor, 75 + }); 76 + } 77 + } while (cursor); 71 78 72 - let inner_tracks = []; 73 - let inner_artists = []; 74 - for (const play of plays) { 75 - for (const arist of play.value.artistNames) { 76 - let alreadyPlayed = inner_artists.find(a => a.name === arist) 77 - if (!alreadyPlayed) { 78 - inner_artists.push({name: arist, plays: 1}) 79 - }else{ 80 - alreadyPlayed.plays++ 81 - } 82 - } 79 + let inner_tracks = []; 80 + let inner_artists = []; 81 + for (const play of plays) { 82 + // new version 83 + if (play.value?.artists) { 84 + for (const artist of play.value?.artists) { 85 + let alreadyPlayed = inner_artists.find( 86 + (a) => a.name === artist, 87 + ); 88 + if (!alreadyPlayed) { 89 + inner_artists.push({ name: artist, plays: 1 }); 90 + } else { 91 + alreadyPlayed.plays++; 92 + } 93 + } 94 + } else { 95 + // old version 96 + for (const arist of play.value?.artistNames) { 97 + let alreadyPlayed = inner_artists.find( 98 + (a) => a.name === arist, 99 + ); 100 + if (!alreadyPlayed) { 101 + inner_artists.push({ name: arist, plays: 1 }); 102 + } else { 103 + alreadyPlayed.plays++; 104 + } 105 + } 106 + } 83 107 84 - let alreadyPlayed = inner_tracks.find(a => a.name === play.value.trackName) 85 - if(!alreadyPlayed) { 86 - inner_tracks.push({name: play.value.trackName, artist: play.value.artistNames[0], plays: 1}) 87 - }else{ 88 - alreadyPlayed.plays++ 89 - } 108 + let alreadyPlayed = inner_tracks.find( 109 + (a) => a.name === play.value.trackName, 110 + ); 111 + if (!alreadyPlayed) { 112 + inner_tracks.push({ 113 + name: play.value.trackName, 114 + artist: play.value.artistNames[0], 115 + plays: 1, 116 + }); 117 + } else { 118 + alreadyPlayed.plays++; 119 + } 120 + } 90 121 122 + artists.value = inner_artists 123 + .sort((a, b) => b.plays - a.plays) 124 + .slice(0, 10); 125 + tracks.value = inner_tracks 126 + .sort((a, b) => b.plays - a.plays) 127 + .slice(0, 10); 128 + totalSongs.value = plays.length; 129 + } finally { 130 + loading.value = false; 91 131 } 92 - 93 - artists.value = inner_artists.sort((a, b) => b.plays - a.plays).slice(0, 10) 94 - tracks.value = inner_tracks.sort((a, b) => b.plays - a.plays).slice(0, 10) 95 - totalSongs.value = plays.length 96 - } finally { 97 - loading.value = false 98 - } 99 - } 100 - 101 - 132 + }; 102 133 </script> 103 134 104 135 <template> 105 - <div class="container mx-auto p-4 text-center"> 106 - <h1 class="text-5xl font-bold mb-2 bg-gradient-to-r from-teal-400 to-teal-600 text-transparent bg-clip-text"> 107 - Teal Wrapped 108 - </h1> 109 - <p class="text-sm text-gray-500 mb-8">Mostly not affiliated with teal.fm™</p> 110 - <div class="join w-full justify-center"> 111 - <input 112 - v-model="userHandle" 113 - type="text" 114 - placeholder="alice.bsky.social" 115 - class="input input-bordered join-item w-1/2 max-w-xs" 116 - /> 117 - <button @click="lookup" class="btn join-item bg-teal-500 hover:bg-teal-600 text-white">That's a wrap</button> 118 - 119 - </div> 120 - <div class="w-full justify-center"> 121 - <span v-if="loading" class="loading loading-dots loading-lg mt-8"></span> 122 - <div v-if="tracks.length > 0" class="mt-8"> 123 - <h2 class="text-2xl font-bold mb-4">Top Songs out of {{totalSongs}}</h2> 124 - <div class="overflow-x-auto"> 125 - <table class="table w-full"> 126 - <thead> 127 - <tr> 128 - <th>Plays</th> 129 - <th>Song</th> 130 - </tr> 131 - </thead> 132 - <tbody> 133 - <tr v-for="track in tracks" :key="track.name"> 134 - <td>{{ track.plays }}</td> 135 - <td>{{ track.name }} by {{track.artist}}</td> 136 - </tr> 137 - </tbody> 138 - </table> 136 + <div class="container mx-auto p-4 text-center"> 137 + <h1 138 + class="text-5xl font-bold mb-2 bg-gradient-to-r from-teal-400 to-teal-600 text-transparent bg-clip-text" 139 + > 140 + Teal Wrapped 141 + </h1> 142 + <p class="text-sm text-gray-500 mb-8"> 143 + Mostly not affiliated with teal.fm™ 144 + </p> 145 + <div class="join w-full justify-center"> 146 + <input 147 + v-model="userHandle" 148 + type="text" 149 + placeholder="alice.bsky.social" 150 + class="input input-bordered join-item w-1/2 max-w-xs" 151 + /> 152 + <button 153 + @click="lookup" 154 + class="btn join-item bg-teal-500 hover:bg-teal-600 text-white" 155 + > 156 + That's a wrap 157 + </button> 139 158 </div> 140 - </div> 141 - <div v-if="artists.length > 0" class="mt-8"> 142 - <h2 class="text-2xl font-bold mb-4">Top Artists</h2> 143 - <div class="overflow-x-auto"> 144 - <table class="table w-full"> 145 - <thead> 146 - <tr> 147 - <th>Plays</th> 148 - <th>Artist</th> 149 - </tr> 150 - </thead> 151 - <tbody> 152 - <tr v-for="artist in artists" :key="artist.name"> 153 - <td>{{ artist.plays }}</td> 154 - <td>{{ artist.name }}</td> 155 - </tr> 156 - </tbody> 157 - </table> 159 + <div class="w-full justify-center"> 160 + <span 161 + v-if="loading" 162 + class="loading loading-dots loading-lg mt-8" 163 + ></span> 164 + <div v-if="tracks.length > 0" class="mt-8"> 165 + <h2 class="text-2xl font-bold mb-4"> 166 + Top Songs out of {{ totalSongs }} 167 + </h2> 168 + <div class="overflow-x-auto"> 169 + <table class="table w-full"> 170 + <thead> 171 + <tr> 172 + <th>Plays</th> 173 + <th>Song</th> 174 + </tr> 175 + </thead> 176 + <tbody> 177 + <tr v-for="track in tracks" :key="track.name"> 178 + <td>{{ track.plays }}</td> 179 + <td>{{ track.name }} by {{ track.artist }}</td> 180 + </tr> 181 + </tbody> 182 + </table> 183 + </div> 184 + </div> 185 + <div v-if="artists.length > 0" class="mt-8"> 186 + <h2 class="text-2xl font-bold mb-4">Top Artists</h2> 187 + <div class="overflow-x-auto"> 188 + <table class="table w-full"> 189 + <thead> 190 + <tr> 191 + <th>Plays</th> 192 + <th>Artist</th> 193 + </tr> 194 + </thead> 195 + <tbody> 196 + <tr v-for="artist in artists" :key="artist.name"> 197 + <td>{{ artist.plays }}</td> 198 + <td>{{ artist.name }}</td> 199 + </tr> 200 + </tbody> 201 + </table> 202 + </div> 203 + </div> 158 204 </div> 159 - </div> 160 205 </div> 161 - </div> 162 206 </template> 163 207 164 208 <style scoped> 165 209 .container { 166 - min-height: 50vh; 167 - display: flex; 168 - flex-direction: column; 169 - justify-content: center; 210 + min-height: 50vh; 211 + display: flex; 212 + flex-direction: column; 213 + justify-content: center; 170 214 } 171 - </style> 215 + </style>