music: Last.fm-style stats with 7d/30d/1y/all ranges and genres
All checks were successful
Deploy production / deploy (push) Successful in 1m14s

- Range picker (?range=7d|30d|1y|all) drives every section; the hours chart
  covers the whole range (daily for 7d/30d, weekly for 1y, monthly for all).
- Genres from Last.fm artist top tags (music.artist_tags, normalised and
  filtered), split by artist listening time, with a per-bucket stacked chart.
- Listening clock, new artists, comparison with the previous period and top
  movers, streaks, milestones, top albums with art, rank changes.
- music.play_facts materialized view (refreshed by the app when plays arrive)
  plus a per-range cache keep the page fast.
- scripts/music-fetch-tags.ts backfills tags, most-played artists first.
This commit is contained in:
2026-10-25 14:03:39 +00:00
parent 5e4cd83748
commit 516bc15545
9 changed files with 27811 additions and 229 deletions

View File

@@ -190,3 +190,28 @@ export const musicImportState = musicSchema.table('import_state', {
data: jsonb('data').notNull(),
updatedAt: timestamptz('updated_at').notNull().defaultNow()
});
/**
* Genres: Last.fm artist.getTopTags per artist, normalised and filtered for junk tags
* (scripts/music-fetch-tags.ts). Weight is Last.fm's tag count (100 = the artist's top tag).
*/
export const musicArtistTags = musicSchema.table(
'artist_tags',
{
artistId: integer('artist_id')
.notNull()
.references(() => musicArtists.id, { onDelete: 'cascade' }),
tag: text('tag').notNull(),
weight: smallint('weight').notNull()
},
(t) => [primaryKey({ columns: [t.artistId, t.tag] }), index('artist_tags_tag_idx').on(t.tag)]
);
/** When tags were last fetched per artist: 'ok', 'no_tags' or 'not_found' (retry later). */
export const musicArtistTagFetch = musicSchema.table('artist_tag_fetch', {
artistId: integer('artist_id')
.primaryKey()
.references(() => musicArtists.id, { onDelete: 'cascade' }),
status: text('status').notNull(),
fetchedAt: timestamptz('fetched_at').notNull().defaultNow()
});