+3
-3
README.md
+3
-3
README.md
+1
-1
index.html
+1
-1
index.html
+93
-10
src/components/LookUp.vue
+93
-10
src/components/LookUp.vue
···
9
WellKnownHandleResolver,
10
} from "@atcute/identity-resolver";
11
import {AtpAgent} from "@atproto/api";
12
13
// Types for fm.teal.alpha.feed.play
14
interface PlayArtist {
15
artistMbId?: string;
···
21
// legacy support: earlier records may use artistNames
22
artistNames?: string[];
23
trackName: string;
24
-
playedTime?: string; // ISO string
25
releaseMbId?: string;
26
releaseName?: string;
27
recordingMbId?: string;
···
59
const totalSongs = ref(0);
60
const errorMessage = ref<string | null>(null);
61
62
const formatNumber = (n: number) => n.toLocaleString();
63
64
// Returns YYYY-MM-DD in the browser's local time zone
···
74
75
const lookup = async () => {
76
loading.value = true;
77
try {
78
const did = await handleResolver.resolve(
79
userHandle.value as `${string}.${string}`,
···
94
const agent = new AtpAgent({
95
service: endpoint,
96
});
97
-
let cursor: string | undefined = undefined;
98
let totalCount = 0;
99
let inner_tracks: { name: string; artist: string; plays: number }[] = [];
100
let inner_artists: { name: string; plays: number }[] = [];
101
const dayCountMap = new Map<string, number>();
102
103
let response = await agent.com.atproto.repo.listRecords({
104
repo: did,
105
collection: "fm.teal.alpha.feed.play",
106
limit: 100,
107
...(cursor ? { cursor } : {}),
108
});
109
110
// Process pages incrementally while fetching them
111
while (true) {
112
const records = (response.data.records as unknown as ListRecord<PlayValue>[]) ?? [];
113
totalCount += records.length;
114
···
117
if (!play.value || !play.value.trackName) {
118
continue;
119
}
120
// Aggregate by artist(s)
121
if (play.value?.artists) {
122
for (const artist of play.value?.artists) {
···
168
}
169
}
170
171
-
// update reactive values incrementally (top 25)
172
artists.value = inner_artists
173
.sort((a, b) => b.plays - a.plays)
174
-
.slice(0, 25);
175
tracks.value = inner_tracks
176
.sort((a, b) => b.plays - a.plays)
177
-
.slice(0, 25);
178
totalSongs.value = totalCount;
179
180
-
// compute top 25 days
181
topDays.value = Array.from(dayCountMap.entries())
182
.map(([date, plays]) => ({date, plays}))
183
.sort((a, b) => b.plays - a.plays)
184
-
.slice(0, 25);
185
186
-
cursor = response.data.cursor;
187
-
if (!cursor) break;
188
189
response = await agent.com.atproto.repo.listRecords({
190
repo: did,
191
collection: "fm.teal.alpha.feed.play",
192
limit: 100,
193
...(cursor ? { cursor } : {}),
194
});
195
}
···
207
<h1
208
class="text-5xl font-bold mb-2 bg-gradient-to-r from-teal-400 to-teal-600 text-transparent bg-clip-text"
209
>
210
-
Teal Wrapped
211
</h1>
212
<p class="text-sm text-gray-500 mb-8">
213
Mostly not affiliated with teal.fm™
···
227
>
228
That's a wrap
229
</button>
230
231
</div>
232
<span class="text-red-500" v-if="errorMessage">{{errorMessage}}</span>
233
</form>
234
<div class="w-full justify-center">
···
9
WellKnownHandleResolver,
10
} from "@atcute/identity-resolver";
11
import {AtpAgent} from "@atproto/api";
12
+
import * as TID from "@atcute/tid";
13
14
+
//Should be using the lexicons to generate types...
15
// Types for fm.teal.alpha.feed.play
16
interface PlayArtist {
17
artistMbId?: string;
···
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;
···
61
const totalSongs = ref(0);
62
const errorMessage = ref<string | null>(null);
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
+
69
const formatNumber = (n: number) => n.toLocaleString();
70
71
// Returns YYYY-MM-DD in the browser's local time zone
···
81
82
const 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}`,
···
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
···
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) {
···
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
}
···
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™
···
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">