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:
@@ -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'")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"`)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user