@rocksky/cli 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -6
- package/dist/drizzle/0001_awesome_gabe_jones.sql +1 -0
- package/dist/drizzle/meta/0001_snapshot.json +1569 -0
- package/dist/drizzle/meta/_journal.json +7 -0
- package/dist/index.js +313 -84
- package/drizzle/0001_awesome_gabe_jones.sql +1 -0
- package/drizzle/meta/0001_snapshot.json +1569 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -1
- package/src/cmd/scrobble-api.ts +8 -0
- package/src/cmd/sync.ts +345 -72
- package/src/schema/tracks.ts +40 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rocksky/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Command-line interface for Rocksky – scrobble tracks, view stats, and manage your listening history",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -32,8 +32,11 @@
|
|
|
32
32
|
"@atproto/jwk-jose": "0.1.5",
|
|
33
33
|
"@atproto/lex-cli": "^0.5.6",
|
|
34
34
|
"@atproto/lexicon": "^0.4.5",
|
|
35
|
+
"@atproto/repo": "^0.6.2",
|
|
35
36
|
"@atproto/sync": "^0.1.11",
|
|
36
37
|
"@atproto/syntax": "^0.3.1",
|
|
38
|
+
"@ipld/car": "^3.2.4",
|
|
39
|
+
"@ipld/dag-cbor": "^9.2.2",
|
|
37
40
|
"@hono/node-server": "^1.13.8",
|
|
38
41
|
"@logtape/logtape": "^1.3.6",
|
|
39
42
|
"@modelcontextprotocol/sdk": "^1.10.2",
|
|
@@ -56,6 +59,7 @@
|
|
|
56
59
|
"kysely": "^0.27.5",
|
|
57
60
|
"lodash": "^4.17.21",
|
|
58
61
|
"md5": "^2.3.0",
|
|
62
|
+
"multiformats": "^9.9.0",
|
|
59
63
|
"open": "^10.1.0",
|
|
60
64
|
"table": "^6.9.0",
|
|
61
65
|
"unstorage": "^1.14.4",
|
package/src/cmd/scrobble-api.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { matchTrack } from "lib/matchTrack";
|
|
|
11
11
|
import _ from "lodash";
|
|
12
12
|
import { publishScrobble } from "scrobble";
|
|
13
13
|
import { validateLastfmSignature } from "lib/lastfm";
|
|
14
|
+
import { sync } from "./sync";
|
|
14
15
|
|
|
15
16
|
export async function scrobbleApi({ port }) {
|
|
16
17
|
const [, handle] = await getDidAndHandle();
|
|
@@ -453,5 +454,12 @@ export async function scrobbleApi({ port }) {
|
|
|
453
454
|
|
|
454
455
|
log.info`lastfm/listenbrainz/webscrobbler scrobble API listening on ${"http://localhost:" + port}`;
|
|
455
456
|
|
|
457
|
+
new Promise(async () => {
|
|
458
|
+
try {
|
|
459
|
+
await sync();
|
|
460
|
+
} catch (err) {
|
|
461
|
+
log.warn`Error during initial sync: ${err}`;
|
|
462
|
+
}
|
|
463
|
+
});
|
|
456
464
|
serve({ fetch: app.fetch, port });
|
|
457
465
|
}
|
package/src/cmd/sync.ts
CHANGED
|
@@ -21,8 +21,8 @@ import path from "node:path";
|
|
|
21
21
|
import { getDidAndHandle } from "lib/getDidAndHandle";
|
|
22
22
|
import { cleanUpJetstreamLockOnExit } from "lib/cleanUpJetstreamLock";
|
|
23
23
|
import { cleanUpSyncLockOnExit } from "lib/cleanUpSyncLock";
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
import { CarReader } from "@ipld/car";
|
|
25
|
+
import * as cbor from "@ipld/dag-cbor";
|
|
26
26
|
|
|
27
27
|
type Artists = { value: Artist.Record; uri: string; cid: string }[];
|
|
28
28
|
type Albums = { value: Album.Record; uri: string; cid: string }[];
|
|
@@ -133,7 +133,7 @@ const createArtists = async (artists: Artists, user: SelectUser) => {
|
|
|
133
133
|
name: tag,
|
|
134
134
|
}));
|
|
135
135
|
|
|
136
|
-
const BATCH_SIZE =
|
|
136
|
+
const BATCH_SIZE = 1000;
|
|
137
137
|
for (let i = 0; i < uniqueTags.length; i += BATCH_SIZE) {
|
|
138
138
|
const batch = uniqueTags.slice(i, i + BATCH_SIZE);
|
|
139
139
|
await ctx.db
|
|
@@ -241,7 +241,7 @@ const createAlbums = async (albums: Albums, user: SelectUser) => {
|
|
|
241
241
|
.filter(({ artist }) => artist);
|
|
242
242
|
|
|
243
243
|
// Process albums in batches
|
|
244
|
-
const BATCH_SIZE =
|
|
244
|
+
const BATCH_SIZE = 1000;
|
|
245
245
|
let totalAlbumsImported = 0;
|
|
246
246
|
|
|
247
247
|
for (let i = 0; i < validAlbumData.length; i += BATCH_SIZE) {
|
|
@@ -332,7 +332,7 @@ const createSongs = async (songs: Songs, user: SelectUser) => {
|
|
|
332
332
|
.filter(({ artist, album }) => artist && album);
|
|
333
333
|
|
|
334
334
|
// Process in batches to avoid stack overflow with large datasets
|
|
335
|
-
const BATCH_SIZE =
|
|
335
|
+
const BATCH_SIZE = 1000;
|
|
336
336
|
let totalTracksImported = 0;
|
|
337
337
|
|
|
338
338
|
for (let i = 0; i < validSongData.length; i += BATCH_SIZE) {
|
|
@@ -481,7 +481,7 @@ const createScrobbles = async (scrobbles: Scrobbles, user: SelectUser) => {
|
|
|
481
481
|
.filter(({ track, album, artist }) => track && album && artist);
|
|
482
482
|
|
|
483
483
|
// Process in batches to avoid stack overflow with large datasets
|
|
484
|
-
const BATCH_SIZE =
|
|
484
|
+
const BATCH_SIZE = 1000;
|
|
485
485
|
let totalScrobblesImported = 0;
|
|
486
486
|
|
|
487
487
|
for (let i = 0; i < validScrobbleData.length; i += BATCH_SIZE) {
|
|
@@ -624,6 +624,16 @@ const onNewSong = async (
|
|
|
624
624
|
) => {
|
|
625
625
|
const { title, artist, album } = record;
|
|
626
626
|
logger.info` New song: ${title} by ${artist} from ${album}`;
|
|
627
|
+
await createSongs(
|
|
628
|
+
[
|
|
629
|
+
{
|
|
630
|
+
cid,
|
|
631
|
+
uri,
|
|
632
|
+
value: record,
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
user,
|
|
636
|
+
);
|
|
627
637
|
};
|
|
628
638
|
|
|
629
639
|
const onNewAlbum = async (
|
|
@@ -672,8 +682,192 @@ const onNewScrobble = async (
|
|
|
672
682
|
uri: string,
|
|
673
683
|
user: SelectUser,
|
|
674
684
|
) => {
|
|
675
|
-
const { title, createdAt } = record;
|
|
685
|
+
const { title, createdAt, artist, album, albumArtist } = record;
|
|
676
686
|
logger.info` New scrobble: ${title} at ${createdAt}`;
|
|
687
|
+
|
|
688
|
+
// Check if the artist exists, create if not
|
|
689
|
+
let [artistRecord] = await ctx.db
|
|
690
|
+
.select()
|
|
691
|
+
.from(schema.artists)
|
|
692
|
+
.where(eq(schema.artists.name, record.albumArtist))
|
|
693
|
+
.execute();
|
|
694
|
+
|
|
695
|
+
if (!artistRecord) {
|
|
696
|
+
logger.info` ⚙️ Artist not found, creating: "${albumArtist}"`;
|
|
697
|
+
|
|
698
|
+
// Create a synthetic artist record from scrobble data
|
|
699
|
+
const artistUri = `at://${user.did}/app.rocksky.artist/${createId()}`;
|
|
700
|
+
const artistCid = createId();
|
|
701
|
+
|
|
702
|
+
await createArtists(
|
|
703
|
+
[
|
|
704
|
+
{
|
|
705
|
+
cid: artistCid,
|
|
706
|
+
uri: artistUri,
|
|
707
|
+
value: {
|
|
708
|
+
$type: "app.rocksky.artist",
|
|
709
|
+
name: record.albumArtist,
|
|
710
|
+
createdAt: new Date().toISOString(),
|
|
711
|
+
tags: record.tags || [],
|
|
712
|
+
} as Artist.Record,
|
|
713
|
+
},
|
|
714
|
+
],
|
|
715
|
+
user,
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
[artistRecord] = await ctx.db
|
|
719
|
+
.select()
|
|
720
|
+
.from(schema.artists)
|
|
721
|
+
.where(eq(schema.artists.name, record.albumArtist))
|
|
722
|
+
.execute();
|
|
723
|
+
|
|
724
|
+
if (!artistRecord) {
|
|
725
|
+
logger.error` ❌ Failed to create artist. Skipping scrobble.`;
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Check if the album exists, create if not
|
|
731
|
+
let [albumRecord] = await ctx.db
|
|
732
|
+
.select()
|
|
733
|
+
.from(schema.albums)
|
|
734
|
+
.where(
|
|
735
|
+
and(
|
|
736
|
+
eq(schema.albums.title, record.album),
|
|
737
|
+
eq(schema.albums.artist, record.albumArtist),
|
|
738
|
+
),
|
|
739
|
+
)
|
|
740
|
+
.execute();
|
|
741
|
+
|
|
742
|
+
if (!albumRecord) {
|
|
743
|
+
logger.info` ⚙️ Album not found, creating: "${album}" by ${albumArtist}`;
|
|
744
|
+
|
|
745
|
+
// Create a synthetic album record from scrobble data
|
|
746
|
+
const albumUri = `at://${user.did}/app.rocksky.album/${createId()}`;
|
|
747
|
+
const albumCid = createId();
|
|
748
|
+
|
|
749
|
+
await createAlbums(
|
|
750
|
+
[
|
|
751
|
+
{
|
|
752
|
+
cid: albumCid,
|
|
753
|
+
uri: albumUri,
|
|
754
|
+
value: {
|
|
755
|
+
$type: "app.rocksky.album",
|
|
756
|
+
title: record.album,
|
|
757
|
+
artist: record.albumArtist,
|
|
758
|
+
createdAt: new Date().toISOString(),
|
|
759
|
+
releaseDate: record.releaseDate,
|
|
760
|
+
year: record.year,
|
|
761
|
+
albumArt: record.albumArt,
|
|
762
|
+
artistUri: artistRecord.uri,
|
|
763
|
+
spotifyLink: record.spotifyLink,
|
|
764
|
+
appleMusicLink: record.appleMusicLink,
|
|
765
|
+
tidalLink: record.tidalLink,
|
|
766
|
+
youtubeLink: record.youtubeLink,
|
|
767
|
+
} as Album.Record,
|
|
768
|
+
},
|
|
769
|
+
],
|
|
770
|
+
user,
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
// Fetch the newly created album
|
|
774
|
+
[albumRecord] = await ctx.db
|
|
775
|
+
.select()
|
|
776
|
+
.from(schema.albums)
|
|
777
|
+
.where(
|
|
778
|
+
and(
|
|
779
|
+
eq(schema.albums.title, record.album),
|
|
780
|
+
eq(schema.albums.artist, record.albumArtist),
|
|
781
|
+
),
|
|
782
|
+
)
|
|
783
|
+
.execute();
|
|
784
|
+
|
|
785
|
+
if (!albumRecord) {
|
|
786
|
+
logger.error` ❌ Failed to create album. Skipping scrobble.`;
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Check if the track exists, create if not
|
|
792
|
+
let [track] = await ctx.db
|
|
793
|
+
.select()
|
|
794
|
+
.from(schema.tracks)
|
|
795
|
+
.where(
|
|
796
|
+
and(
|
|
797
|
+
eq(schema.tracks.title, record.title),
|
|
798
|
+
eq(schema.tracks.artist, record.artist),
|
|
799
|
+
eq(schema.tracks.album, record.album),
|
|
800
|
+
eq(schema.tracks.albumArtist, record.albumArtist),
|
|
801
|
+
),
|
|
802
|
+
)
|
|
803
|
+
.execute();
|
|
804
|
+
|
|
805
|
+
if (!track) {
|
|
806
|
+
logger.info` ⚙️ Track not found, creating: "${title}" by ${artist} from ${album}`;
|
|
807
|
+
|
|
808
|
+
// Create a synthetic track record from scrobble data
|
|
809
|
+
const trackUri = `at://${user.did}/app.rocksky.song/${createId()}`;
|
|
810
|
+
const trackCid = createId();
|
|
811
|
+
|
|
812
|
+
await createSongs(
|
|
813
|
+
[
|
|
814
|
+
{
|
|
815
|
+
cid: trackCid,
|
|
816
|
+
uri: trackUri,
|
|
817
|
+
value: {
|
|
818
|
+
$type: "app.rocksky.song",
|
|
819
|
+
title: record.title,
|
|
820
|
+
artist: record.artist,
|
|
821
|
+
albumArtist: record.albumArtist,
|
|
822
|
+
album: record.album,
|
|
823
|
+
duration: record.duration,
|
|
824
|
+
trackNumber: record.trackNumber,
|
|
825
|
+
discNumber: record.discNumber,
|
|
826
|
+
releaseDate: record.releaseDate,
|
|
827
|
+
year: record.year,
|
|
828
|
+
genre: record.genre,
|
|
829
|
+
tags: record.tags,
|
|
830
|
+
composer: record.composer,
|
|
831
|
+
lyrics: record.lyrics,
|
|
832
|
+
copyrightMessage: record.copyrightMessage,
|
|
833
|
+
albumArt: record.albumArt,
|
|
834
|
+
youtubeLink: record.youtubeLink,
|
|
835
|
+
spotifyLink: record.spotifyLink,
|
|
836
|
+
tidalLink: record.tidalLink,
|
|
837
|
+
appleMusicLink: record.appleMusicLink,
|
|
838
|
+
createdAt: new Date().toISOString(),
|
|
839
|
+
mbId: record.mbid,
|
|
840
|
+
label: record.label,
|
|
841
|
+
albumUri: albumRecord.uri,
|
|
842
|
+
artistUri: artistRecord.uri,
|
|
843
|
+
} as Song.Record,
|
|
844
|
+
},
|
|
845
|
+
],
|
|
846
|
+
user,
|
|
847
|
+
);
|
|
848
|
+
|
|
849
|
+
// Fetch the newly created track
|
|
850
|
+
[track] = await ctx.db
|
|
851
|
+
.select()
|
|
852
|
+
.from(schema.tracks)
|
|
853
|
+
.where(
|
|
854
|
+
and(
|
|
855
|
+
eq(schema.tracks.title, record.title),
|
|
856
|
+
eq(schema.tracks.artist, record.artist),
|
|
857
|
+
eq(schema.tracks.album, record.album),
|
|
858
|
+
eq(schema.tracks.albumArtist, record.albumArtist),
|
|
859
|
+
),
|
|
860
|
+
)
|
|
861
|
+
.execute();
|
|
862
|
+
|
|
863
|
+
if (!track) {
|
|
864
|
+
logger.error` ❌ Failed to create track. Skipping scrobble.`;
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
logger.info` ✓ All required entities ready. Creating scrobble...`;
|
|
870
|
+
|
|
677
871
|
await createScrobbles(
|
|
678
872
|
[
|
|
679
873
|
{
|
|
@@ -687,126 +881,205 @@ const onNewScrobble = async (
|
|
|
687
881
|
};
|
|
688
882
|
|
|
689
883
|
const getRockskyUserSongs = async (agent: Agent): Promise<Songs> => {
|
|
690
|
-
|
|
884
|
+
const results: {
|
|
691
885
|
value: Song.Record;
|
|
692
886
|
uri: string;
|
|
693
887
|
cid: string;
|
|
694
888
|
}[] = [];
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
cursor,
|
|
889
|
+
|
|
890
|
+
try {
|
|
891
|
+
logger.info(`Fetching repository CAR file for songs...`);
|
|
892
|
+
|
|
893
|
+
const repoRes = await agent.com.atproto.sync.getRepo({
|
|
894
|
+
did: agent.assertDid,
|
|
702
895
|
});
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
|
|
896
|
+
|
|
897
|
+
const carReader = await CarReader.fromBytes(new Uint8Array(repoRes.data));
|
|
898
|
+
const collection = "app.rocksky.song";
|
|
899
|
+
|
|
900
|
+
for await (const { cid, bytes } of carReader.blocks()) {
|
|
901
|
+
try {
|
|
902
|
+
const decoded = cbor.decode(bytes);
|
|
903
|
+
|
|
904
|
+
// Check if this is a record with $type matching our collection
|
|
905
|
+
if (decoded && typeof decoded === "object" && "$type" in decoded) {
|
|
906
|
+
if (decoded.$type === collection) {
|
|
907
|
+
const value = decoded as unknown as Song.Record;
|
|
908
|
+
// Extract rkey from uri if present in the block, otherwise use cid
|
|
909
|
+
const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
|
|
910
|
+
|
|
911
|
+
results.push({
|
|
912
|
+
value,
|
|
913
|
+
uri,
|
|
914
|
+
cid: cid.toString(),
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
} catch (e) {
|
|
919
|
+
logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
710
924
|
logger.info(
|
|
711
925
|
`${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} songs`,
|
|
712
926
|
);
|
|
713
|
-
}
|
|
927
|
+
} catch (error) {
|
|
928
|
+
logger.error(`Error fetching songs from CAR: ${error}`);
|
|
929
|
+
throw error;
|
|
930
|
+
}
|
|
714
931
|
|
|
715
932
|
return results;
|
|
716
933
|
};
|
|
717
934
|
|
|
718
935
|
const getRockskyUserAlbums = async (agent: Agent): Promise<Albums> => {
|
|
719
|
-
|
|
936
|
+
const results: {
|
|
720
937
|
value: Album.Record;
|
|
721
938
|
uri: string;
|
|
722
939
|
cid: string;
|
|
723
940
|
}[] = [];
|
|
724
|
-
let cursor: string | undefined;
|
|
725
|
-
do {
|
|
726
|
-
const res = await agent.com.atproto.repo.listRecords({
|
|
727
|
-
repo: agent.assertDid,
|
|
728
|
-
collection: "app.rocksky.album",
|
|
729
|
-
limit: PAGE_SIZE,
|
|
730
|
-
cursor,
|
|
731
|
-
});
|
|
732
941
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
942
|
+
try {
|
|
943
|
+
logger.info(`Fetching repository CAR file for albums...`);
|
|
944
|
+
|
|
945
|
+
// Use getRepo to fetch the entire repository as a CAR file
|
|
946
|
+
const repoRes = await agent.com.atproto.sync.getRepo({
|
|
947
|
+
did: agent.assertDid,
|
|
948
|
+
});
|
|
738
949
|
|
|
739
|
-
|
|
950
|
+
// Parse the CAR file
|
|
951
|
+
const carReader = await CarReader.fromBytes(new Uint8Array(repoRes.data));
|
|
952
|
+
const collection = "app.rocksky.album";
|
|
953
|
+
|
|
954
|
+
for await (const { cid, bytes } of carReader.blocks()) {
|
|
955
|
+
try {
|
|
956
|
+
const decoded = cbor.decode(bytes);
|
|
957
|
+
|
|
958
|
+
if (decoded && typeof decoded === "object" && "$type" in decoded) {
|
|
959
|
+
if (decoded.$type === collection) {
|
|
960
|
+
const value = decoded as unknown as Album.Record;
|
|
961
|
+
const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
|
|
962
|
+
|
|
963
|
+
results.push({
|
|
964
|
+
value,
|
|
965
|
+
uri,
|
|
966
|
+
cid: cid.toString(),
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
} catch (e) {
|
|
971
|
+
logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
740
975
|
|
|
741
|
-
cursor = res.data.cursor;
|
|
742
976
|
logger.info(
|
|
743
977
|
`${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} albums`,
|
|
744
978
|
);
|
|
745
|
-
}
|
|
979
|
+
} catch (error) {
|
|
980
|
+
logger.error(`Error fetching albums from CAR: ${error}`);
|
|
981
|
+
throw error;
|
|
982
|
+
}
|
|
746
983
|
|
|
747
984
|
return results;
|
|
748
985
|
};
|
|
749
986
|
|
|
750
987
|
const getRockskyUserArtists = async (agent: Agent): Promise<Artists> => {
|
|
751
|
-
|
|
988
|
+
const results: {
|
|
752
989
|
value: Artist.Record;
|
|
753
990
|
uri: string;
|
|
754
991
|
cid: string;
|
|
755
992
|
}[] = [];
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
cursor,
|
|
993
|
+
|
|
994
|
+
try {
|
|
995
|
+
logger.info(`Fetching repository CAR file for artists...`);
|
|
996
|
+
|
|
997
|
+
const repoRes = await agent.com.atproto.sync.getRepo({
|
|
998
|
+
did: agent.assertDid,
|
|
763
999
|
});
|
|
764
1000
|
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
cid: string;
|
|
768
|
-
value: Artist.Record;
|
|
769
|
-
}>;
|
|
1001
|
+
const carReader = await CarReader.fromBytes(new Uint8Array(repoRes.data));
|
|
1002
|
+
const collection = "app.rocksky.artist";
|
|
770
1003
|
|
|
771
|
-
|
|
1004
|
+
for await (const { cid, bytes } of carReader.blocks()) {
|
|
1005
|
+
try {
|
|
1006
|
+
const decoded = cbor.decode(bytes);
|
|
1007
|
+
|
|
1008
|
+
if (decoded && typeof decoded === "object" && "$type" in decoded) {
|
|
1009
|
+
if (decoded.$type === collection) {
|
|
1010
|
+
const value = decoded as unknown as Artist.Record;
|
|
1011
|
+
const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
|
|
1012
|
+
|
|
1013
|
+
results.push({
|
|
1014
|
+
value,
|
|
1015
|
+
uri,
|
|
1016
|
+
cid: cid.toString(),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
} catch (e) {
|
|
1021
|
+
// Skip blocks that can't be decoded
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
772
1025
|
|
|
773
|
-
cursor = res.data.cursor;
|
|
774
1026
|
logger.info(
|
|
775
1027
|
`${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} artists`,
|
|
776
1028
|
);
|
|
777
|
-
}
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
logger.error(`Error fetching artists from CAR: ${error}`);
|
|
1031
|
+
throw error;
|
|
1032
|
+
}
|
|
778
1033
|
|
|
779
1034
|
return results;
|
|
780
1035
|
};
|
|
781
1036
|
|
|
782
1037
|
const getRockskyUserScrobbles = async (agent: Agent): Promise<Scrobbles> => {
|
|
783
|
-
|
|
1038
|
+
const results: {
|
|
784
1039
|
value: Scrobble.Record;
|
|
785
1040
|
uri: string;
|
|
786
1041
|
cid: string;
|
|
787
1042
|
}[] = [];
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
cursor,
|
|
1043
|
+
|
|
1044
|
+
try {
|
|
1045
|
+
logger.info(`Fetching repository CAR file for scrobbles...`);
|
|
1046
|
+
|
|
1047
|
+
const repoRes = await agent.com.atproto.sync.getRepo({
|
|
1048
|
+
did: agent.assertDid,
|
|
795
1049
|
});
|
|
796
1050
|
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1051
|
+
const carReader = await CarReader.fromBytes(new Uint8Array(repoRes.data));
|
|
1052
|
+
const collection = "app.rocksky.scrobble";
|
|
1053
|
+
|
|
1054
|
+
for await (const { cid, bytes } of carReader.blocks()) {
|
|
1055
|
+
try {
|
|
1056
|
+
const decoded = cbor.decode(bytes);
|
|
802
1057
|
|
|
803
|
-
|
|
1058
|
+
if (decoded && typeof decoded === "object" && "$type" in decoded) {
|
|
1059
|
+
if (decoded.$type === collection) {
|
|
1060
|
+
const value = decoded as unknown as Scrobble.Record;
|
|
1061
|
+
const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
|
|
1062
|
+
|
|
1063
|
+
results.push({
|
|
1064
|
+
value,
|
|
1065
|
+
uri,
|
|
1066
|
+
cid: cid.toString(),
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
} catch (e) {
|
|
1071
|
+
logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
804
1075
|
|
|
805
|
-
cursor = res.data.cursor;
|
|
806
1076
|
logger.info(
|
|
807
1077
|
`${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} scrobbles`,
|
|
808
1078
|
);
|
|
809
|
-
}
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
logger.error(`Error fetching scrobbles from CAR: ${error}`);
|
|
1081
|
+
throw error;
|
|
1082
|
+
}
|
|
810
1083
|
|
|
811
1084
|
return results;
|
|
812
1085
|
};
|
package/src/schema/tracks.ts
CHANGED
|
@@ -1,37 +1,45 @@
|
|
|
1
1
|
import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
|
|
2
|
-
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
2
|
+
import { integer, sqliteTable, text, index } from "drizzle-orm/sqlite-core";
|
|
3
3
|
|
|
4
|
-
const tracks = sqliteTable(
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
4
|
+
const tracks = sqliteTable(
|
|
5
|
+
"tracks",
|
|
6
|
+
{
|
|
7
|
+
id: text("id").primaryKey().notNull(),
|
|
8
|
+
title: text("title").notNull(),
|
|
9
|
+
artist: text("artist").notNull(),
|
|
10
|
+
albumArtist: text("album_artist").notNull(),
|
|
11
|
+
albumArt: text("album_art"),
|
|
12
|
+
album: text("album").notNull(),
|
|
13
|
+
trackNumber: integer("track_number"),
|
|
14
|
+
duration: integer("duration").notNull(),
|
|
15
|
+
mbId: text("mb_id").unique(),
|
|
16
|
+
youtubeLink: text("youtube_link").unique(),
|
|
17
|
+
spotifyLink: text("spotify_link").unique(),
|
|
18
|
+
appleMusicLink: text("apple_music_link").unique(),
|
|
19
|
+
tidalLink: text("tidal_link").unique(),
|
|
20
|
+
discNumber: integer("disc_number"),
|
|
21
|
+
lyrics: text("lyrics"),
|
|
22
|
+
composer: text("composer"),
|
|
23
|
+
genre: text("genre"),
|
|
24
|
+
label: text("label"),
|
|
25
|
+
copyrightMessage: text("copyright_message"),
|
|
26
|
+
uri: text("uri").unique(),
|
|
27
|
+
cid: text("cid").unique().notNull(),
|
|
28
|
+
albumUri: text("album_uri"),
|
|
29
|
+
artistUri: text("artist_uri"),
|
|
30
|
+
createdAt: integer("created_at", { mode: "timestamp" })
|
|
31
|
+
.notNull()
|
|
32
|
+
.default(sql`(unixepoch())`),
|
|
33
|
+
updatedAt: integer("updated_at", { mode: "timestamp" })
|
|
34
|
+
.notNull()
|
|
35
|
+
.default(sql`(unixepoch())`),
|
|
36
|
+
},
|
|
37
|
+
(t) => ({
|
|
38
|
+
idx_title_artist_album_albumartist: index(
|
|
39
|
+
"idx_title_artist_album_albumartist",
|
|
40
|
+
).on(t.title, t.artist, t.album, t.albumArtist),
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
35
43
|
|
|
36
44
|
export type SelectTrack = InferSelectModel<typeof tracks>;
|
|
37
45
|
export type InsertTrack = InferInsertModel<typeof tracks>;
|