diff --git a/drizzle/0003_stiff_captain_universe.sql b/drizzle/0003_stiff_captain_universe.sql new file mode 100644 index 0000000..3f0b402 --- /dev/null +++ b/drizzle/0003_stiff_captain_universe.sql @@ -0,0 +1,14 @@ +CREATE TABLE `user_stat_bucket` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `metric` text NOT NULL, + `value` integer NOT NULL, + `bucket_start` integer NOT NULL, + `station_id` integer, + `file_id` text, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `user_stat_bucket_user_metric_bucket_idx` ON `user_stat_bucket` (`user_id`,`metric`,`bucket_start`);--> statement-breakpoint +DROP TABLE `task`; \ No newline at end of file diff --git a/drizzle/0004_abandoned_captain_stacy.sql b/drizzle/0004_abandoned_captain_stacy.sql new file mode 100644 index 0000000..3bf4e5a --- /dev/null +++ b/drizzle/0004_abandoned_captain_stacy.sql @@ -0,0 +1,9 @@ +CREATE TABLE `song_ids` ( + `id` text PRIMARY KEY NOT NULL, + `file_id` text NOT NULL, + `spotify_id` text, + `title` text NOT NULL, + `artists` text NOT NULL, + `image` text NOT NULL, + `label` text NOT NULL +); diff --git a/drizzle/0005_chubby_master_mold.sql b/drizzle/0005_chubby_master_mold.sql new file mode 100644 index 0000000..71ddc9f --- /dev/null +++ b/drizzle/0005_chubby_master_mold.sql @@ -0,0 +1,33 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_song_ids` ( + `id` text PRIMARY KEY NOT NULL, + `file_id` text NOT NULL, + `spotify_id` text, + `title` text NOT NULL, + `artists` text NOT NULL, + `image` text NOT NULL, + `label` text +); +--> statement-breakpoint +INSERT INTO `__new_song_ids`("id", "file_id", "spotify_id", "title", "artists", "image", "label") SELECT "id", "file_id", "spotify_id", "title", "artists", "image", "label" FROM `song_ids`;--> statement-breakpoint +DROP TABLE `song_ids`;--> statement-breakpoint +ALTER TABLE `__new_song_ids` RENAME TO `song_ids`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `song_ids_file_id_idx` ON `song_ids` (`file_id`);--> statement-breakpoint +CREATE TABLE `__new_user_stat_bucket` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `metric` text NOT NULL, + `value` integer NOT NULL, + `bucket_start` integer NOT NULL, + `station_id` integer DEFAULT 0 NOT NULL, + `file_id` text DEFAULT '' NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_user_stat_bucket`("id", "user_id", "metric", "value", "bucket_start", "station_id", "file_id", "created_at") SELECT "id", "user_id", "metric", "value", "bucket_start", "station_id", "file_id", "created_at" FROM `user_stat_bucket`;--> statement-breakpoint +DROP TABLE `user_stat_bucket`;--> statement-breakpoint +ALTER TABLE `__new_user_stat_bucket` RENAME TO `user_stat_bucket`;--> statement-breakpoint +CREATE INDEX `user_stat_bucket_user_metric_bucket_idx` ON `user_stat_bucket` (`user_id`,`metric`,`bucket_start`);--> statement-breakpoint +CREATE UNIQUE INDEX `user_stat_bucket_unique_idx` ON `user_stat_bucket` (`user_id`,`metric`,`bucket_start`,`station_id`,`file_id`); \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..e80aade --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,599 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d97ddeeb-3d7a-46a2-9a50-5d90fc51516e", + "prevId": "773dd59c-33d4-4b87-8541-dda2e9151482", + "tables": { + "user_stat_bucket": { + "name": "user_stat_bucket", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bucket_start": { + "name": "bucket_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "station_id": { + "name": "station_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_stat_bucket_user_metric_bucket_idx": { + "name": "user_stat_bucket_user_metric_bucket_idx", + "columns": [ + "user_id", + "metric", + "bucket_start" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_stat_bucket_user_id_user_id_fk": { + "name": "user_stat_bucket_user_id_user_id_fk", + "tableFrom": "user_stat_bucket", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkey": { + "name": "passkey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "passkey_credentialID_idx": { + "name": "passkey_credentialID_idx", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "account_number": { + "name": "account_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_account_number_unique": { + "name": "user_account_number_unique", + "columns": [ + "account_number" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..dd769fb --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,658 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9eba4b9d-41cd-4908-9356-2bb7075467b9", + "prevId": "d97ddeeb-3d7a-46a2-9a50-5d90fc51516e", + "tables": { + "song_ids": { + "name": "song_ids", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spotify_id": { + "name": "spotify_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artists": { + "name": "artists", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_stat_bucket": { + "name": "user_stat_bucket", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bucket_start": { + "name": "bucket_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "station_id": { + "name": "station_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_stat_bucket_user_metric_bucket_idx": { + "name": "user_stat_bucket_user_metric_bucket_idx", + "columns": [ + "user_id", + "metric", + "bucket_start" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_stat_bucket_user_id_user_id_fk": { + "name": "user_stat_bucket_user_id_user_id_fk", + "tableFrom": "user_stat_bucket", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkey": { + "name": "passkey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "passkey_credentialID_idx": { + "name": "passkey_credentialID_idx", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "account_number": { + "name": "account_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_account_number_unique": { + "name": "user_account_number_unique", + "columns": [ + "account_number" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..ac67980 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,679 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9e75c298-5f62-44f0-9f1a-40481e7df553", + "prevId": "9eba4b9d-41cd-4908-9356-2bb7075467b9", + "tables": { + "song_ids": { + "name": "song_ids", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spotify_id": { + "name": "spotify_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artists": { + "name": "artists", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "song_ids_file_id_idx": { + "name": "song_ids_file_id_idx", + "columns": [ + "file_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_stat_bucket": { + "name": "user_stat_bucket", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bucket_start": { + "name": "bucket_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "station_id": { + "name": "station_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_stat_bucket_user_metric_bucket_idx": { + "name": "user_stat_bucket_user_metric_bucket_idx", + "columns": [ + "user_id", + "metric", + "bucket_start" + ], + "isUnique": false + }, + "user_stat_bucket_unique_idx": { + "name": "user_stat_bucket_unique_idx", + "columns": [ + "user_id", + "metric", + "bucket_start", + "station_id", + "file_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_stat_bucket_user_id_user_id_fk": { + "name": "user_stat_bucket_user_id_user_id_fk", + "tableFrom": "user_stat_bucket", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkey": { + "name": "passkey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "passkey_credentialID_idx": { + "name": "passkey_credentialID_idx", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "account_number": { + "name": "account_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_account_number_unique": { + "name": "user_account_number_unique", + "columns": [ + "account_number" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5b60b9e..12d824d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,27 @@ "when": 1775246437951, "tag": "0002_account_number_auth", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1775423880350, + "tag": "0003_stiff_captain_universe", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1775425092281, + "tag": "0004_abandoned_captain_stacy", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1777011840981, + "tag": "0005_chubby_master_mold", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/lib/components/app/auth-dialog.svelte b/src/lib/components/app/auth-dialog.svelte index ea932d4..ae497d2 100644 --- a/src/lib/components/app/auth-dialog.svelte +++ b/src/lib/components/app/auth-dialog.svelte @@ -32,6 +32,7 @@ let passkeyName = $state(''); let turnstileToken = $state(''); let resetTurnstile = $state<(() => void) | undefined>(); + let createFormElement = $state(); const turnstileSiteKey = $derived( dev ? '1x00000000000000000000AA' : (publicEnv.PUBLIC_TURNSTILE_SITE_KEY ?? '') ); @@ -114,15 +115,26 @@ authMessage = 'Turnstile check expired. Please try again.'; }; + const getTurnstileToken = () => { + const formToken = createFormElement + ?.querySelector('input[name="cf-turnstile-response"]') + ?.value + .trim(); + + return turnstileToken || formToken || ''; + }; + const createAccount = async () => { - if (!turnstileToken) { + const token = getTurnstileToken(); + + if (!token) { authMessage = 'Please complete the Turnstile check before creating an account.'; return; } await runAuthAction( 'create-account', - () => authClient.createAccount(name, turnstileToken), + () => authClient.createAccount(name, token), 'Account creation failed', async () => { await session.get().refetch(); @@ -329,7 +341,7 @@ {:else if authScreen === 'login'} -
+
{#if busyAction === 'create-account'} diff --git a/src/lib/components/app/daemon.svelte b/src/lib/components/app/daemon.svelte index e60dd27..c2544b5 100644 --- a/src/lib/components/app/daemon.svelte +++ b/src/lib/components/app/daemon.svelte @@ -161,6 +161,32 @@ } }); + onMount(() => { + const listenInterval = setInterval(async () => { + if ( + !appState.isPlaying || + !appState.currentSong || + !appState.currentStation || + !audioElement || + audioElement.paused + ) { + return; + } + + await fetch('/api/listen', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + fileId: appState.currentSong.fileId, + stationId: appState.currentStation, + seconds: 30, + }), + }); + }, 30_000); + + return () => clearInterval(listenInterval); + }) + $effect(() => { if (!audioElement) return; togglePlayback(appState.isPlaying); @@ -273,4 +299,4 @@ show={appState.showPomodoro} > - \ No newline at end of file + diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 33e7cfd..0fd3924 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -6,6 +6,7 @@ import { anonymous } from 'better-auth/plugins'; import { sveltekitCookies } from 'better-auth/svelte-kit'; import type { D1Database } from '@cloudflare/workers-types'; import { env } from '$env/dynamic/private'; +import { dev } from '$app/environment'; import { getRequestEvent } from '$app/server'; import { getDb } from '$lib/server/db'; import { passkey } from '@better-auth/passkey'; @@ -33,14 +34,16 @@ const getClientIpAddress = () => { }; const verifyTurnstileToken = async (token: string) => { - if (!env.TURNSTILE_SECRET_KEY) { + const secretKey = dev ? '1x0000000000000000000000000000000AA' : env.TURNSTILE_SECRET_KEY; + + if (!secretKey) { throw new APIError('INTERNAL_SERVER_ERROR', { message: 'Turnstile secret key is not configured', }); } const verificationBody = new FormData(); - verificationBody.set('secret', env.TURNSTILE_SECRET_KEY); + verificationBody.set('secret', secretKey); verificationBody.set('response', token); const remoteIp = getClientIpAddress(); @@ -68,7 +71,7 @@ const verifyTurnstileToken = async (token: string) => { message: result['error-codes']?.includes('timeout-or-duplicate') ? 'Turnstile check expired. Please try again.' - : 'Please complete the Turnstile check before creating an account.', + : 'Turnstile verification failed. Please try again.', }); } }; diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index eb47782..19382bb 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,14 +1,39 @@ -import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; import { user } from './auth.schema'; +import { sql } from 'drizzle-orm'; -export const userStatEvent = sqliteTable('user_stat_event', { +export const userStatBucket = sqliteTable('user_stat_bucket', { id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), - metric: text('metric').notNull(), // 'listen_time', 'pomodoro_focus_time'... - value: integer('value').notNull(), // seconds + metric: text('metric').notNull(), + value: integer('value').notNull(), // seconds, count, etc. bucketStart: integer('bucket_start', { mode: 'timestamp_ms' }).notNull(), - stationId: text('station_id'), - createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull() -}); + stationId: integer('station_id').notNull().default(0), + fileId: text('file_id').notNull().default(''), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), +}, (table) => [ + index('user_stat_bucket_user_metric_bucket_idx').on(table.userId, table.metric, table.bucketStart), + uniqueIndex('user_stat_bucket_unique_idx').on( + table.userId, + table.metric, + table.bucketStart, + table.stationId, + table.fileId, + ), +]); + +export const songIds = sqliteTable('song_ids', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + fileId: text('file_id').notNull(), + spotifyId: text('spotify_id'), + title: text('title').notNull(), + artists: text('artists').notNull(), + image: text('image').notNull(), + label: text('label'), +}, (table) => [ + uniqueIndex('song_ids_file_id_idx').on(table.fileId), +]); export * from './auth.schema'; diff --git a/src/lib/stations/chillhop.ts b/src/lib/stations/chillhop.ts index ddcec64..4ea9e5c 100644 --- a/src/lib/stations/chillhop.ts +++ b/src/lib/stations/chillhop.ts @@ -1,18 +1,55 @@ -import type { CHSong, Song } from "@/types"; +import { getRequestEvent } from '$app/server'; +import { getDb } from '@/server/db'; +import { songIds } from '@/server/db/schema'; +import type { CHSong, Song } from '@/types'; +import { and, eq } from 'drizzle-orm'; + export async function getChillhopStation(id: number): Promise { const res = await fetch(`https://stream.chillhop.com/live/${id}`); - const data = await res.json() as CHSong[]; - - const finalData = data.map(song => ({ + const data = (await res.json()) as CHSong[]; + + const finalData = data.map((song) => ({ + fileId: String(song.fileId), artists: song.artists, title: song.title, endpoint: `/api/chstream/${song.fileId}`, image: song.image, label: 'Chillhop Music', spotifyId: song.spotifyId, - duration: song.duration, + duration: song.duration ?? 0, })) as Song[]; + + // should not await because it doesn't need to be done before returning the data + for (const song of finalData) { + analyticsData(song).catch((err) => { + console.error('Failed to store analytics data for song:', song.title, err); + }); + } return finalData; } + +async function analyticsData(song: Song) { + const db = getRequestDb(); + const existingSong = await db.select().from(songIds).where(and(eq(songIds.title, song.title), eq(songIds.artists, song.artists))).get(); + + if (existingSong) return; + + await db.insert(songIds).values({ + fileId: song.fileId, + spotifyId: song.spotifyId, + title: song.title, + artists: song.artists, + image: song.image, + label: song.label, + }); +} + +function getRequestDb() { + const d1 = getRequestEvent().platform?.env.DB; + if (!d1) { + throw new Error('D1 binding "DB" not found'); + } + return getDb(d1); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index f96442b..0f21de1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,4 +1,5 @@ export interface Song { + fileId: string; artists: string; title: string; endpoint: string; diff --git a/src/routes/api/listen/+server.ts b/src/routes/api/listen/+server.ts new file mode 100644 index 0000000..eb69bc0 --- /dev/null +++ b/src/routes/api/listen/+server.ts @@ -0,0 +1,70 @@ +import { json } from '@sveltejs/kit'; +import { sql } from 'drizzle-orm'; +import { getDb } from '@/server/db'; +import { userStatBucket } from '@/server/db/schema'; +import type { RequestHandler } from './$types'; + +type ListenBody = { + fileId?: unknown; + stationId?: unknown; + seconds?: unknown; +}; + +const LISTEN_METRIC = 'listen_seconds'; +const BUCKET_SIZE_MS = 60 * 60 * 1000; + +const getHourlyBucketStart = (date = new Date()) => + new Date(Math.floor(date.getTime() / BUCKET_SIZE_MS) * BUCKET_SIZE_MS); + +const clampListenSeconds = (seconds: unknown) => { + const parsed = typeof seconds === 'number' ? seconds : Number(seconds); + + if (!Number.isFinite(parsed)) { + return 30; + } + + return Math.max(1, Math.min(60, Math.round(parsed))); +}; + +export const POST: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = (await event.request.json().catch(() => ({}))) as ListenBody; + const fileId = typeof body.fileId === 'string' ? body.fileId.trim() : ''; + const stationId = typeof body.stationId === 'number' ? body.stationId : Number(body.stationId); + + if (!fileId || !Number.isInteger(stationId)) { + return json({ error: 'Invalid listen payload' }, { status: 400 }); + } + + const seconds = clampListenSeconds(body.seconds); + const db = getDb(event.platform!.env.DB); + + await db + .insert(userStatBucket) + .values({ + userId: user.id, + metric: LISTEN_METRIC, + value: seconds, + bucketStart: getHourlyBucketStart(), + stationId, + fileId, + }) + .onConflictDoUpdate({ + target: [ + userStatBucket.userId, + userStatBucket.metric, + userStatBucket.bucketStart, + userStatBucket.stationId, + userStatBucket.fileId, + ], + set: { + value: sql`${userStatBucket.value} + ${seconds}`, + }, + }); + + return json({ ok: true }); +}; diff --git a/src/routes/api/stats/+server.ts b/src/routes/api/stats/+server.ts new file mode 100644 index 0000000..5267080 --- /dev/null +++ b/src/routes/api/stats/+server.ts @@ -0,0 +1,67 @@ +import { json } from '@sveltejs/kit'; +import { and, desc, eq, gte, sql } from 'drizzle-orm'; +import { getDb } from '@/server/db'; +import { songIds, userStatBucket } from '@/server/db/schema'; +import type { RequestHandler } from './$types'; + +const LISTEN_METRIC = 'listen_seconds'; + +const getUtcDayStart = (date = new Date()) => + new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + +export const GET: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + const db = getDb(event.platform!.env.DB); + const todayStart = getUtcDayStart(); + const listenFilter = and( + eq(userStatBucket.userId, user.id), + eq(userStatBucket.metric, LISTEN_METRIC), + ); + + const [totalListen] = await db + .select({ seconds: sql`coalesce(sum(${userStatBucket.value}), 0)` }) + .from(userStatBucket) + .where(listenFilter); + + const [todayListen] = await db + .select({ seconds: sql`coalesce(sum(${userStatBucket.value}), 0)` }) + .from(userStatBucket) + .where(and(listenFilter, gte(userStatBucket.bucketStart, todayStart))); + + const topSongs = await db + .select({ + fileId: userStatBucket.fileId, + title: songIds.title, + artists: songIds.artists, + image: songIds.image, + seconds: sql`sum(${userStatBucket.value})`, + }) + .from(userStatBucket) + .leftJoin(songIds, eq(userStatBucket.fileId, songIds.fileId)) + .where(listenFilter) + .groupBy(userStatBucket.fileId, songIds.title, songIds.artists, songIds.image) + .orderBy(desc(sql`sum(${userStatBucket.value})`)) + .limit(5); + + const topStations = await db + .select({ + stationId: userStatBucket.stationId, + seconds: sql`sum(${userStatBucket.value})`, + }) + .from(userStatBucket) + .where(listenFilter) + .groupBy(userStatBucket.stationId) + .orderBy(desc(sql`sum(${userStatBucket.value})`)) + .limit(5); + + return json({ + totalSeconds: totalListen?.seconds ?? 0, + todaySeconds: todayListen?.seconds ?? 0, + topSongs, + topStations, + }); +};