From df2006509d3fa945e9b3bc3da4006ec5e64c97a1 Mon Sep 17 00:00:00 2001 From: Fredrik Liljegren Date: Tue, 24 Feb 2026 20:44:34 +0100 Subject: [PATCH] Add Last.fm auto-sync and fix auto-tagging - Auto-sync: scrobbles are fetched automatically before tag/daily-summary queries if last sync is >30 minutes old (same pattern as Oura/RescueTime) - Retroactive tagging: new rules are applied to all existing scrobbles - Rule cleanup: deleting a rule also removes its auto-generated tags - Full re-tag: new POST /lastfm/retag endpoint and retag_lastfm_scrobbles MCP tool to delete all auto-tags and reapply all rules from scratch - New DB functions: hardDeleteTagsBySource, hardDeleteTagsByExternalIdPrefix, getAllScrobbles with integration tests - Updated api-spec with new response schemas Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- apps/backend/src/api.ts | 1 + apps/backend/src/db/index.ts | 4 +- apps/backend/src/db/raw-records.ts | 22 ++ apps/backend/src/db/tags.integration.test.ts | 89 ++++++++ apps/backend/src/db/tags.ts | 18 ++ apps/backend/src/lastfm-router.ts | 46 +++- apps/backend/src/lastfm-sync.test.ts | 211 +++++++++++++++++- apps/backend/src/lastfm-sync.ts | 61 +++++ apps/backend/src/mcp/lastfm-tools.ts | 27 ++- .../backend/src/services/correlations.test.ts | 1 + apps/backend/src/services/queries.ts | 9 +- apps/backend/src/services/sync-provider.ts | 26 +++ docs/lastfm.md | 21 +- packages/api-spec/src/schemas/sync.ts | 38 +++- 14 files changed, 555 insertions(+), 19 deletions(-) diff --git a/apps/backend/src/api.ts b/apps/backend/src/api.ts index df8e84c..7bac5de 100644 --- a/apps/backend/src/api.ts +++ b/apps/backend/src/api.ts @@ -86,6 +86,7 @@ const main = async () => { // Create sync provider for auto-syncing data before queries const syncProvider = createSyncProvider({ + getLastFmApiKey: () => centralDb.getLastFmApiKey(), oura, }) diff --git a/apps/backend/src/db/index.ts b/apps/backend/src/db/index.ts index 8419799..ba0d9b2 100644 --- a/apps/backend/src/db/index.ts +++ b/apps/backend/src/db/index.ts @@ -53,7 +53,7 @@ export { } from './connection' // Raw records -export { getScrobbles, insertRawRecord, type ScrobbleRecord } from './raw-records' +export { getAllScrobbles, getScrobbles, insertRawRecord, type ScrobbleRecord } from './raw-records' // Time series export { @@ -109,6 +109,8 @@ export { getTagById, getTags, getUniqueTags, + hardDeleteTagsByExternalIdPrefix, + hardDeleteTagsBySource, insertTag, isProgrammaticTag, restoreTag, diff --git a/apps/backend/src/db/raw-records.ts b/apps/backend/src/db/raw-records.ts index fab445e..0efbdc6 100644 --- a/apps/backend/src/db/raw-records.ts +++ b/apps/backend/src/db/raw-records.ts @@ -23,6 +23,28 @@ export interface ScrobbleRecord { album: string } +/** + * Query all Last.fm scrobbles from raw_records (no time range bounds). + * Used for re-tagging all scrobbles when rules change. + */ +export const getAllScrobbles = async (user: string): Promise => { + const result = await query( + user, + `SELECT recorded_at, data + FROM raw_records + WHERE source = 'lastfm' AND record_type = 'scrobble' + ORDER BY recorded_at ASC`, + [], + ) + + return result.rows.map((row) => ({ + album: (row.data.album as string) ?? '', + artist: (row.data.artist as string) ?? '', + recorded_at: row.recorded_at as Date, + track: (row.data.track as string) ?? '', + })) +} + /** * Query Last.fm scrobbles from raw_records within a time range. */ diff --git a/apps/backend/src/db/tags.integration.test.ts b/apps/backend/src/db/tags.integration.test.ts index 9122829..3b180d7 100644 --- a/apps/backend/src/db/tags.integration.test.ts +++ b/apps/backend/src/db/tags.integration.test.ts @@ -9,6 +9,8 @@ import { getProgrammaticTags, getTags, getUniqueTags, + hardDeleteTagsByExternalIdPrefix, + hardDeleteTagsBySource, insertTag, isProgrammaticTag, updateTagEndTime, @@ -635,4 +637,91 @@ describe('Tags Integration Tests', () => { expect(uuidEntries).toHaveLength(1) }) }) + + describe('hardDeleteTagsBySource', () => { + test('deletes all tags with the given source including soft-deleted', async () => { + const user = getTestUser() + + await insertTag(user, { + external_id: 'lastfm-auto-rule1-1000', + source: 'lastfm-auto', + start_time: new Date('2024-01-15T10:00:00Z'), + tag: 'Guitar', + }) + await insertTag(user, { + external_id: 'lastfm-auto-rule1-2000', + source: 'lastfm-auto', + start_time: new Date('2024-01-15T11:00:00Z'), + tag: 'Guitar', + }) + // Soft-delete one + await deleteTag(user, 'lastfm-auto-rule1-2000') + + // Tag from a different source (should survive) + await insertTag(user, { + external_id: 'manual-tag-1', + source: 'manual', + start_time: new Date('2024-01-15T12:00:00Z'), + tag: 'coffee', + }) + + const deleted = await hardDeleteTagsBySource(user, 'lastfm-auto') + expect(deleted).toBe(2) + + const tags = await getTags(user, new Date('2024-01-15T00:00:00Z'), new Date('2024-01-15T23:59:59Z')) + expect(tags).toHaveLength(1) + expect(tags[0].tag).toBe('coffee') + }) + + test('returns 0 when no tags match', async () => { + const user = getTestUser() + + const deleted = await hardDeleteTagsBySource(user, 'nonexistent-source') + expect(deleted).toBe(0) + }) + }) + + describe('hardDeleteTagsByExternalIdPrefix', () => { + test('deletes tags matching the external_id prefix', async () => { + const user = getTestUser() + const ruleId = 'abc-123' + + await insertTag(user, { + external_id: `lastfm-auto-${ruleId}-1000`, + source: 'lastfm-auto', + start_time: new Date('2024-01-15T10:00:00Z'), + tag: 'Guitar', + }) + await insertTag(user, { + external_id: `lastfm-session-${ruleId}-2000`, + source: 'lastfm-auto', + start_time: new Date('2024-01-15T11:00:00Z'), + tag: 'Guitar', + }) + // Tag from a different rule (should survive) + await insertTag(user, { + external_id: 'lastfm-auto-other-rule-3000', + source: 'lastfm-auto', + start_time: new Date('2024-01-15T12:00:00Z'), + tag: 'Drums', + }) + + const deleted1 = await hardDeleteTagsByExternalIdPrefix(user, `lastfm-auto-${ruleId}-`) + expect(deleted1).toBe(1) + + const deleted2 = await hardDeleteTagsByExternalIdPrefix(user, `lastfm-session-${ruleId}-`) + expect(deleted2).toBe(1) + + const tags = await getTags(user, new Date('2024-01-15T00:00:00Z'), new Date('2024-01-15T23:59:59Z')) + expect(tags).toHaveLength(1) + expect(tags[0].tag).toBe('Drums') + }) + + test('returns 0 when no tags match', async () => { + const user = getTestUser() + + const deleted = await hardDeleteTagsByExternalIdPrefix(user, 'nonexistent-prefix-') + expect(deleted).toBe(0) + }) + }) }) diff --git a/apps/backend/src/db/tags.ts b/apps/backend/src/db/tags.ts index 1546e7e..6b6bc63 100644 --- a/apps/backend/src/db/tags.ts +++ b/apps/backend/src/db/tags.ts @@ -139,6 +139,24 @@ export const updateTagNameByKey = async (user: string, tagKey: string, newName: return result.rowCount ?? 0 } +/** + * Hard-delete all tags with a given source (including soft-deleted ones). + * Used for full re-tagging of auto-generated tags. + */ +export const hardDeleteTagsBySource = async (user: string, source: string): Promise => { + const result = await query(user, `DELETE FROM tags WHERE source = $1`, [source]) + return result.rowCount ?? 0 +} + +/** + * Hard-delete all tags whose external_id starts with the given prefix. + * Used to clean up tags for a specific rule when it's deleted. + */ +export const hardDeleteTagsByExternalIdPrefix = async (user: string, prefix: string): Promise => { + const result = await query(user, `DELETE FROM tags WHERE external_id LIKE $1`, [prefix + '%']) + return result.rowCount ?? 0 +} + /** * Get unique tags from the database (all stored tag names). */ diff --git a/apps/backend/src/lastfm-router.ts b/apps/backend/src/lastfm-router.ts index 4ef2ad1..071b74d 100644 --- a/apps/backend/src/lastfm-router.ts +++ b/apps/backend/src/lastfm-router.ts @@ -8,9 +8,10 @@ import { addLastFmTagRuleBodySchema, type AddLastFmTagRuleBody, type AddLastFmTagRuleResponse, + type DeleteLastFmTagRuleResponse, type LastFmTagRulesResponse, + type RetagLastFmResponse, type ScrobblesResponse, - type SyncResponse, } from '@aurboda/api-spec' import { RequestHandler, Router } from 'express' import type { ParamsDictionary } from 'express-serve-static-core' @@ -22,6 +23,7 @@ import { type LastFmMatchMode, type LastFmMatchType, } from './db' +import { applyRuleRetroactively, cleanupRuleTags, retagAllScrobbles } from './lastfm-sync' import { validateBody } from './validation' /** @@ -123,6 +125,9 @@ export const createLastFmRouter = (authMiddleware: RequestHandler): Router => { track_name, }) + // Apply the new rule retroactively to all existing scrobbles + const tagsApplied = await applyRuleRetroactively(user, rule) + res.json({ data: { artist_name: rule.artist_name, @@ -134,6 +139,7 @@ export const createLastFmRouter = (authMiddleware: RequestHandler): Router => { merge_gap_seconds: rule.merge_gap_seconds ?? null, rule_name: rule.rule_name, tag_name: rule.tag_name, + tags_applied: tagsApplied, track_name: rule.track_name, }, success: true, @@ -151,17 +157,39 @@ export const createLastFmRouter = (authMiddleware: RequestHandler): Router => { }, ) - // DELETE /lastfm/tag-rules/:id - Delete a tag rule - router.delete<{ id: string }, SyncResponse>('/tag-rules/:id', authMiddleware, async (req, res) => { + // DELETE /lastfm/tag-rules/:id - Delete a tag rule and its auto-generated tags + router.delete<{ id: string }, DeleteLastFmTagRuleResponse>( + '/tag-rules/:id', + authMiddleware, + async (req, res) => { + const user = req.user! + const { id } = req.params + + try { + const tagsRemoved = await cleanupRuleTags(user, id) + const deleted = await deleteLastFmTagRule(user, id) + if (!deleted) { + return res.status(404).json({ error: 'Rule not found', success: false }) + } + res.json({ success: true, tags_removed: tagsRemoved }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + res.status(500).json({ error: message, success: false }) + } + }, + ) + + // POST /lastfm/retag - Delete all auto-tags and reapply all rules from scratch + router.post('/retag', authMiddleware, async (req, res) => { const user = req.user! - const { id } = req.params try { - const deleted = await deleteLastFmTagRule(user, id) - if (!deleted) { - return res.status(404).json({ error: 'Rule not found', success: false }) - } - res.json({ success: true }) + const result = await retagAllScrobbles(user) + res.json({ + success: true, + tags_created: result.tags_created, + tags_deleted: result.tags_deleted, + }) } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' res.status(500).json({ error: message, success: false }) diff --git a/apps/backend/src/lastfm-sync.test.ts b/apps/backend/src/lastfm-sync.test.ts index 47549e0..3afca66 100644 --- a/apps/backend/src/lastfm-sync.test.ts +++ b/apps/backend/src/lastfm-sync.test.ts @@ -3,15 +3,26 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { LastFmTagRule } from './db' +import type { LastFmTagRule, ScrobbleRecord } from './db' import type { Scrobble } from './lastfm' -import { applyTagRules, matchesRule, syncLastFmData } from './lastfm-sync' +import { + applyRuleRetroactively, + applyTagRules, + cleanupRuleTags, + matchesRule, + retagAllScrobbles, + scrobbleRecordToScrobble, + syncLastFmData, +} from './lastfm-sync' // Mock the db module vi.mock('./db', () => ({ findMergeableTag: vi.fn(), + getAllScrobbles: vi.fn(), getLastFmTagRules: vi.fn(), getSyncState: vi.fn(), + hardDeleteTagsByExternalIdPrefix: vi.fn(), + hardDeleteTagsBySource: vi.fn(), insertRawRecord: vi.fn(), insertTag: vi.fn(), updateTagEndTime: vi.fn(), @@ -27,8 +38,11 @@ vi.mock('./lastfm', () => ({ import { findMergeableTag, + getAllScrobbles, getLastFmTagRules, getSyncState, + hardDeleteTagsByExternalIdPrefix, + hardDeleteTagsBySource, insertRawRecord, insertTag, updateTagEndTime, @@ -633,3 +647,196 @@ describe('syncLastFmData', () => { expect(mockClient.getRecentTracks).toHaveBeenCalledWith('lastfm-username', startDate, expect.any(Date)) }) }) + +describe('scrobbleRecordToScrobble', () => { + it('converts ScrobbleRecord to Scrobble', () => { + const record: ScrobbleRecord = { + album: 'Test Album', + artist: 'Test Artist', + recorded_at: new Date('2024-01-01T10:00:00Z'), + track: 'Test Track', + } + + const result = scrobbleRecordToScrobble(record) + + expect(result).toEqual({ + album: 'Test Album', + artist: 'Test Artist', + timestamp: new Date('2024-01-01T10:00:00Z'), + track: 'Test Track', + }) + }) + + it('converts empty album string to undefined', () => { + const record: ScrobbleRecord = { + album: '', + artist: 'Test Artist', + recorded_at: new Date('2024-01-01T10:00:00Z'), + track: 'Test Track', + } + + const result = scrobbleRecordToScrobble(record) + + expect(result.album).toBeUndefined() + }) +}) + +describe('applyRuleRetroactively', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('applies rule to all existing scrobbles', async () => { + const records: ScrobbleRecord[] = [ + { + album: 'Album 1', + artist: 'Match Artist', + recorded_at: new Date('2024-01-01T10:00:00Z'), + track: 'Song 1', + }, + { + album: 'Album 2', + artist: 'Other Artist', + recorded_at: new Date('2024-01-01T11:00:00Z'), + track: 'Song 2', + }, + ] + vi.mocked(getAllScrobbles).mockResolvedValue(records) + + const rule: LastFmTagRule = { + artist_name: 'Match Artist', + created_at: new Date(), + id: 'rule-1', + match_mode: 'exact', + match_type: 'artist', + rule_name: 'Test Rule', + tag_name: 'TestTag', + } + + const tagsCreated = await applyRuleRetroactively('testuser', rule) + + expect(tagsCreated).toBe(1) + expect(getAllScrobbles).toHaveBeenCalledWith('testuser') + expect(insertTag).toHaveBeenCalledTimes(1) + }) + + it('returns 0 when no scrobbles exist', async () => { + vi.mocked(getAllScrobbles).mockResolvedValue([]) + + const rule: LastFmTagRule = { + artist_name: 'Any Artist', + created_at: new Date(), + id: 'rule-1', + match_mode: 'exact', + match_type: 'artist', + rule_name: 'Test Rule', + tag_name: 'TestTag', + } + + const tagsCreated = await applyRuleRetroactively('testuser', rule) + + expect(tagsCreated).toBe(0) + expect(insertTag).not.toHaveBeenCalled() + }) +}) + +describe('cleanupRuleTags', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('deletes point-in-time and session tags for the rule', async () => { + vi.mocked(hardDeleteTagsByExternalIdPrefix).mockResolvedValueOnce(3).mockResolvedValueOnce(2) + + const deleted = await cleanupRuleTags('testuser', 'rule-abc') + + expect(deleted).toBe(5) + expect(hardDeleteTagsByExternalIdPrefix).toHaveBeenCalledWith('testuser', 'lastfm-auto-rule-abc-') + expect(hardDeleteTagsByExternalIdPrefix).toHaveBeenCalledWith('testuser', 'lastfm-session-rule-abc-') + }) + + it('returns 0 when no tags match', async () => { + vi.mocked(hardDeleteTagsByExternalIdPrefix).mockResolvedValue(0) + + const deleted = await cleanupRuleTags('testuser', 'nonexistent') + + expect(deleted).toBe(0) + }) +}) + +describe('retagAllScrobbles', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('deletes all auto-tags and reapplies rules', async () => { + vi.mocked(hardDeleteTagsBySource).mockResolvedValue(10) + vi.mocked(getAllScrobbles).mockResolvedValue([ + { + album: 'Album', + artist: 'Match Artist', + recorded_at: new Date('2024-01-01T10:00:00Z'), + track: 'Song 1', + }, + ]) + vi.mocked(getLastFmTagRules).mockResolvedValue([ + { + artist_name: 'Match Artist', + created_at: new Date(), + id: 'rule-1', + match_mode: 'exact', + match_type: 'artist', + rule_name: 'Test Rule', + tag_name: 'TestTag', + }, + ]) + + const result = await retagAllScrobbles('testuser') + + expect(result.tags_deleted).toBe(10) + expect(result.tags_created).toBe(1) + expect(hardDeleteTagsBySource).toHaveBeenCalledWith('testuser', 'lastfm-auto') + expect(insertTag).toHaveBeenCalledTimes(1) + }) + + it('returns early when no scrobbles exist', async () => { + vi.mocked(hardDeleteTagsBySource).mockResolvedValue(5) + vi.mocked(getAllScrobbles).mockResolvedValue([]) + vi.mocked(getLastFmTagRules).mockResolvedValue([ + { + artist_name: 'Any', + created_at: new Date(), + id: 'rule-1', + match_mode: 'exact', + match_type: 'artist', + rule_name: 'Rule', + tag_name: 'Tag', + }, + ]) + + const result = await retagAllScrobbles('testuser') + + expect(result.tags_deleted).toBe(5) + expect(result.tags_created).toBe(0) + expect(insertTag).not.toHaveBeenCalled() + }) + + it('returns early when no rules exist', async () => { + vi.mocked(hardDeleteTagsBySource).mockResolvedValue(5) + vi.mocked(getAllScrobbles).mockResolvedValue([ + { + album: '', + artist: 'Artist', + recorded_at: new Date('2024-01-01T10:00:00Z'), + track: 'Song', + }, + ]) + vi.mocked(getLastFmTagRules).mockResolvedValue([]) + + const result = await retagAllScrobbles('testuser') + + expect(result.tags_deleted).toBe(5) + expect(result.tags_created).toBe(0) + expect(insertTag).not.toHaveBeenCalled() + }) +}) diff --git a/apps/backend/src/lastfm-sync.ts b/apps/backend/src/lastfm-sync.ts index 8c28765..9263e2d 100644 --- a/apps/backend/src/lastfm-sync.ts +++ b/apps/backend/src/lastfm-sync.ts @@ -8,11 +8,15 @@ import { subDays } from 'date-fns' import { findMergeableTag, + getAllScrobbles, getLastFmTagRules, getSyncState, + hardDeleteTagsByExternalIdPrefix, + hardDeleteTagsBySource, insertRawRecord, insertTag, type LastFmTagRule, + type ScrobbleRecord, updateTagEndTime, upsertSyncState, } from './db' @@ -203,6 +207,63 @@ export const applyTagRules = async ( return pointTags + sessionTags } +/** Result of a re-tag operation */ +export interface LastFmRetagResult { + tags_deleted: number + tags_created: number +} + +/** + * Convert a ScrobbleRecord (from raw_records DB) to a Scrobble (for rule matching). + */ +export const scrobbleRecordToScrobble = (record: ScrobbleRecord): Scrobble => ({ + album: record.album || undefined, + artist: record.artist, + timestamp: record.recorded_at, + track: record.track, +}) + +/** + * Apply a single rule retroactively to all existing scrobbles in raw_records. + * Called after a new rule is created. + */ +export const applyRuleRetroactively = async (user: string, rule: LastFmTagRule): Promise => { + const records = await getAllScrobbles(user) + if (records.length === 0) return 0 + + const scrobbles = records.map(scrobbleRecordToScrobble) + return applyTagRules(user, scrobbles, [rule]) +} + +/** + * Hard-delete all tags generated by a specific rule. + * Called before a rule is deleted. + */ +export const cleanupRuleTags = async (user: string, ruleId: string): Promise => { + const pointDeleted = await hardDeleteTagsByExternalIdPrefix(user, `lastfm-auto-${ruleId}-`) + const sessionDeleted = await hardDeleteTagsByExternalIdPrefix(user, `lastfm-session-${ruleId}-`) + return pointDeleted + sessionDeleted +} + +/** + * Delete all auto-generated Last.fm tags and reapply all rules from scratch. + * Use when rules have changed and tags need a full refresh. + */ +export const retagAllScrobbles = async (user: string): Promise => { + const tagsDeleted = await hardDeleteTagsBySource(user, 'lastfm-auto') + + const [records, rules] = await Promise.all([getAllScrobbles(user), getLastFmTagRules(user)]) + + if (records.length === 0 || rules.length === 0) { + return { tags_created: 0, tags_deleted: tagsDeleted } + } + + const scrobbles = records.map(scrobbleRecordToScrobble) + const tagsCreated = await applyTagRules(user, scrobbles, rules) + + return { tags_created: tagsCreated, tags_deleted: tagsDeleted } +} + /** * Sync Last.fm scrobbles for a user. */ diff --git a/apps/backend/src/mcp/lastfm-tools.ts b/apps/backend/src/mcp/lastfm-tools.ts index 0f2a238..676910f 100644 --- a/apps/backend/src/mcp/lastfm-tools.ts +++ b/apps/backend/src/mcp/lastfm-tools.ts @@ -11,6 +11,7 @@ import { type LastFmMatchMode, type LastFmMatchType, } from '../db' +import { applyRuleRetroactively, cleanupRuleTags, retagAllScrobbles } from '../lastfm-sync' import { errorResponse, jsonResponse, type McpServer } from './helpers' export const registerLastFmTools = (server: McpServer, user: string) => { @@ -49,7 +50,7 @@ export const registerLastFmTools = (server: McpServer, user: string) => { // Tool: add_lastfm_tag_rule server.tool( 'add_lastfm_tag_rule', - 'Add a Last.fm auto-tagging rule. Creates tags when scrobbles match the specified criteria.', + 'Add a Last.fm auto-tagging rule. Creates tags when scrobbles match the specified criteria. The rule is applied retroactively to all existing scrobbles.', { ...addLastFmTagRuleBodySchema.shape }, async ({ artist_name, @@ -81,10 +82,14 @@ export const registerLastFmTools = (server: McpServer, user: string) => { track_name, }) + // Apply the new rule retroactively to all existing scrobbles + const tagsApplied = await applyRuleRetroactively(user, rule) + return jsonResponse({ data: { ...rule, created_at: rule.created_at.toISOString(), + tags_applied: tagsApplied, }, success: true, }) @@ -101,16 +106,32 @@ export const registerLastFmTools = (server: McpServer, user: string) => { // Tool: delete_lastfm_tag_rule server.tool( 'delete_lastfm_tag_rule', - 'Delete a Last.fm auto-tagging rule by its ID.', + 'Delete a Last.fm auto-tagging rule by its ID. Also removes all auto-generated tags from this rule.', { rule_id: z.string().uuid().describe('The ID of the rule to delete'), }, async ({ rule_id }) => { + const tagsRemoved = await cleanupRuleTags(user, rule_id) const deleted = await deleteLastFmTagRule(user, rule_id) if (!deleted) { return jsonResponse({ error: 'Rule not found', success: false }) } - return jsonResponse({ success: true }) + return jsonResponse({ success: true, tags_removed: tagsRemoved }) + }, + ) + + // Tool: retag_lastfm_scrobbles + server.tool( + 'retag_lastfm_scrobbles', + 'Delete all auto-generated Last.fm tags and reapply all rules from scratch. Use after changing rules to fix tagging.', + {}, + async () => { + const result = await retagAllScrobbles(user) + return jsonResponse({ + success: true, + tags_created: result.tags_created, + tags_deleted: result.tags_deleted, + }) }, ) } diff --git a/apps/backend/src/services/correlations.test.ts b/apps/backend/src/services/correlations.test.ts index b06f612..42ebb95 100644 --- a/apps/backend/src/services/correlations.test.ts +++ b/apps/backend/src/services/correlations.test.ts @@ -212,6 +212,7 @@ describe('correlations service', () => { const syncProvider = { syncCalendarsIfNeeded: vi.fn().mockResolvedValue(undefined), + syncLastFmIfNeeded: vi.fn().mockResolvedValue(undefined), syncOuraIfNeeded: vi.fn().mockResolvedValue(undefined), syncRescueTimeIfNeeded: vi.fn().mockResolvedValue(undefined), } diff --git a/apps/backend/src/services/queries.ts b/apps/backend/src/services/queries.ts index a9977f1..b54f948 100644 --- a/apps/backend/src/services/queries.ts +++ b/apps/backend/src/services/queries.ts @@ -49,6 +49,8 @@ export interface SyncProvider { syncRescueTimeIfNeeded: (user: string) => Promise /** Sync calendar data if stale */ syncCalendarsIfNeeded: (user: string) => Promise + /** Sync Last.fm scrobbles if stale */ + syncLastFmIfNeeded: (user: string) => Promise } export interface MetricDataPoint { @@ -486,6 +488,7 @@ export async function getDailySummary( sync.syncOuraIfNeeded(user, 'sessions'), sync.syncRescueTimeIfNeeded(user), sync.syncCalendarsIfNeeded(user), + sync.syncLastFmIfNeeded(user), ]) } @@ -820,7 +823,11 @@ export async function queryTags( sync?: SyncProvider, ): Promise { if (sync) { - await Promise.all([sync.syncOuraIfNeeded(user, 'tags'), sync.syncCalendarsIfNeeded(user)]) + await Promise.all([ + sync.syncOuraIfNeeded(user, 'tags'), + sync.syncCalendarsIfNeeded(user), + sync.syncLastFmIfNeeded(user), + ]) } const tags = await getTags(user, start, end) diff --git a/apps/backend/src/services/sync-provider.ts b/apps/backend/src/services/sync-provider.ts index 2594ab3..541104f 100644 --- a/apps/backend/src/services/sync-provider.ts +++ b/apps/backend/src/services/sync-provider.ts @@ -8,6 +8,7 @@ import { isBefore, subMinutes } from 'date-fns' import { getSyncState } from '../db' import { syncAllCalendars } from '../ical-sync' +import { syncLastFmData } from '../lastfm-sync' import { ouraClient } from '../oura' import { isRateLimited as isOuraRateLimited, OuraDataType, syncOuraDataType } from '../oura-sync' import { @@ -24,6 +25,8 @@ const DEFAULT_SYNC_THRESHOLD_MINUTES = 30 type OuraClientType = ReturnType export interface SyncProviderConfig { + /** Callback to get the Last.fm API key (optional - if not provided, Last.fm sync is disabled) */ + getLastFmApiKey?: () => Promise /** Oura API client (optional - if not provided, Oura sync is disabled) */ oura?: OuraClientType /** Sync threshold in minutes (default: 30) */ @@ -58,6 +61,29 @@ export function createSyncProvider(config: SyncProviderConfig): SyncProvider { } }, + syncLastFmIfNeeded: async (user: string): Promise => { + if (!config.getLastFmApiKey) return + + try { + const settings = await getSettings(user) + if (!settings.lastfm_username) return + + const apiKey = await config.getLastFmApiKey() + if (!apiKey) return + + const syncState = await getSyncState(user, 'lastfm', 'scrobbles') + const thresholdTime = subMinutes(new Date(), threshold) + if (syncState?.last_sync_time && isBefore(thresholdTime, syncState.last_sync_time)) { + return + } + + console.log('Auto-syncing Last.fm scrobbles...') + await syncLastFmData(user, apiKey, settings.lastfm_username) + } catch (error) { + console.error('Failed to auto-sync Last.fm:', error) + } + }, + syncOuraIfNeeded: async (user: string, dataType: 'tags' | 'sessions'): Promise => { if (!config.oura) return diff --git a/docs/lastfm.md b/docs/lastfm.md index 0fb8f27..d71155f 100644 --- a/docs/lastfm.md +++ b/docs/lastfm.md @@ -56,13 +56,26 @@ When `merge_gap_seconds` is set, consecutive matching scrobbles within the speci For example, with a 10-minute merge gap: if you listen to 5 tracks by the same artist over 20 minutes, they become one tag spanning the full session rather than 5 separate tags. +### Retroactive Tagging + +When a new rule is created, it is automatically applied to all existing scrobbles in the database. This means you don't need to re-sync to pick up historical matches. + +When a rule is deleted, all auto-generated tags from that rule are also removed. + +### Re-Tagging + +If rules have been changed and tags are out of sync, you can do a full re-tag to delete all auto-generated Last.fm tags and reapply all rules from scratch: + +- **REST:** `POST /api/lastfm/retag` +- **MCP:** `retag_lastfm_scrobbles()` + ### Managing Rules Rules can be managed in **Settings > Last.fm Tag Rules** or via MCP tools: - `get_lastfm_tag_rules()` -- List all rules -- `add_lastfm_tag_rule(...)` -- Create a rule -- `delete_lastfm_tag_rule(rule_id)` -- Delete a rule +- `add_lastfm_tag_rule(...)` -- Create a rule (also applies retroactively) +- `delete_lastfm_tag_rule(rule_id)` -- Delete a rule (also removes its auto-tags) ## Querying Scrobbles @@ -75,6 +88,10 @@ Returns track name, artist, album, and timestamp for each scrobble in the time r ## Sync +### Auto-Sync + +Last.fm scrobbles are automatically synced when querying tags or the daily summary, if the last sync was more than 30 minutes ago. This ensures scrobble data stays current without manual intervention. + ### Manual Sync - **REST:** `POST /api/sync/lastfm` diff --git a/packages/api-spec/src/schemas/sync.ts b/packages/api-spec/src/schemas/sync.ts index 2e68a7d..039f8de 100644 --- a/packages/api-spec/src/schemas/sync.ts +++ b/packages/api-spec/src/schemas/sync.ts @@ -515,12 +515,48 @@ export type LastFmTagRulesResponse = z.infer +/** + * Delete Last.fm tag rule response. + */ +export const deleteLastFmTagRuleResponseSchema = baseResponseSchema + .extend({ + tags_removed: z + .number() + .int() + .optional() + .meta({ description: 'Number of auto-generated tags removed' }), + }) + .meta({ id: 'DeleteLastFmTagRuleResponse' }) + +export type DeleteLastFmTagRuleResponse = z.infer + +/** + * Last.fm retag response. + */ +export const retagLastFmResponseSchema = baseResponseSchema + .extend({ + tags_created: z.number().int().optional().meta({ description: 'Number of tags created' }), + tags_deleted: z.number().int().optional().meta({ description: 'Number of tags deleted' }), + }) + .meta({ id: 'RetagLastFmResponse' }) + +export type RetagLastFmResponse = z.infer + // ============================================================================ // Last.fm scrobbles query schemas // ============================================================================