fix: ensure title changes are accounted for in canonical id calculation

This commit is contained in:
Christian Benincasa
2026-03-06 07:46:12 -05:00
parent e6b662f0ef
commit 94fb90c35b
4 changed files with 2414 additions and 17 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,9 @@ export class LocalMediaCanonicalizer implements Canonicalizer<ProgramLike> {
private getShowCanonicalId(show: Show): string {
const hash = crypto.createHash('sha1');
this.updateHashForBaseItem(show, hash);
// FIXME: updateHashForBaseItem already calls updateHashForGrouping for non-terminal
// types, so plot/tagline are hashed twice here. Removing this call is a breaking
// change (all Show canonical IDs will change).
this.updateHashForGrouping(show, hash);
orderBy(show.actors, (a) => a.name).forEach((a) => {
hash.update(a.name);
@@ -73,6 +76,7 @@ export class LocalMediaCanonicalizer implements Canonicalizer<ProgramLike> {
private getSeasonCanonicalId(season: Season): string {
const hash = crypto.createHash('sha1');
this.updateHashForBaseItem(season, hash);
// FIXME: same double-hash bug as getShowCanonicalId — plot/tagline hashed twice.
this.updateHashForGrouping(season, hash);
hash.update(season.index?.toString() ?? '');
orderBy(season.studios, (s) => s.name).forEach((s) => hash.update(s.name));
@@ -100,6 +104,7 @@ export class LocalMediaCanonicalizer implements Canonicalizer<ProgramLike> {
private getMusicArtistCanonicalId(musicArtist: MusicArtist): string {
const hash = crypto.createHash('sha1');
this.updateHashForBaseItem(musicArtist, hash);
// FIXME: same double-hash bug as getShowCanonicalId — plot/tagline hashed twice.
this.updateHashForGrouping(musicArtist, hash);
return hash.digest('hex');
}
@@ -107,6 +112,7 @@ export class LocalMediaCanonicalizer implements Canonicalizer<ProgramLike> {
private getMusicAlbumCanonicalId(musicAlbum: MusicAlbum): string {
const hash = crypto.createHash('sha1');
this.updateHashForBaseItem(musicAlbum, hash);
// FIXME: same double-hash bug as getShowCanonicalId — plot/tagline hashed twice.
this.updateHashForGrouping(musicAlbum, hash);
hash.update(musicAlbum.index?.toString() ?? '');
orderBy(musicAlbum.studios, (s) => s.name).forEach((s) =>
@@ -121,6 +127,10 @@ export class LocalMediaCanonicalizer implements Canonicalizer<ProgramLike> {
private getMusicTrackCanonicalId(musicTrack: MusicTrack): string {
const hash = crypto.createHash('sha1');
this.updateHashForBaseItem(musicTrack, hash);
// FIXME: updateHashForBaseItem already calls updateHashForTerminalProgram for
// terminal types, so all terminal fields (actors, directors, duration, mediaItem,
// etc.) are hashed twice, and year is hashed a third time below. Removing this
// call is a breaking change (all MusicTrack canonical IDs will change).
this.updateHashForTerminalProgram(musicTrack, hash);
hash.update(musicTrack.trackNumber?.toString() ?? '');
hash.update(musicTrack.year?.toString() ?? '');
@@ -129,16 +139,19 @@ export class LocalMediaCanonicalizer implements Canonicalizer<ProgramLike> {
private updateHashForBaseItem(base: ProgramLike, hash: crypto.Hash) {
hash.update(base.externalId);
base.genres?.forEach((g) => {
orderBy(base.genres ?? [], (g) => g.name).forEach((g) => {
hash.update(g.name);
});
base.identifiers.forEach((i) => {
orderBy(
base.identifiers,
(i) => `${i.type}|${i.id}|${i.sourceId ?? ''}`,
).forEach((i) => {
hash.update(`${i.id}|${i.sourceId ?? ''}|${i.type}`);
});
hash.update(base.libraryId);
hash.update(base.mediaSourceId);
hash.update(base.sourceType);
base.tags.forEach((t) => hash.update(t));
orderBy(base.tags).forEach((t) => hash.update(t));
hash.update(base.title);
hash.update(base.type);
if (isTerminalItemType(base)) {
@@ -152,6 +165,11 @@ export class LocalMediaCanonicalizer implements Canonicalizer<ProgramLike> {
base: TerminalProgram,
hash: crypto.Hash,
) {
// FIXME: actors and directors are not prefixed with a type marker before hashing.
// Because hash.update('') is a no-op, an actor with no order/role is
// hash-indistinguishable from a director with the same name. Fix by adding a
// type prefix (e.g. hash.update('actor') / hash.update('director')) — but this
// is a breaking change for any item that has actors or directors.
orderBy(base.actors, (a) => a.name).forEach((a) => {
hash.update(a.name);
hash.update(a.order?.toString() ?? '');

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,7 @@ export class PlexMediaCanonicalizer implements Canonicalizer<PlexMedia> {
private canonicalizePlexMovie(plexMovie: PlexMovie): string {
const hash = crypto.createHash('sha1');
hash.update(plexMovie.key);
hash.update(plexMovie.title);
hash.update(plexMovie.addedAt?.toString() ?? '');
hash.update(plexMovie.updatedAt?.toString() ?? '');
for (const media of plexMovie.Media ?? []) {
@@ -64,6 +65,7 @@ export class PlexMediaCanonicalizer implements Canonicalizer<PlexMedia> {
private canonicalizePlexShow(plexShow: PlexTvShow): string {
const hash = crypto.createHash('sha1');
hash.update(plexShow.key);
hash.update(plexShow.title);
hash.update(plexShow.addedAt?.toString() ?? '');
hash.update(plexShow.updatedAt?.toString() ?? '');
for (const role of plexShow.Role ?? []) {
@@ -84,6 +86,7 @@ export class PlexMediaCanonicalizer implements Canonicalizer<PlexMedia> {
): string {
const hash = crypto.createHash('sha1');
hash.update(plexCollection.key);
hash.update(plexCollection.title);
hash.update(plexCollection.addedAt?.toString() ?? '');
hash.update(plexCollection.updatedAt?.toString() ?? '');
hash.update(plexCollection.childCount?.toFixed() ?? '');
@@ -94,6 +97,7 @@ export class PlexMediaCanonicalizer implements Canonicalizer<PlexMedia> {
private canonicalizePlexSeason(plexSeason: PlexTvSeason): string {
const hash = crypto.createHash('sha1');
hash.update(plexSeason.key);
hash.update(plexSeason.title);
hash.update(plexSeason.addedAt?.toString() ?? '');
hash.update(plexSeason.updatedAt?.toString() ?? '');
@@ -107,6 +111,7 @@ export class PlexMediaCanonicalizer implements Canonicalizer<PlexMedia> {
private canonicalizePlexEpisode(plexEpisode: PlexEpisode): string {
const hash = crypto.createHash('sha1');
hash.update(plexEpisode.key);
hash.update(plexEpisode.title);
hash.update(plexEpisode.addedAt?.toString() ?? '');
hash.update(plexEpisode.updatedAt?.toString() ?? '');
@@ -134,14 +139,20 @@ export class PlexMediaCanonicalizer implements Canonicalizer<PlexMedia> {
}
}
seq.collect(
compact(
flatten([plexEpisode.Director, plexEpisode.Writer, plexEpisode.Role]),
),
(keyVal) => {
hash.update(keyVal.tag);
},
);
for (const director of plexEpisode.Director ?? []) {
hash.update('director');
hash.update(director.tag);
}
for (const writer of plexEpisode.Writer ?? []) {
hash.update('writer');
hash.update(writer.tag);
}
for (const role of plexEpisode.Role ?? []) {
hash.update('role');
hash.update(role.tag);
}
return hash.digest('base64');
}
@@ -149,6 +160,7 @@ export class PlexMediaCanonicalizer implements Canonicalizer<PlexMedia> {
private canonicalizePlexMusicArtist(plexArtist: PlexMusicArtist): string {
const hash = crypto.createHash('sha1');
hash.update(plexArtist.key);
hash.update(plexArtist.title);
hash.update(plexArtist.addedAt?.toString() ?? '');
hash.update(plexArtist.updatedAt?.toString() ?? '');
@@ -169,9 +181,9 @@ export class PlexMediaCanonicalizer implements Canonicalizer<PlexMedia> {
// hash.update(keyVal.tag);
// },
// );
plexArtist.Genre?.sort()
.map((g) => g.tag)
.forEach((genre) => hash.update(genre));
plexArtist.Genre?.toSorted((a, b) => a.tag.localeCompare(b.tag)).forEach(
(g) => hash.update(g.tag),
);
return hash.digest('base64');
}
@@ -179,6 +191,7 @@ export class PlexMediaCanonicalizer implements Canonicalizer<PlexMedia> {
private canonicalizePlexMusicAlbum(plexAlbum: PlexMusicAlbum): string {
const hash = crypto.createHash('sha1');
hash.update(plexAlbum.key);
hash.update(plexAlbum.title);
hash.update(plexAlbum.addedAt?.toString() ?? '');
hash.update(plexAlbum.updatedAt?.toString() ?? '');
hash.update(plexAlbum.year?.toFixed() ?? '');
@@ -191,9 +204,9 @@ export class PlexMediaCanonicalizer implements Canonicalizer<PlexMedia> {
hash.update(plexAlbum.studio);
}
plexAlbum.Genre?.sort()
.map((g) => g.tag)
.forEach((genre) => hash.update(genre));
plexAlbum.Genre?.toSorted((a, b) => a.tag.localeCompare(b.tag)).forEach(
(g) => hash.update(g.tag),
);
return hash.digest('base64');
}
@@ -201,6 +214,7 @@ export class PlexMediaCanonicalizer implements Canonicalizer<PlexMedia> {
private canonicalizePlexTrack(plexMusicTrack: PlexMusicTrack): string {
const hash = crypto.createHash('sha1');
hash.update(plexMusicTrack.key);
hash.update(plexMusicTrack.title);
hash.update(plexMusicTrack.addedAt?.toString() ?? '');
hash.update(plexMusicTrack.updatedAt?.toString() ?? '');
hash.update(plexMusicTrack.duration?.toFixed() ?? '');