music: self-hosted scrobbler (Spotify poller, Last.fm + streaming-history importers, /trackers/music, homepage listening card)
All checks were successful
Deploy production / deploy (push) Successful in 2m37s
All checks were successful
Deploy production / deploy (push) Successful in 2m37s
- music schema: artists/albums/tracks (+album art), plays with source + dedupe, now_playing, spotify_auth, import_state; plays_deduped view hides Last.fm scrobbles Spotify also has - in-app Spotify poller (60s, leased) started from hooks init; no new role or env needed - /api/spotify/connect + callback (owner only), /trackers/music public stats - scripts: music-poll, music-import-lastfm (resumable), music-import-history - deploy/music-collector: optional host timer with its own role
This commit is contained in:
@@ -3,7 +3,8 @@ import { relations } from './relations';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const client = postgres(DB_URL, { prepare: false });
|
||||
/** Raw postgres.js client (same pool), for hand-written SQL such as the music stats. */
|
||||
export const client = postgres(DB_URL, { prepare: false });
|
||||
|
||||
export const db = drizzle({
|
||||
relations,
|
||||
|
||||
@@ -18,3 +18,4 @@ export * from './tasks';
|
||||
export * from './groceries';
|
||||
export * from './home';
|
||||
export * from './cars';
|
||||
export * from './music';
|
||||
|
||||
180
src/lib/server/db/schema/music.ts
Normal file
180
src/lib/server/db/schema/music.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
primaryKey,
|
||||
smallint,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { musicSchema } from './schemas';
|
||||
import { id, timestamptz } from './helpers';
|
||||
|
||||
// Listening history (a self-hosted Last.fm): every play from Spotify (polled every minute,
|
||||
// see src/lib/server/music/collector.ts) and later YouTube / local files, plus the
|
||||
// Extended Streaming History import (scripts/music-import-history.ts). Public read
|
||||
// (personal_ro gets SELECT) except music.spotify_auth, which holds the refresh token.
|
||||
// Rows without a spotify_id (imports, YouTube, local files) are matched by name.
|
||||
|
||||
export const musicArtists = musicSchema.table(
|
||||
'artists',
|
||||
{
|
||||
id: id(),
|
||||
spotifyId: text('spotify_id'),
|
||||
name: text('name').notNull(),
|
||||
imageUrl: text('image_url'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('artists_spotify_id_key')
|
||||
.on(t.spotifyId)
|
||||
.where(sql`${t.spotifyId} is not null`),
|
||||
index('artists_name_idx').on(sql`lower(${t.name})`)
|
||||
]
|
||||
);
|
||||
|
||||
export const musicAlbums = musicSchema.table(
|
||||
'albums',
|
||||
{
|
||||
id: id(),
|
||||
spotifyId: text('spotify_id'),
|
||||
name: text('name').notNull(),
|
||||
artistId: integer('artist_id').references(() => musicArtists.id, { onDelete: 'set null' }),
|
||||
/** Largest cover (640 px on Spotify) and a small one (64–300 px) for lists. */
|
||||
imageUrl: text('image_url'),
|
||||
imageUrlSmall: text('image_url_small'),
|
||||
releaseDate: text('release_date'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('albums_spotify_id_key')
|
||||
.on(t.spotifyId)
|
||||
.where(sql`${t.spotifyId} is not null`),
|
||||
index('albums_name_idx').on(sql`lower(${t.name})`)
|
||||
]
|
||||
);
|
||||
|
||||
export const musicTracks = musicSchema.table(
|
||||
'tracks',
|
||||
{
|
||||
id: id(),
|
||||
spotifyId: text('spotify_id'),
|
||||
name: text('name').notNull(),
|
||||
albumId: integer('album_id').references(() => musicAlbums.id, { onDelete: 'set null' }),
|
||||
/** Primary (first credited) artist, also in track_artists at position 0. */
|
||||
artistId: integer('artist_id').references(() => musicArtists.id, { onDelete: 'set null' }),
|
||||
durationMs: integer('duration_ms'),
|
||||
explicit: boolean('explicit'),
|
||||
/** normalised "primary artist|title" (src/lib/music/normalize.ts), for cross-source dedupe. */
|
||||
matchKey: text('match_key').notNull(),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('tracks_spotify_id_key')
|
||||
.on(t.spotifyId)
|
||||
.where(sql`${t.spotifyId} is not null`),
|
||||
index('tracks_name_idx').on(sql`lower(${t.name})`),
|
||||
index('tracks_match_key_idx').on(t.matchKey)
|
||||
]
|
||||
);
|
||||
|
||||
export const musicTrackArtists = musicSchema.table(
|
||||
'track_artists',
|
||||
{
|
||||
trackId: integer('track_id')
|
||||
.notNull()
|
||||
.references(() => musicTracks.id, { onDelete: 'cascade' }),
|
||||
artistId: integer('artist_id')
|
||||
.notNull()
|
||||
.references(() => musicArtists.id, { onDelete: 'cascade' }),
|
||||
position: smallint('position').notNull().default(0)
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.trackId, t.artistId] }),
|
||||
index('track_artists_artist_idx').on(t.artistId)
|
||||
]
|
||||
);
|
||||
|
||||
export const musicPlays = musicSchema.table(
|
||||
'plays',
|
||||
{
|
||||
id: id(),
|
||||
trackId: integer('track_id')
|
||||
.notNull()
|
||||
.references(() => musicTracks.id, { onDelete: 'cascade' }),
|
||||
/** Spotify: when the play ended (played_at / export ts). Last.fm: the scrobble time,
|
||||
* usually when the track started. See the music.plays_deduped view. */
|
||||
playedAt: timestamptz('played_at').notNull(),
|
||||
source: text('source').notNull().default('spotify'),
|
||||
/** How the row got here: 'api' (poller), 'export' (Spotify streaming history),
|
||||
* 'import' (Last.fm and other backfills), 'manual'. */
|
||||
origin: text('origin').notNull().default('api'),
|
||||
/** Known for imports; null for API plays (Spotify only reports plays of 30 s or more). */
|
||||
msPlayed: integer('ms_played'),
|
||||
contextType: text('context_type'),
|
||||
contextUri: text('context_uri'),
|
||||
platform: text('platform'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('plays_dedupe_key').on(t.source, t.trackId, t.playedAt),
|
||||
index('plays_played_at_idx').on(t.playedAt),
|
||||
index('plays_source_played_idx').on(t.source, t.playedAt),
|
||||
index('plays_track_played_idx').on(t.trackId, t.playedAt),
|
||||
check('plays_source_check', sql`${t.source} in ('spotify', 'lastfm', 'youtube', 'local')`),
|
||||
check('plays_origin_check', sql`${t.origin} in ('api', 'export', 'import', 'manual')`)
|
||||
]
|
||||
);
|
||||
|
||||
/** One row (id = 1): what is playing right now, refreshed by the poller every minute. */
|
||||
export const musicNowPlaying = musicSchema.table(
|
||||
'now_playing',
|
||||
{
|
||||
id: smallint('id').primaryKey().default(1),
|
||||
trackId: integer('track_id').references(() => musicTracks.id, { onDelete: 'set null' }),
|
||||
isPlaying: boolean('is_playing').notNull().default(false),
|
||||
progressMs: integer('progress_ms'),
|
||||
deviceName: text('device_name'),
|
||||
deviceType: text('device_type'),
|
||||
contextUri: text('context_uri'),
|
||||
updatedAt: timestamptz('updated_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [check('now_playing_single_row', sql`${t.id} = 1`)]
|
||||
);
|
||||
|
||||
/**
|
||||
* One row (id = 1): the owner's Spotify authorization (Authorization Code flow, stored by
|
||||
* /api/spotify/callback) and the poller's state. Never granted to personal_ro.
|
||||
*/
|
||||
export const musicSpotifyAuth = musicSchema.table(
|
||||
'spotify_auth',
|
||||
{
|
||||
id: smallint('id').primaryKey().default(1),
|
||||
spotifyUserId: text('spotify_user_id').notNull(),
|
||||
displayName: text('display_name'),
|
||||
refreshToken: text('refresh_token').notNull(),
|
||||
accessToken: text('access_token'),
|
||||
accessExpiresAt: timestamptz('access_expires_at'),
|
||||
scope: text('scope'),
|
||||
/** recently-played `after` cursor: played_at (ms since epoch) of the newest stored play. */
|
||||
recentCursor: bigint('recent_cursor', { mode: 'number' }),
|
||||
needsReauth: boolean('needs_reauth').notNull().default(false),
|
||||
leaseUntil: timestamptz('lease_until'),
|
||||
lastPollAt: timestamptz('last_poll_at'),
|
||||
lastError: text('last_error'),
|
||||
connectedAt: timestamptz('connected_at').notNull().defaultNow(),
|
||||
updatedAt: timestamptz('updated_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [check('spotify_auth_single_row', sql`${t.id} = 1`)]
|
||||
);
|
||||
|
||||
/** Resumable backfill state, e.g. key 'lastfm:stanrunge' (see scripts/music-import-lastfm.ts). */
|
||||
export const musicImportState = musicSchema.table('import_state', {
|
||||
key: text('key').primaryKey(),
|
||||
data: jsonb('data').notNull(),
|
||||
updatedAt: timestamptz('updated_at').notNull().defaultNow()
|
||||
});
|
||||
@@ -18,3 +18,4 @@ export const tasksSchema = pgSchema('tasks');
|
||||
export const groceriesSchema = pgSchema('groceries');
|
||||
export const homeSchema = pgSchema('home');
|
||||
export const carsSchema = pgSchema('cars');
|
||||
export const musicSchema = pgSchema('music');
|
||||
|
||||
Reference in New Issue
Block a user