Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/backend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ const main = async () => {

// Create sync provider for auto-syncing data before queries
const syncProvider = createSyncProvider({
getLastFmApiKey: () => centralDb.getLastFmApiKey(),
oura,
})

Expand Down
4 changes: 3 additions & 1 deletion apps/backend/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -109,6 +109,8 @@ export {
getTagById,
getTags,
getUniqueTags,
hardDeleteTagsByExternalIdPrefix,
hardDeleteTagsBySource,
insertTag,
isProgrammaticTag,
restoreTag,
Expand Down
22 changes: 22 additions & 0 deletions apps/backend/src/db/raw-records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScrobbleRecord[]> => {
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.
*/
Expand Down
89 changes: 89 additions & 0 deletions apps/backend/src/db/tags.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
getProgrammaticTags,
getTags,
getUniqueTags,
hardDeleteTagsByExternalIdPrefix,
hardDeleteTagsBySource,
insertTag,
isProgrammaticTag,
updateTagEndTime,
Expand Down Expand Up @@ -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)
})
})
})
18 changes: 18 additions & 0 deletions apps/backend/src/db/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> => {
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<number> => {
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).
*/
Expand Down
46 changes: 37 additions & 9 deletions apps/backend/src/lastfm-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -22,6 +23,7 @@ import {
type LastFmMatchMode,
type LastFmMatchType,
} from './db'
import { applyRuleRetroactively, cleanupRuleTags, retagAllScrobbles } from './lastfm-sync'
import { validateBody } from './validation'

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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<ParamsDictionary, RetagLastFmResponse>('/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 })
Expand Down
Loading
Loading