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

Merge pull request #1 from espeon/main

update lexicons + checking and validation

authored by baileytownsend.dev and committed by

GitHub c18302ed 59994ebf

+230 -155
+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",
+197 -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}`) 38 + loading.value = true; 39 + try { 40 + const did = await handleResolver.resolve( 41 + userHandle.value as `${string}.${string}`, 42 + ); 37 43 38 - if (did == undefined) { 39 - throw new Error('expected handle to resolve') 40 - } 41 - console.log(did) // did:plc:ewvi7nxzyoun6zhxrhs64oiz 44 + if (did == undefined) { 45 + throw new Error("expected handle to resolve"); 46 + } 47 + console.log(did); // did:plc:ewvi7nxzyoun6zhxrhs64oiz 42 48 43 - const doc = await docResolver.resolve(did); 44 - console.log(doc) 49 + const doc = await docResolver.resolve(did); 50 + console.log(doc); 45 51 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 - }) 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 + }); 56 64 65 + do { 66 + plays.push(...response.data.records); 67 + cursor = response.data.cursor; 57 68 58 - do { 59 - plays.push(...response.data.records) 60 - cursor = response.data.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); 61 78 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) 79 + let inner_tracks = []; 80 + let inner_artists = []; 81 + for (const play of plays) { 82 + // spot-check if play is valid 83 + if ( 84 + play.success == false || 85 + play.value == undefined || 86 + play.value.artistName || 87 + play.value.trackName == undefined 88 + ) { 89 + continue; 90 + } 91 + // new version 92 + console.log(play); 93 + if (play.value?.artists) { 94 + for (const artist of play.value?.artists) { 95 + let alreadyPlayed = inner_artists.find( 96 + (a) => a.name === artist, 97 + ); 98 + if (!alreadyPlayed) { 99 + inner_artists.push({ 100 + name: artist.artistName, 101 + plays: 1, 102 + }); 103 + } else { 104 + alreadyPlayed.plays++; 105 + } 106 + } 107 + } else if (play.value?.artistNames) { 108 + // old version 109 + for (const arist of play.value?.artistNames) { 110 + let alreadyPlayed = inner_artists.find( 111 + (a) => a.name === arist, 112 + ); 113 + if (!alreadyPlayed) { 114 + inner_artists.push({ name: arist, plays: 1 }); 115 + } else { 116 + alreadyPlayed.plays++; 117 + } 118 + } 119 + } 71 120 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 - } 121 + let alreadyPlayed = inner_tracks.find( 122 + (a) => a.name === play.value.trackName, 123 + ); 124 + if (!alreadyPlayed && play?.value) { 125 + inner_tracks.push({ 126 + name: play.value.trackName, 127 + artist: play.value?.artists 128 + ? play.value.artists[0].artistName 129 + : play.value.artistNames[0], 130 + plays: 1, 131 + }); 132 + } else { 133 + alreadyPlayed.plays++; 134 + } 135 + } 83 136 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 - } 90 - 137 + artists.value = inner_artists 138 + .sort((a, b) => b.plays - a.plays) 139 + .slice(0, 10); 140 + tracks.value = inner_tracks 141 + .sort((a, b) => b.plays - a.plays) 142 + .slice(0, 10); 143 + totalSongs.value = plays.length; 144 + } finally { 145 + loading.value = false; 91 146 } 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 - 147 + }; 102 148 </script> 103 149 104 150 <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> 151 + <div class="container mx-auto p-4 text-center"> 152 + <h1 153 + class="text-5xl font-bold mb-2 bg-gradient-to-r from-teal-400 to-teal-600 text-transparent bg-clip-text" 154 + > 155 + Teal Wrapped 156 + </h1> 157 + <p class="text-sm text-gray-500 mb-8"> 158 + Mostly not affiliated with teal.fm™ 159 + </p> 160 + <div class="join w-full justify-center"> 161 + <input 162 + v-model="userHandle" 163 + type="text" 164 + placeholder="alice.bsky.social" 165 + class="input input-bordered join-item w-1/2 max-w-xs" 166 + /> 167 + <button 168 + @click="lookup" 169 + class="btn join-item bg-teal-500 hover:bg-teal-600 text-white" 170 + > 171 + That's a wrap 172 + </button> 139 173 </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> 174 + <div class="w-full justify-center"> 175 + <span 176 + v-if="loading" 177 + class="loading loading-dots loading-lg mt-8" 178 + ></span> 179 + <div v-if="tracks.length > 0" class="mt-8"> 180 + <h2 class="text-2xl font-bold mb-4"> 181 + Top Songs out of {{ totalSongs }} 182 + </h2> 183 + <div class="overflow-x-auto"> 184 + <table class="table w-full"> 185 + <thead> 186 + <tr> 187 + <th>Plays</th> 188 + <th>Song</th> 189 + </tr> 190 + </thead> 191 + <tbody> 192 + <tr v-for="track in tracks" :key="track.name"> 193 + <td>{{ track.plays }}</td> 194 + <td>{{ track.name }} by {{ track.artist }}</td> 195 + </tr> 196 + </tbody> 197 + </table> 198 + </div> 199 + </div> 200 + <div v-if="artists.length > 0" class="mt-8"> 201 + <h2 class="text-2xl font-bold mb-4">Top Artists</h2> 202 + <div class="overflow-x-auto"> 203 + <table class="table w-full"> 204 + <thead> 205 + <tr> 206 + <th>Plays</th> 207 + <th>Artist</th> 208 + </tr> 209 + </thead> 210 + <tbody> 211 + <tr v-for="artist in artists" :key="artist.name"> 212 + <td>{{ artist.plays }}</td> 213 + <td>{{ artist.name }}</td> 214 + </tr> 215 + </tbody> 216 + </table> 217 + </div> 218 + </div> 158 219 </div> 159 - </div> 160 220 </div> 161 - </div> 162 221 </template> 163 222 164 223 <style scoped> 165 224 .container { 166 - min-height: 50vh; 167 - display: flex; 168 - flex-direction: column; 169 - justify-content: center; 225 + min-height: 50vh; 226 + display: flex; 227 + flex-direction: column; 228 + justify-content: center; 170 229 } 171 - </style> 230 + </style>