@rocksky/cli 0.3.0 → 0.3.2

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.
@@ -8,6 +8,13 @@
8
8
  "when": 1768065262210,
9
9
  "tag": "0000_parallel_paper_doll",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1768571013590,
16
+ "tag": "0001_awesome_gabe_jones",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocksky/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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/preview.png ADDED
Binary file
@@ -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
- const PAGE_SIZE = 100;
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 }[];
@@ -39,11 +39,13 @@ export async function sync() {
39
39
  logger.info` DID: ${did}`;
40
40
  logger.info` Handle: ${handle}`;
41
41
 
42
+ const carReader = await downloadCarFile(agent);
43
+
42
44
  const [artists, albums, songs, scrobbles] = await Promise.all([
43
- getRockskyUserArtists(agent),
44
- getRockskyUserAlbums(agent),
45
- getRockskyUserSongs(agent),
46
- getRockskyUserScrobbles(agent),
45
+ getRockskyUserArtists(agent, carReader),
46
+ getRockskyUserAlbums(agent, carReader),
47
+ getRockskyUserSongs(agent, carReader),
48
+ getRockskyUserScrobbles(agent, carReader),
47
49
  ]);
48
50
 
49
51
  logger.info` Artists: ${artists.length}`;
@@ -133,7 +135,7 @@ const createArtists = async (artists: Artists, user: SelectUser) => {
133
135
  name: tag,
134
136
  }));
135
137
 
136
- const BATCH_SIZE = 500;
138
+ const BATCH_SIZE = 1000;
137
139
  for (let i = 0; i < uniqueTags.length; i += BATCH_SIZE) {
138
140
  const batch = uniqueTags.slice(i, i + BATCH_SIZE);
139
141
  await ctx.db
@@ -241,7 +243,7 @@ const createAlbums = async (albums: Albums, user: SelectUser) => {
241
243
  .filter(({ artist }) => artist);
242
244
 
243
245
  // Process albums in batches
244
- const BATCH_SIZE = 500;
246
+ const BATCH_SIZE = 1000;
245
247
  let totalAlbumsImported = 0;
246
248
 
247
249
  for (let i = 0; i < validAlbumData.length; i += BATCH_SIZE) {
@@ -332,7 +334,7 @@ const createSongs = async (songs: Songs, user: SelectUser) => {
332
334
  .filter(({ artist, album }) => artist && album);
333
335
 
334
336
  // Process in batches to avoid stack overflow with large datasets
335
- const BATCH_SIZE = 500;
337
+ const BATCH_SIZE = 1000;
336
338
  let totalTracksImported = 0;
337
339
 
338
340
  for (let i = 0; i < validSongData.length; i += BATCH_SIZE) {
@@ -481,7 +483,7 @@ const createScrobbles = async (scrobbles: Scrobbles, user: SelectUser) => {
481
483
  .filter(({ track, album, artist }) => track && album && artist);
482
484
 
483
485
  // Process in batches to avoid stack overflow with large datasets
484
- const BATCH_SIZE = 500;
486
+ const BATCH_SIZE = 1000;
485
487
  let totalScrobblesImported = 0;
486
488
 
487
489
  for (let i = 0; i < validScrobbleData.length; i += BATCH_SIZE) {
@@ -624,6 +626,16 @@ const onNewSong = async (
624
626
  ) => {
625
627
  const { title, artist, album } = record;
626
628
  logger.info` New song: ${title} by ${artist} from ${album}`;
629
+ await createSongs(
630
+ [
631
+ {
632
+ cid,
633
+ uri,
634
+ value: record,
635
+ },
636
+ ],
637
+ user,
638
+ );
627
639
  };
628
640
 
629
641
  const onNewAlbum = async (
@@ -672,8 +684,192 @@ const onNewScrobble = async (
672
684
  uri: string,
673
685
  user: SelectUser,
674
686
  ) => {
675
- const { title, createdAt } = record;
687
+ const { title, createdAt, artist, album, albumArtist } = record;
676
688
  logger.info` New scrobble: ${title} at ${createdAt}`;
689
+
690
+ // Check if the artist exists, create if not
691
+ let [artistRecord] = await ctx.db
692
+ .select()
693
+ .from(schema.artists)
694
+ .where(eq(schema.artists.name, record.albumArtist))
695
+ .execute();
696
+
697
+ if (!artistRecord) {
698
+ logger.info` ⚙️ Artist not found, creating: "${albumArtist}"`;
699
+
700
+ // Create a synthetic artist record from scrobble data
701
+ const artistUri = `at://${user.did}/app.rocksky.artist/${createId()}`;
702
+ const artistCid = createId();
703
+
704
+ await createArtists(
705
+ [
706
+ {
707
+ cid: artistCid,
708
+ uri: artistUri,
709
+ value: {
710
+ $type: "app.rocksky.artist",
711
+ name: record.albumArtist,
712
+ createdAt: new Date().toISOString(),
713
+ tags: record.tags || [],
714
+ } as Artist.Record,
715
+ },
716
+ ],
717
+ user,
718
+ );
719
+
720
+ [artistRecord] = await ctx.db
721
+ .select()
722
+ .from(schema.artists)
723
+ .where(eq(schema.artists.name, record.albumArtist))
724
+ .execute();
725
+
726
+ if (!artistRecord) {
727
+ logger.error` ❌ Failed to create artist. Skipping scrobble.`;
728
+ return;
729
+ }
730
+ }
731
+
732
+ // Check if the album exists, create if not
733
+ let [albumRecord] = await ctx.db
734
+ .select()
735
+ .from(schema.albums)
736
+ .where(
737
+ and(
738
+ eq(schema.albums.title, record.album),
739
+ eq(schema.albums.artist, record.albumArtist),
740
+ ),
741
+ )
742
+ .execute();
743
+
744
+ if (!albumRecord) {
745
+ logger.info` ⚙️ Album not found, creating: "${album}" by ${albumArtist}`;
746
+
747
+ // Create a synthetic album record from scrobble data
748
+ const albumUri = `at://${user.did}/app.rocksky.album/${createId()}`;
749
+ const albumCid = createId();
750
+
751
+ await createAlbums(
752
+ [
753
+ {
754
+ cid: albumCid,
755
+ uri: albumUri,
756
+ value: {
757
+ $type: "app.rocksky.album",
758
+ title: record.album,
759
+ artist: record.albumArtist,
760
+ createdAt: new Date().toISOString(),
761
+ releaseDate: record.releaseDate,
762
+ year: record.year,
763
+ albumArt: record.albumArt,
764
+ artistUri: artistRecord.uri,
765
+ spotifyLink: record.spotifyLink,
766
+ appleMusicLink: record.appleMusicLink,
767
+ tidalLink: record.tidalLink,
768
+ youtubeLink: record.youtubeLink,
769
+ } as Album.Record,
770
+ },
771
+ ],
772
+ user,
773
+ );
774
+
775
+ // Fetch the newly created album
776
+ [albumRecord] = await ctx.db
777
+ .select()
778
+ .from(schema.albums)
779
+ .where(
780
+ and(
781
+ eq(schema.albums.title, record.album),
782
+ eq(schema.albums.artist, record.albumArtist),
783
+ ),
784
+ )
785
+ .execute();
786
+
787
+ if (!albumRecord) {
788
+ logger.error` ❌ Failed to create album. Skipping scrobble.`;
789
+ return;
790
+ }
791
+ }
792
+
793
+ // Check if the track exists, create if not
794
+ let [track] = await ctx.db
795
+ .select()
796
+ .from(schema.tracks)
797
+ .where(
798
+ and(
799
+ eq(schema.tracks.title, record.title),
800
+ eq(schema.tracks.artist, record.artist),
801
+ eq(schema.tracks.album, record.album),
802
+ eq(schema.tracks.albumArtist, record.albumArtist),
803
+ ),
804
+ )
805
+ .execute();
806
+
807
+ if (!track) {
808
+ logger.info` ⚙️ Track not found, creating: "${title}" by ${artist} from ${album}`;
809
+
810
+ // Create a synthetic track record from scrobble data
811
+ const trackUri = `at://${user.did}/app.rocksky.song/${createId()}`;
812
+ const trackCid = createId();
813
+
814
+ await createSongs(
815
+ [
816
+ {
817
+ cid: trackCid,
818
+ uri: trackUri,
819
+ value: {
820
+ $type: "app.rocksky.song",
821
+ title: record.title,
822
+ artist: record.artist,
823
+ albumArtist: record.albumArtist,
824
+ album: record.album,
825
+ duration: record.duration,
826
+ trackNumber: record.trackNumber,
827
+ discNumber: record.discNumber,
828
+ releaseDate: record.releaseDate,
829
+ year: record.year,
830
+ genre: record.genre,
831
+ tags: record.tags,
832
+ composer: record.composer,
833
+ lyrics: record.lyrics,
834
+ copyrightMessage: record.copyrightMessage,
835
+ albumArt: record.albumArt,
836
+ youtubeLink: record.youtubeLink,
837
+ spotifyLink: record.spotifyLink,
838
+ tidalLink: record.tidalLink,
839
+ appleMusicLink: record.appleMusicLink,
840
+ createdAt: new Date().toISOString(),
841
+ mbId: record.mbid,
842
+ label: record.label,
843
+ albumUri: albumRecord.uri,
844
+ artistUri: artistRecord.uri,
845
+ } as Song.Record,
846
+ },
847
+ ],
848
+ user,
849
+ );
850
+
851
+ // Fetch the newly created track
852
+ [track] = await ctx.db
853
+ .select()
854
+ .from(schema.tracks)
855
+ .where(
856
+ and(
857
+ eq(schema.tracks.title, record.title),
858
+ eq(schema.tracks.artist, record.artist),
859
+ eq(schema.tracks.album, record.album),
860
+ eq(schema.tracks.albumArtist, record.albumArtist),
861
+ ),
862
+ )
863
+ .execute();
864
+
865
+ if (!track) {
866
+ logger.error` ❌ Failed to create track. Skipping scrobble.`;
867
+ return;
868
+ }
869
+ }
870
+
871
+ logger.info` ✓ All required entities ready. Creating scrobble...`;
872
+
677
873
  await createScrobbles(
678
874
  [
679
875
  {
@@ -686,127 +882,201 @@ const onNewScrobble = async (
686
882
  );
687
883
  };
688
884
 
689
- const getRockskyUserSongs = async (agent: Agent): Promise<Songs> => {
690
- let results: {
885
+ const downloadCarFile = async (agent: Agent) => {
886
+ logger.info(`Fetching repository CAR file ...`);
887
+
888
+ const repoRes = await agent.com.atproto.sync.getRepo({
889
+ did: agent.assertDid,
890
+ });
891
+
892
+ return CarReader.fromBytes(new Uint8Array(repoRes.data));
893
+ };
894
+
895
+ const getRockskyUserSongs = async (
896
+ agent: Agent,
897
+ carReader: CarReader,
898
+ ): Promise<Songs> => {
899
+ const results: {
691
900
  value: Song.Record;
692
901
  uri: string;
693
902
  cid: string;
694
903
  }[] = [];
695
- let cursor: string | undefined;
696
- do {
697
- const res = await agent.com.atproto.repo.listRecords({
698
- repo: agent.assertDid,
699
- collection: "app.rocksky.song",
700
- limit: PAGE_SIZE,
701
- cursor,
702
- });
703
- const records = res.data.records as Array<{
704
- uri: string;
705
- cid: string;
706
- value: Song.Record;
707
- }>;
708
- results = results.concat(records);
709
- cursor = res.data.cursor;
904
+
905
+ try {
906
+ const collection = "app.rocksky.song";
907
+
908
+ for await (const { cid, bytes } of carReader.blocks()) {
909
+ try {
910
+ const decoded = cbor.decode(bytes);
911
+
912
+ // Check if this is a record with $type matching our collection
913
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
914
+ if (decoded.$type === collection) {
915
+ const value = decoded as unknown as Song.Record;
916
+ // Extract rkey from uri if present in the block, otherwise use cid
917
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
918
+
919
+ results.push({
920
+ value,
921
+ uri,
922
+ cid: cid.toString(),
923
+ });
924
+ }
925
+ }
926
+ } catch (e) {
927
+ logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
928
+ continue;
929
+ }
930
+ }
931
+
710
932
  logger.info(
711
933
  `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} songs`,
712
934
  );
713
- } while (cursor);
935
+ } catch (error) {
936
+ logger.error(`Error fetching songs from CAR: ${error}`);
937
+ throw error;
938
+ }
714
939
 
715
940
  return results;
716
941
  };
717
942
 
718
- const getRockskyUserAlbums = async (agent: Agent): Promise<Albums> => {
719
- let results: {
943
+ const getRockskyUserAlbums = async (
944
+ agent: Agent,
945
+ carReader: CarReader,
946
+ ): Promise<Albums> => {
947
+ const results: {
720
948
  value: Album.Record;
721
949
  uri: string;
722
950
  cid: string;
723
951
  }[] = [];
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
-
733
- const records = res.data.records as Array<{
734
- uri: string;
735
- cid: string;
736
- value: Album.Record;
737
- }>;
738
952
 
739
- results = results.concat(records);
953
+ try {
954
+ const collection = "app.rocksky.album";
955
+ logger.info`Extracting ${collection} records from CAR file ...`;
956
+
957
+ for await (const { cid, bytes } of carReader.blocks()) {
958
+ try {
959
+ const decoded = cbor.decode(bytes);
960
+
961
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
962
+ if (decoded.$type === collection) {
963
+ const value = decoded as unknown as Album.Record;
964
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
965
+
966
+ results.push({
967
+ value,
968
+ uri,
969
+ cid: cid.toString(),
970
+ });
971
+ }
972
+ }
973
+ } catch (e) {
974
+ logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
975
+ continue;
976
+ }
977
+ }
740
978
 
741
- cursor = res.data.cursor;
742
979
  logger.info(
743
980
  `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} albums`,
744
981
  );
745
- } while (cursor);
982
+ } catch (error) {
983
+ logger.error(`Error fetching albums from CAR: ${error}`);
984
+ throw error;
985
+ }
746
986
 
747
987
  return results;
748
988
  };
749
989
 
750
- const getRockskyUserArtists = async (agent: Agent): Promise<Artists> => {
751
- let results: {
990
+ const getRockskyUserArtists = async (
991
+ agent: Agent,
992
+ carReader: CarReader,
993
+ ): Promise<Artists> => {
994
+ const results: {
752
995
  value: Artist.Record;
753
996
  uri: string;
754
997
  cid: string;
755
998
  }[] = [];
756
- let cursor: string | undefined;
757
- do {
758
- const res = await agent.com.atproto.repo.listRecords({
759
- repo: agent.assertDid,
760
- collection: "app.rocksky.artist",
761
- limit: PAGE_SIZE,
762
- cursor,
763
- });
764
999
 
765
- const records = res.data.records as Array<{
766
- uri: string;
767
- cid: string;
768
- value: Artist.Record;
769
- }>;
770
-
771
- results = results.concat(records);
1000
+ try {
1001
+ const collection = "app.rocksky.artist";
1002
+ logger.info`Extracting ${collection} records from CAR file ...`;
1003
+
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
- } while (cursor);
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
- const getRockskyUserScrobbles = async (agent: Agent): Promise<Scrobbles> => {
783
- let results: {
1037
+ const getRockskyUserScrobbles = async (
1038
+ agent: Agent,
1039
+ carReader: CarReader,
1040
+ ): Promise<Scrobbles> => {
1041
+ const results: {
784
1042
  value: Scrobble.Record;
785
1043
  uri: string;
786
1044
  cid: string;
787
1045
  }[] = [];
788
- let cursor: string | undefined;
789
- do {
790
- const res = await agent.com.atproto.repo.listRecords({
791
- repo: agent.assertDid,
792
- collection: "app.rocksky.scrobble",
793
- limit: PAGE_SIZE,
794
- cursor,
795
- });
796
1046
 
797
- const records = res.data.records as Array<{
798
- uri: string;
799
- cid: string;
800
- value: Scrobble.Record;
801
- }>;
802
-
803
- results = results.concat(records);
1047
+ try {
1048
+ const collection = "app.rocksky.scrobble";
1049
+ logger.info`Extracting ${collection} records from CAR file ...`;
1050
+
1051
+ for await (const { cid, bytes } of carReader.blocks()) {
1052
+ try {
1053
+ const decoded = cbor.decode(bytes);
1054
+
1055
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
1056
+ if (decoded.$type === collection) {
1057
+ const value = decoded as unknown as Scrobble.Record;
1058
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
1059
+
1060
+ results.push({
1061
+ value,
1062
+ uri,
1063
+ cid: cid.toString(),
1064
+ });
1065
+ }
1066
+ }
1067
+ } catch (e) {
1068
+ logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
1069
+ continue;
1070
+ }
1071
+ }
804
1072
 
805
- cursor = res.data.cursor;
806
1073
  logger.info(
807
1074
  `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} scrobbles`,
808
1075
  );
809
- } while (cursor);
1076
+ } catch (error) {
1077
+ logger.error(`Error fetching scrobbles from CAR: ${error}`);
1078
+ throw error;
1079
+ }
810
1080
 
811
1081
  return results;
812
1082
  };
@@ -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("tracks", {
5
- id: text("id").primaryKey().notNull(),
6
- title: text("title").notNull(),
7
- artist: text("artist").notNull(),
8
- albumArtist: text("album_artist").notNull(),
9
- albumArt: text("album_art"),
10
- album: text("album").notNull(),
11
- trackNumber: integer("track_number"),
12
- duration: integer("duration").notNull(),
13
- mbId: text("mb_id").unique(),
14
- youtubeLink: text("youtube_link").unique(),
15
- spotifyLink: text("spotify_link").unique(),
16
- appleMusicLink: text("apple_music_link").unique(),
17
- tidalLink: text("tidal_link").unique(),
18
- discNumber: integer("disc_number"),
19
- lyrics: text("lyrics"),
20
- composer: text("composer"),
21
- genre: text("genre"),
22
- label: text("label"),
23
- copyrightMessage: text("copyright_message"),
24
- uri: text("uri").unique(),
25
- cid: text("cid").unique().notNull(),
26
- albumUri: text("album_uri"),
27
- artistUri: text("artist_uri"),
28
- createdAt: integer("created_at", { mode: "timestamp" })
29
- .notNull()
30
- .default(sql`(unixepoch())`),
31
- updatedAt: integer("updated_at", { mode: "timestamp" })
32
- .notNull()
33
- .default(sql`(unixepoch())`),
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>;