feat: collect listening stats

This commit is contained in:
2026-04-24 16:31:01 +02:00
parent 9be2ed4b86
commit 59e82142d5
15 changed files with 2275 additions and 21 deletions

View File

@@ -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`;

View File

@@ -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
);

View File

@@ -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`);

View File

@@ -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": {}
}
}

View File

@@ -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": {}
}
}

View File

@@ -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": {}
}
}

View File

@@ -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
}
]
}
}

View File

@@ -32,6 +32,7 @@
let passkeyName = $state('');
let turnstileToken = $state('');
let resetTurnstile = $state<(() => void) | undefined>();
let createFormElement = $state<HTMLDivElement>();
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<HTMLInputElement>('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 @@
</Button>
</div>
{:else if authScreen === 'login'}
<div class="space-y-4">
<div class="space-y-4" bind:this={createFormElement}>
<div class="space-y-2">
<Label for="accountNumber" class="text-white/70">Account Number</Label>
<Input
@@ -434,7 +446,7 @@
<Button
type="button"
onclick={createAccount}
disabled={busyAction === 'create-account' || !name || !turnstileToken}
disabled={busyAction === 'create-account' || !name}
class="w-full disabled:opacity-50"
>
{#if busyAction === 'create-account'}

View File

@@ -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}
>
<Pomodoro></Pomodoro>
</Window>
</Window>

View File

@@ -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.',
});
}
};

View File

@@ -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';

View File

@@ -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<Song[]> {
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);
}

View File

@@ -1,4 +1,5 @@
export interface Song {
fileId: string;
artists: string;
title: string;
endpoint: string;

View File

@@ -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 });
};

View File

@@ -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<number>`coalesce(sum(${userStatBucket.value}), 0)` })
.from(userStatBucket)
.where(listenFilter);
const [todayListen] = await db
.select({ seconds: sql<number>`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<number>`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<number>`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,
});
};