+3
-3
README.md
+3
-3
README.md
···
1
-
# teal.fm wrapped
1
+
# Teal Wrapped
2
2
3
-
Quick hack would not even call this TypeScript. Just wanted to do a quick project with a UI to count songs and artists from my teal.fm records.
3
+
An unofficial teal.fm stats viewer. View some quick stats about your fm.teal.alpha.feed.play records.
4
4
5
-
Would not run or count on it. There will be errors
5
+
[wrapped.baileytownsend.dev](https://wrapped.baileytownsend.dev/)
6
6
7
7
`bun install`
8
8
`bun run dev`
+1
-1
index.html
+1
-1
index.html
+93
-10
src/components/LookUp.vue
+93
-10
src/components/LookUp.vue
···
9
9
WellKnownHandleResolver,
10
10
} from "@atcute/identity-resolver";
11
11
import {AtpAgent} from "@atproto/api";
12
+
import * as TID from "@atcute/tid";
12
13
14
+
//Should be using the lexicons to generate types...
13
15
// Types for fm.teal.alpha.feed.play
14
16
interface PlayArtist {
15
17
artistMbId?: string;
···
21
23
// legacy support: earlier records may use artistNames
22
24
artistNames?: string[];
23
25
trackName: string;
24
-
playedTime?: string; // ISO string
26
+
playedTime: string; // ISO string
25
27
releaseMbId?: string;
26
28
releaseName?: string;
27
29
recordingMbId?: string;
···
59
61
const totalSongs = ref(0);
60
62
const errorMessage = ref<string | null>(null);
61
63
64
+
// Advanced controls
65
+
const topLimit = ref<number>(25);
66
+
const startDate = ref<string | null>(null); // format: YYYY-MM-DD (local)
67
+
const endDate = ref<string | null>(null);
68
+
62
69
const formatNumber = (n: number) => n.toLocaleString();
63
70
64
71
// Returns YYYY-MM-DD in the browser's local time zone
···
74
81
75
82
const lookup = async () => {
76
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
+
77
91
try {
78
92
const did = await handleResolver.resolve(
79
93
userHandle.value as `${string}.${string}`,
···
94
108
const agent = new AtpAgent({
95
109
service: endpoint,
96
110
});
97
-
let cursor: string | undefined = undefined;
111
+
let cursor: string | null = null;
98
112
let totalCount = 0;
99
113
let inner_tracks: { name: string; artist: string; plays: number }[] = [];
100
114
let inner_artists: { name: string; plays: number }[] = [];
101
115
const dayCountMap = new Map<string, number>();
102
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
+
103
136
let response = await agent.com.atproto.repo.listRecords({
104
137
repo: did,
105
138
collection: "fm.teal.alpha.feed.play",
106
139
limit: 100,
140
+
reverse: reverse,
107
141
...(cursor ? { cursor } : {}),
108
142
});
109
143
144
+
110
145
// Process pages incrementally while fetching them
146
+
let shouldStop = false;
147
+
let endDateTime = new Date(endDate.value + 'T23:59:59.999');
111
148
while (true) {
149
+
// break
112
150
const records = (response.data.records as unknown as ListRecord<PlayValue>[]) ?? [];
113
151
totalCount += records.length;
114
152
···
117
155
if (!play.value || !play.value.trackName) {
118
156
continue;
119
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
+
120
166
// Aggregate by artist(s)
121
167
if (play.value?.artists) {
122
168
for (const artist of play.value?.artists) {
···
168
214
}
169
215
}
170
216
171
-
// update reactive values incrementally (top 25)
217
+
// update reactive values incrementally (top N)
172
218
artists.value = inner_artists
173
219
.sort((a, b) => b.plays - a.plays)
174
-
.slice(0, 25);
220
+
.slice(0, topLimit.value);
175
221
tracks.value = inner_tracks
176
222
.sort((a, b) => b.plays - a.plays)
177
-
.slice(0, 25);
223
+
.slice(0, topLimit.value);
178
224
totalSongs.value = totalCount;
179
225
180
-
// compute top 25 days
226
+
// compute top N days
181
227
topDays.value = Array.from(dayCountMap.entries())
182
228
.map(([date, plays]) => ({date, plays}))
183
229
.sort((a, b) => b.plays - a.plays)
184
-
.slice(0, 25);
230
+
.slice(0, topLimit.value);
185
231
186
-
cursor = response.data.cursor;
187
-
if (!cursor) break;
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
+
}
188
237
189
238
response = await agent.com.atproto.repo.listRecords({
190
239
repo: did,
191
240
collection: "fm.teal.alpha.feed.play",
192
241
limit: 100,
242
+
reverse: reverse,
193
243
...(cursor ? { cursor } : {}),
194
244
});
195
245
}
···
207
257
<h1
208
258
class="text-5xl font-bold mb-2 bg-gradient-to-r from-teal-400 to-teal-600 text-transparent bg-clip-text"
209
259
>
210
-
Teal Wrapped
260
+
teal.fm wrapped
211
261
</h1>
212
262
<p class="text-sm text-gray-500 mb-8">
213
263
Mostly not affiliated with teal.fm™
···
227
277
>
228
278
That's a wrap
229
279
</button>
280
+
</div>
230
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>
231
313
</div>
314
+
232
315
<span class="text-red-500" v-if="errorMessage">{{errorMessage}}</span>
233
316
</form>
234
317
<div class="w-full justify-center">