Unify media_key and item_key — use item_key everywhere

media_key was a lossy shortening of item_key (libraryId:lastSegment) that
introduced a real collision bug: two TV episodes from different series with
the same filename would share the same media_key and each other's tags.

- DB migration converts existing media_tags rows from short format to full
  item_key by joining against media_items; ambiguous/orphaned rows are dropped
- media_tags column renamed media_key → item_key
- Removed itemKeyToMediaKey() from scanner; reconcileAndPrune now passes
  item_key directly to reKeyMediaItem
- DB reader functions (tv, movies, games) now expose item_key on returned
  entities; frontend components use entity.item_key instead of constructing
  the short libraryId:id form
- MixedView now constructs the full mixed_file: item_key format
- Tag API renamed mediaKey param → itemKey throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garret Patti
2026-04-10 18:04:29 -04:00
parent 390ce8fcc6
commit 6f86750a99
20 changed files with 161 additions and 124 deletions

View File

@@ -32,9 +32,9 @@ function initDb(db: Database.Database): void {
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_category ON tags(name, category_id);
CREATE TABLE IF NOT EXISTS media_tags (
media_key TEXT NOT NULL,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (media_key, tag_id)
item_key TEXT NOT NULL,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (item_key, tag_id)
);
CREATE TABLE IF NOT EXISTS libraries (
@@ -101,6 +101,7 @@ function initDb(db: Database.Database): void {
migrateLibrariesType(db)
migrateMediaItemsSchema(db)
migrateMediaItemsFingerprint(db)
migrateMediaTagsToItemKey(db)
seedAppSettings(db)
}
@@ -177,6 +178,56 @@ function migrateMediaItemsFingerprint(db: Database.Database): void {
}
}
function migrateMediaTagsToItemKey(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_tags'")
.get() as { sql: string } | undefined
if (!row || !row.sql.includes('media_key')) return // Already migrated or table doesn't exist
// Create replacement table with item_key column
db.exec(`
CREATE TABLE media_tags_new (
item_key TEXT NOT NULL,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (item_key, tag_id)
)
`)
// Build reverse mapping: short media_key → full item_key
// Uses same logic as the old itemKeyToMediaKey: libraryId + lastSegment
const items = db
.prepare('SELECT item_key FROM media_items')
.all() as { item_key: string }[]
const shortToFull: Record<string, string[]> = {}
for (const { item_key } of items) {
const firstColon = item_key.indexOf(':')
const lastColon = item_key.lastIndexOf(':')
const libraryId = item_key.slice(0, firstColon)
const shortId = item_key.slice(lastColon + 1)
const mediaKey = `${libraryId}:${shortId}`
;(shortToFull[mediaKey] ??= []).push(item_key)
}
const tagRows = db
.prepare('SELECT media_key, tag_id FROM media_tags')
.all() as { media_key: string; tag_id: string }[]
const insert = db.prepare('INSERT OR IGNORE INTO media_tags_new (item_key, tag_id) VALUES (?, ?)')
db.transaction(() => {
for (const { media_key, tag_id } of tagRows) {
const candidates = shortToFull[media_key]
if (!candidates || candidates.length !== 1) continue // orphaned or ambiguous collision
insert.run(candidates[0], tag_id)
}
})()
db.exec(`
DROP TABLE media_tags;
ALTER TABLE media_tags_new RENAME TO media_tags;
`)
}
function migrateLibrariesType(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")

View File

@@ -149,6 +149,7 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
const idPart = row.item_key.split(':game_series:')[1] ?? row.item_key
seriesMap.set(row.item_key, {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart),
coverUrl: meta.coverUrl ?? null,
wideCoverUrl: meta.wideCoverUrl ?? null,
@@ -163,6 +164,7 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
const idPart = row.item_key.split(':game:')[1] ?? row.item_key
const game: Game = {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart),
coverUrl: meta.coverUrl ?? null,
wideCoverUrl: meta.wideCoverUrl ?? null,

View File

@@ -97,6 +97,7 @@ export function moviesFromDb(libraryId: string): Movie[] {
const idPart = row.item_key.split(':movie:')[1] ?? row.item_key
return {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart),
year: row.year ?? null,
plot: row.plot ?? null,

View File

@@ -556,22 +556,6 @@ function detectMoves(
* Tags on deleted items are intentionally left as orphans — harmless and
* recoverable if the file reappears.
*/
/**
* Converts an item_key (used in media_items) to the media_key format used in
* media_tags. The UI constructs media_keys as `${libraryId}:${shortId}` where
* shortId is only the terminal path segment — e.g.:
* "lib1:movie:Inception%20(2010)" → "lib1:Inception%20(2010)"
* "lib1:tv_episode:Show:S1:ep.mkv" → "lib1:ep.mkv"
* "lib1:mixed_file:dir%2Ffile.mp4" → "lib1:dir%2Ffile.mp4"
*/
function itemKeyToMediaKey(itemKey: string): string {
const firstColon = itemKey.indexOf(':')
const lastColon = itemKey.lastIndexOf(':')
const libraryId = itemKey.slice(0, firstColon)
const shortId = itemKey.slice(lastColon + 1)
return `${libraryId}:${shortId}`
}
function reconcileAndPrune(
db: Database.Database,
libraryId: string,
@@ -583,11 +567,8 @@ function reconcileAndPrune(
// Apply moves first (outside transaction so console.log is visible as they happen)
for (const { oldKey, newKey } of moves) {
renameItem.run(newKey, oldKey)
// Convert item_keys to the media_key format actually used in media_tags
const oldMediaKey = itemKeyToMediaKey(oldKey)
const newMediaKey = itemKeyToMediaKey(newKey)
if (oldMediaKey !== newMediaKey) {
reKeyMediaItem(oldMediaKey, newMediaKey)
if (oldKey !== newKey) {
reKeyMediaItem(oldKey, newKey)
}
console.log(`[scanner] fingerprint match: renamed "${oldKey}" → "${newKey}"`)
}

View File

@@ -164,20 +164,20 @@ export function deleteTag(id: string): void {
// ─── Assignments ──────────────────────────────────────────────────────────────
export function addTagToItem(mediaKey: string, tagId: string): void {
export function addTagToItem(itemKey: string, tagId: string): void {
const db = getDb()
const tag = db.prepare('SELECT 1 FROM tags WHERE id = ?').get(tagId)
if (!tag) throw new Error(`Tag not found: ${tagId}`)
// INSERT OR IGNORE handles duplicate gracefully
db.prepare('INSERT OR IGNORE INTO media_tags (media_key, tag_id) VALUES (?, ?)').run(mediaKey, tagId)
db.prepare('INSERT OR IGNORE INTO media_tags (item_key, tag_id) VALUES (?, ?)').run(itemKey, tagId)
}
export function removeTagFromItem(mediaKey: string, tagId: string): void {
export function removeTagFromItem(itemKey: string, tagId: string): void {
const db = getDb()
db.prepare('DELETE FROM media_tags WHERE media_key = ? AND tag_id = ?').run(mediaKey, tagId)
db.prepare('DELETE FROM media_tags WHERE item_key = ? AND tag_id = ?').run(itemKey, tagId)
}
export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categories: TagCategory[] } {
export function getResolvedTagsForItem(itemKey: string): { tags: Tag[]; categories: TagCategory[] } {
const db = getDb()
const tags = db
@@ -185,10 +185,10 @@ export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categor
`SELECT t.id, t.name, t.category_id as categoryId
FROM tags t
JOIN media_tags mt ON mt.tag_id = t.id
WHERE mt.media_key = ?
WHERE mt.item_key = ?
ORDER BY t.name`
)
.all(mediaKey) as Tag[]
.all(itemKey) as Tag[]
const categoryIds = [...new Set(tags.map((t) => t.categoryId))]
const categories: TagCategory[] =
@@ -206,11 +206,11 @@ export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categor
export function getTagAssignmentsForLibrary(libraryId: string): Record<string, string[]> {
const db = getDb()
const rows = db
.prepare('SELECT media_key, tag_id FROM media_tags WHERE media_key LIKE ?')
.all(`${libraryId}:%`) as { media_key: string; tag_id: string }[]
.prepare('SELECT item_key, tag_id FROM media_tags WHERE item_key LIKE ?')
.all(`${libraryId}:%`) as { item_key: string; tag_id: string }[]
const result: Record<string, string[]> = {}
for (const row of rows) {
;(result[row.media_key] ??= []).push(row.tag_id)
;(result[row.item_key] ??= []).push(row.tag_id)
}
return result
}
@@ -220,49 +220,42 @@ export function getTagAssignmentsForLibrary(libraryId: string): Record<string, s
export function getSeriesEpisodeTagMap(libraryId: string): Record<string, string[]> {
const db = getDb()
const prefix = `${libraryId}:tv_episode:`
const episodes = db
.prepare('SELECT item_key FROM media_items WHERE library_id = ? AND item_type = ?')
.all(libraryId, 'tv_episode') as { item_key: string }[]
if (episodes.length === 0) return {}
const tagRows = db
.prepare('SELECT media_key, tag_id FROM media_tags WHERE media_key LIKE ?')
.all(`${libraryId}:%`) as { media_key: string; tag_id: string }[]
const tagsByMediaKey: Record<string, string[]> = {}
for (const row of tagRows) {
;(tagsByMediaKey[row.media_key] ??= []).push(row.tag_id)
}
// Join media_items with media_tags directly on item_key
const rows = db
.prepare(
`SELECT mi.item_key, mt.tag_id
FROM media_items mi
JOIN media_tags mt ON mt.item_key = mi.item_key
WHERE mi.library_id = ? AND mi.item_type = 'tv_episode'`
)
.all(libraryId) as { item_key: string; tag_id: string }[]
const result: Record<string, string[]> = {}
for (const { item_key } of episodes) {
for (const { item_key, tag_id } of rows) {
if (!item_key.startsWith(prefix)) continue
// item_key: "libraryId:tv_episode:seriesId:seasonId:episodeId"
const parts = item_key.split(':')
if (parts.length < 5) continue
const seriesId = parts[2]
const episodeId = parts[parts.length - 1]
const episodeTags = tagsByMediaKey[`${libraryId}:${episodeId}`]
if (!episodeTags) continue
const seriesTags = (result[seriesId] ??= [])
for (const tagId of episodeTags) {
if (!seriesTags.includes(tagId)) seriesTags.push(tagId)
}
if (!seriesTags.includes(tag_id)) seriesTags.push(tag_id)
}
return result
}
export function removeAllAssignmentsForLibrary(libraryId: string): void {
const db = getDb()
db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`)
db.prepare('DELETE FROM media_tags WHERE item_key LIKE ?').run(`${libraryId}:%`)
}
export function removeAllAssignmentsForItem(mediaKey: string): void {
export function removeAllAssignmentsForItem(itemKey: string): void {
const db = getDb()
db.prepare("DELETE FROM media_tags WHERE media_key = ?").run(mediaKey)
db.prepare('DELETE FROM media_tags WHERE item_key = ?').run(itemKey)
}
export function reKeyMediaItem(oldKey: string, newKey: string): void {
getDb()
.prepare('UPDATE media_tags SET media_key = ? WHERE media_key = ?')
.prepare('UPDATE media_tags SET item_key = ? WHERE item_key = ?')
.run(newKey, oldKey)
}

View File

@@ -215,6 +215,7 @@ export function tvSeriesFromDb(libraryId: string): TvSeries[] {
const idPart = row.item_key.split(':tv_series:')[1] ?? row.item_key
return {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart),
year: row.year ?? null,
plot: row.plot ?? null,
@@ -242,6 +243,7 @@ export function tvSeasonsFromDb(libraryId: string, seriesId: string): TvSeason[]
const seasonId = parts[1]?.split(':').slice(1).join(':') ?? row.item_key
return {
id: seasonId,
item_key: row.item_key,
seriesId,
title: row.title ?? seasonId,
seasonNumber: meta.seasonNumber ?? null,
@@ -276,6 +278,7 @@ export function tvEpisodesFromDb(
const episodeId = suffix.split(':').slice(2).join(':')
return {
id: episodeId,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(episodeId),
episodeNumber: meta.episodeNumber ?? null,
seasonNumber: meta.seasonNumber ?? null,