Files
iptvnator/libs/services/src/lib/vod-source-pin.service.ts
T
4grayandClaude Opus 5 76804ee1bc fix(portals): a failed pin read must not export as "no pins"
Three findings, all in code from this session.

Backup called the lenient `listForPlaylist`, which turns a failed read
into `[]`. Since `e0ebbeaf` made restore treat `sourcePins` as
authoritative — clearing the playlist's pins before applying it — an
export whose read failed produced a file that looks complete and wipes
every pin on restore. Losing them is bad; losing them through the one
feature meant to protect them is worse. Backup now uses a strict listing
that throws, so the export fails instead.

The diacritic map stopped at U+024F, which is tidy and leaves Vietnamese
out: `ố` is U+1ED1, the normalizer folds it to `o`, and the scan filtered
those rows out before confirmation. Latin Extended Additional is included
now; the filter decides what belongs, so the range only has to be wide.

And two panels spelling one language differently (`eng` vs `en`, or
`en-US`) raised a dub warning between identical tracks. Tags are
canonicalized before comparison — 639-2 collapses to 639-1, both German
forms meet at `de`, regions drop, and `und` becomes nothing. Anything
that survives longer than three characters is not a language code, so the
comparison is declined rather than guessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:10:53 +02:00

161 lines
5.3 KiB
TypeScript

import { Injectable } from '@angular/core';
import type { VodSourcePin } from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
/**
* Persistence for the per-movie pinned source ("play this film from here").
*
* Electron-only, like the discovery that feeds it: without cross-playlist
* matching there is nothing to pin between. In the PWA every call is a no-op
* and `isAvailable` is false, so the UI hides the affordance rather than
* offering a button that silently forgets.
*/
@Injectable({ providedIn: 'root' })
export class VodSourcePinService {
get isAvailable(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.electron?.dbGetVodSourcePin === 'function'
);
}
/**
* @param matchKeys every key this movie may be stored under, MOST TRUSTED
* FIRST. A film pinned before TMDB enrichment landed is keyed by title and
* prefers a `tmdb:` key afterwards; passing both means the id arriving
* later does not orphan the pin.
*/
async get(matchKeys: string[]): Promise<VodSourcePin | null> {
if (!this.isAvailable || matchKeys.length === 0) {
return null;
}
try {
return await window.electron.dbGetVodSourcePin(matchKeys);
} catch (error) {
console.warn(
'Reading the pinned VOD source failed:',
redactSensitiveData(error)
);
return null;
}
}
/**
* Drop every pin pointing at this playlist, in one statement.
*
* `clear()` caps its key list, so clearing a playlist through it would
* silently leave the surplus behind while reporting success.
*/
async clearForPlaylist(playlistId: string): Promise<boolean> {
if (!this.isAvailable || !playlistId) {
return false;
}
try {
const result =
await window.electron.dbClearVodSourcePinsForPlaylist(
playlistId
);
return result?.success === true;
} catch (error) {
console.warn(
'Clearing pinned VOD sources failed:',
redactSensitiveData(error)
);
return false;
}
}
/**
* Every pin pointing at this playlist, empty when there are none OR when
* the read failed. Only for callers that treat both the same way.
*/
async listForPlaylist(playlistId: string): Promise<VodSourcePin[]> {
try {
return await this.readForPlaylist(playlistId);
} catch (error) {
console.warn(
'Listing pinned VOD sources failed:',
redactSensitiveData(error)
);
return [];
}
}
/**
* As above, but a failed read THROWS instead of reading as "none".
*
* Backup needs this distinction and nothing else does. An export writes
* `sourcePins` as the authoritative set for the playlist, and restore
* clears whatever is there before applying it — so a read that failed and
* returned `[]` produces a backup that looks complete and silently wipes
* every pin the user had. Losing the preferences is bad; losing them via a
* file created to protect them is worse.
*/
async listForPlaylistOrThrow(playlistId: string): Promise<VodSourcePin[]> {
return this.readForPlaylist(playlistId);
}
private async readForPlaylist(playlistId: string): Promise<VodSourcePin[]> {
if (!this.isAvailable || !playlistId) {
return [];
}
return (await window.electron.dbListVodSourcePins(playlistId)) ?? [];
}
/**
* Store the pin, also under `aliasKeys`, retiring `retireKeys` — all in
* the same transaction, because a half-applied change has no honest
* outcome to report.
*
* @param aliasKeys further keys naming exactly this film. A movie's
* identity grows as enrichment lands, and a pin recorded only under the
* richest key is invisible to the next reopen that has not been enriched.
*/
async set(
pin: VodSourcePin,
retireKeys: string[] = [],
aliasKeys: string[] = []
): Promise<boolean> {
if (!this.isAvailable) {
return false;
}
try {
const result = await window.electron.dbSetVodSourcePin(
pin,
retireKeys,
aliasKeys
);
return result?.success === true;
} catch (error) {
console.warn(
'Pinning the VOD source failed:',
redactSensitiveData(error)
);
return false;
}
}
/** Clears the pin under every alias, so unpinning cannot be undone by a
* stale row stored under the other key form. */
async clear(matchKeys: string[]): Promise<boolean> {
if (!this.isAvailable || matchKeys.length === 0) {
return false;
}
try {
const result = await window.electron.dbClearVodSourcePin(matchKeys);
return result?.success === true;
} catch (error) {
console.warn(
'Unpinning the VOD source failed:',
redactSensitiveData(error)
);
return false;
}
}
}