@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.
@@ -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.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",
@@ -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 }[];
@@ -133,7 +133,7 @@ const createArtists = async (artists: Artists, user: SelectUser) => {
133
133
  name: tag,
134
134
  }));
135
135
 
136
- const BATCH_SIZE = 500;
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 = 500;
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 = 500;
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 = 500;
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
- let results: {
884
+ const results: {
691
885
  value: Song.Record;
692
886
  uri: string;
693
887
  cid: string;
694
888
  }[] = [];
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,
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
- 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;
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
- } while (cursor);
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
- let results: {
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
- const records = res.data.records as Array<{
734
- uri: string;
735
- cid: string;
736
- value: Album.Record;
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
- results = results.concat(records);
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
- } while (cursor);
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
- let results: {
988
+ const results: {
752
989
  value: Artist.Record;
753
990
  uri: string;
754
991
  cid: string;
755
992
  }[] = [];
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,
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 records = res.data.records as Array<{
766
- uri: string;
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
- results = results.concat(records);
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
1037
  const getRockskyUserScrobbles = async (agent: Agent): Promise<Scrobbles> => {
783
- let results: {
1038
+ const results: {
784
1039
  value: Scrobble.Record;
785
1040
  uri: string;
786
1041
  cid: string;
787
1042
  }[] = [];
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,
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 records = res.data.records as Array<{
798
- uri: string;
799
- cid: string;
800
- value: Scrobble.Record;
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
- results = results.concat(records);
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
- } while (cursor);
1079
+ } catch (error) {
1080
+ logger.error(`Error fetching scrobbles from CAR: ${error}`);
1081
+ throw error;
1082
+ }
810
1083
 
811
1084
  return results;
812
1085
  };
@@ -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>;