mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
refactor(storage): seams for server-backed playlist storage (#1896)
* docs(storage): server storage design and implementation plans Design for keeping playlists and user data on the self-hosted server (#305), revised against master, plus three implementation plans: storage seams, the web-backend storage API, and the PWA server mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(database): share the playlist row codec Move buildPlaylistRow, parseAppPlaylist and the upsert conflict helpers from the Electron database operations into libs/shared/database (@iptvnator/shared/database/app-playlist) so the self-hosted web backend can write the same rows. The Electron modules re-export them; behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(database): read the data root from IPTVNATOR_DATA_DIR Deployments that keep the database on a mounted volume, such as the self-hosted web backend in Docker, can now set the data root. The E2E override still wins; without either variable the root stays ~/.iptvnator. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(services): add the playlist storage backend resolver PlaylistStorageBackend becomes the single place that decides whether playlists persist through the Electron bridge or IndexedDB, and defines the PlaylistDocumentStore contract a server store will implement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(services): resolve playlist storage through one backend PlaylistsService asks PlaylistStorageBackend for its document store instead of reading window.electron and supportsSqlite directly. Paths that only make sense on the desktop (legacy IndexedDB import, worker deletion with progress, the favorites-channel query) check for the Electron kind explicitly. Behavior is unchanged: the backend still resolves to the Electron bridge or IndexedDB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(xtream): store PWA user data through USER_DATA_STORAGE PwaXtreamDataSource and PwaCollectionStorage read and write favorites, recently viewed items, collection snapshots and playback positions through the new USER_DATA_STORAGE token (default: localStorage) instead of calling localStorage directly, so a deployment can substitute a server-synced storage. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(pwa): document the storage seams Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(database): bracket access in the shared playlist codec libs/shared/database compiles with noPropertyAccessFromIndexSignature, which the Electron backend did not enforce on the moved code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build(electron): alias the shared playlist codec in the worker bundle The database worker is bundled by esbuild with an explicit alias table; without the new entry, @iptvnator/shared/database/app-playlist resolved under the library's index file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(services): read localStorage lazily in USER_DATA_STORAGE When the browser denies storage access, reading localStorage itself throws. The default factory returned the global, so injecting the token failed and PwaXtreamDataSource could not be constructed. The adapter now reads the global inside each call, where the data source already guards its reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(database): describe /data as an example data root The Docker image does not set IPTVNATOR_DATA_DIR yet; that arrives with the server storage backend. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
633fde3e0e
commit
94570bc387
30 files changed
+8271
-450
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
# Server Storage for the Self-hosted PWA — Overview
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-10-08-server-storage-design.md`.
|
||||
Branch base: `origin/master` at `7abf479de` (2026-10-11).
|
||||
|
||||
## Order of work
|
||||
|
||||
Three plans, three pull requests, merged in this order. Each one leaves the
|
||||
app working and fully tested.
|
||||
|
||||
| Plan | PR title | Release note |
|
||||
| --- | --- | --- |
|
||||
| [Phase 1: seams](2026-10-11-server-storage-phase-1-seams.md) | `refactor(storage): seams for server-backed playlist storage` | none, label `no-release-note` (behavior-preserving) |
|
||||
| [Phase 2a: backend](2026-10-11-server-storage-phase-2a-backend.md) | `feat(self-hosted): server storage API in web-backend` | none yet, label `no-release-note`; the PWA does not use it until 2b |
|
||||
| [Phase 2b: PWA](2026-10-11-server-storage-phase-2b-pwa.md) | `feat(self-hosted): keep playlists on the server for every device` | `.changes/self-hosted-server-storage.md`, closes #305 |
|
||||
|
||||
Phase 2a only depends on Phase 1 Tasks 2 and 3 (shared codec, data-dir
|
||||
variable). Phase 2b depends on all of Phase 1 and 2a.
|
||||
|
||||
## Branches and worktrees
|
||||
|
||||
- One worktree per PR under `.claude/worktrees/`, each with its own
|
||||
`pnpm install --frozen-lockfile` (AGENTS.md).
|
||||
- Phase 1 branch: `claude/server-storage` (this worktree, already holds the
|
||||
spec and the plans).
|
||||
- Phase 2a branch: `claude/server-storage-backend`, created from
|
||||
`claude/server-storage` after Phase 1 is committed, rebased onto `master`
|
||||
once Phase 1 merges.
|
||||
- Phase 2b branch: `claude/server-storage-pwa`, created from the 2a branch the
|
||||
same way.
|
||||
|
||||
## Gates for every PR (from AGENTS.md)
|
||||
|
||||
1. Targeted unit tests and lint of each touched project.
|
||||
2. `pnpm run typecheck:ci`.
|
||||
3. Docs updated in the canonical owner (listed per plan).
|
||||
4. Local review gate: commit, then `codex review --base origin/master -c model_reasoning_effort=high`
|
||||
and `greptile review --json` on the same commit, fix, repeat until both are clean (max five rounds).
|
||||
5. Push once, open the PR, report checks, skipped checks with reasons, docs and release-note status.
|
||||
|
||||
## Decisions to keep in mind while implementing
|
||||
|
||||
- Do not change `PlaylistsService`'s public API. All storage selection goes
|
||||
through `PlaylistStorageBackend`.
|
||||
- Never add files to `tools/eslint/max-lines-baseline.mjs`. New production
|
||||
files stay under 300 lines.
|
||||
- Storage errors on the server are logged by error name and route only;
|
||||
Drizzle messages can carry SQL parameters (portal credentials).
|
||||
- The server-mode browser cache uses the key prefix `iptvnator-server:`;
|
||||
never write server data into the legacy `xtream-*` keys.
|
||||
- Translate every new i18n key in all 19 locales; `pnpm run i18n:check`
|
||||
must pass.
|
||||
@@ -103,6 +103,10 @@ async function buildWorker() {
|
||||
__dirname,
|
||||
'../../libs/shared/database/src/lib/path-utils.ts'
|
||||
),
|
||||
'@iptvnator/shared/database/app-playlist': path.join(
|
||||
__dirname,
|
||||
'../../libs/shared/database/src/lib/app-playlist.ts'
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+6
-71
@@ -17,16 +17,18 @@ import { sql, type SQL } from 'drizzle-orm';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
|
||||
export {
|
||||
playlistConflictUpdate,
|
||||
readPayloadServerTimezone,
|
||||
type PlaylistConflictUpdate,
|
||||
} from '@iptvnator/shared/database/app-playlist';
|
||||
|
||||
export interface PlaylistConnectionIdentity {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type PlaylistConflictUpdate = {
|
||||
[K in keyof schema.NewPlaylist]?: schema.NewPlaylist[K] | SQL;
|
||||
};
|
||||
|
||||
const stored = schema.playlists;
|
||||
|
||||
/**
|
||||
@@ -86,70 +88,3 @@ export function serverTimezoneInvalidation(nextServerUrl: string): SQL {
|
||||
ELSE ${stored.payload}
|
||||
END`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `DO UPDATE` half of an app-playlist upsert. A full upsert is built
|
||||
* from a snapshot the caller read earlier; when that snapshot carries no
|
||||
* panel clock while the stored row does — `setPlaylistServerTimezone`
|
||||
* landed in between — the stored clock is carried over as long as the row
|
||||
* still points at the same panel, so a favorites, recent-items or metadata
|
||||
* write cannot hand a clockless payload back to the past. A snapshot that
|
||||
* carries its own clock, or moves the source, wins as is. Nested CASE, not
|
||||
* AND: SQLite may reorder AND terms, and the json_* readers raise on a
|
||||
* malformed payload unless json_valid ran first.
|
||||
*/
|
||||
export function playlistConflictUpdate(
|
||||
row: schema.NewPlaylist,
|
||||
playlist: Record<string, unknown>
|
||||
): PlaylistConflictUpdate {
|
||||
if (readString(playlist.serverTimezone)) {
|
||||
return row;
|
||||
}
|
||||
const excludedPayload = sql.raw(`excluded.${stored.payload.name}`);
|
||||
const payload = sql`CASE
|
||||
WHEN json_valid(${stored.payload})
|
||||
THEN CASE
|
||||
WHEN json_type(${stored.payload}, '$.serverTimezone') = 'text'
|
||||
AND ${stored.serverUrl} IS excluded.${sql.raw(stored.serverUrl.name)}
|
||||
AND ${stored.username} IS excluded.${sql.raw(stored.username.name)}
|
||||
AND ${stored.password} IS excluded.${sql.raw(stored.password.name)}
|
||||
THEN json_set(
|
||||
${excludedPayload},
|
||||
'$.serverTimezone',
|
||||
json_extract(${stored.payload}, '$.serverTimezone')
|
||||
)
|
||||
ELSE ${excludedPayload}
|
||||
END
|
||||
ELSE ${excludedPayload}
|
||||
END`;
|
||||
return { ...row, payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* The clock stored in a raw row's payload, for `DB_GET_PLAYLIST`: the
|
||||
* Xtream store seeds `currentPlaylist` from that read before (or without)
|
||||
* the account-info check that learns the value.
|
||||
*/
|
||||
export function readPayloadServerTimezone(
|
||||
payload: string | null | undefined
|
||||
): string | undefined {
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(payload);
|
||||
return parsed && typeof parsed === 'object'
|
||||
? readString((parsed as Record<string, unknown>).serverTimezone)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
@@ -28,14 +28,20 @@ import {
|
||||
readPayloadServerTimezone,
|
||||
serverTimezoneInvalidation,
|
||||
} from './playlist-server-timezone.operations';
|
||||
import {
|
||||
buildPlaylistRow,
|
||||
getPlaylistItemCount,
|
||||
parseAppPlaylist,
|
||||
parseJsonValue,
|
||||
PLAYLIST_TYPES,
|
||||
type AppPlaylistType,
|
||||
} from '@iptvnator/shared/database/app-playlist';
|
||||
|
||||
const PLAYLIST_TYPES = {
|
||||
XTREAM: 'xtream',
|
||||
STALKER: 'stalker',
|
||||
M3U_FILE: 'm3u-file',
|
||||
M3U_TEXT: 'm3u-text',
|
||||
M3U_URL: 'm3u-url',
|
||||
} as const;
|
||||
// Kept for existing importers (specs, playlist-migration.operations.ts).
|
||||
export {
|
||||
buildPlaylistRow,
|
||||
parseAppPlaylist,
|
||||
} from '@iptvnator/shared/database/app-playlist';
|
||||
|
||||
export interface AppPlaylistUpsertPhaseCapture {
|
||||
captureAsync: <TResult>(
|
||||
@@ -63,56 +69,6 @@ export interface AppPlaylistGetPhaseCapture {
|
||||
) => TResult;
|
||||
}
|
||||
|
||||
type PlaylistType = (typeof PLAYLIST_TYPES)[keyof typeof PLAYLIST_TYPES];
|
||||
const PLAYLIST_TYPE_VALUES = new Set<PlaylistType>(
|
||||
Object.values(PLAYLIST_TYPES)
|
||||
);
|
||||
|
||||
function getStringValue(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function getNumericValue(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse JSON value from DB:', error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function getStringArrayValue(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function getFavoriteChannelMatch(
|
||||
channel: Channel,
|
||||
favoritePositions: ReadonlyMap<string, number>
|
||||
@@ -143,191 +99,6 @@ function getFavoriteChannelMatch(
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferPlaylistType(playlist: Record<string, unknown>): PlaylistType {
|
||||
const explicitType = getStringValue(playlist.type);
|
||||
if (
|
||||
explicitType &&
|
||||
PLAYLIST_TYPE_VALUES.has(explicitType as PlaylistType)
|
||||
) {
|
||||
return explicitType as PlaylistType;
|
||||
}
|
||||
|
||||
if (getStringValue(playlist.serverUrl)) {
|
||||
return PLAYLIST_TYPES.XTREAM;
|
||||
}
|
||||
|
||||
if (getStringValue(playlist.macAddress)) {
|
||||
return PLAYLIST_TYPES.STALKER;
|
||||
}
|
||||
|
||||
if (getStringValue(playlist.filePath)) {
|
||||
return PLAYLIST_TYPES.M3U_FILE;
|
||||
}
|
||||
|
||||
if (getStringValue(playlist.url)) {
|
||||
return PLAYLIST_TYPES.M3U_URL;
|
||||
}
|
||||
|
||||
return PLAYLIST_TYPES.M3U_TEXT;
|
||||
}
|
||||
|
||||
export function buildPlaylistRow(
|
||||
playlist: Record<string, unknown>
|
||||
): schema.NewPlaylist | null {
|
||||
const id = getStringValue(playlist._id) ?? getStringValue(playlist.id);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = inferPlaylistType(playlist);
|
||||
const portalUrl = getStringValue(playlist.portalUrl);
|
||||
const url = getStringValue(playlist.url);
|
||||
const nowIso = new Date().toISOString();
|
||||
const updateDate = getNumericValue(playlist.updateDate);
|
||||
|
||||
return {
|
||||
id,
|
||||
name:
|
||||
getStringValue(playlist.title) ??
|
||||
getStringValue(playlist.name) ??
|
||||
id,
|
||||
serverUrl: getStringValue(playlist.serverUrl),
|
||||
username: getStringValue(playlist.username),
|
||||
password: getStringValue(playlist.password),
|
||||
lastUpdated:
|
||||
typeof updateDate === 'number'
|
||||
? new Date(updateDate).toISOString()
|
||||
: getStringValue(playlist.lastUpdated),
|
||||
type,
|
||||
userAgent: getStringValue(playlist.userAgent),
|
||||
origin: getStringValue(playlist.origin),
|
||||
referrer: getStringValue(playlist.referrer),
|
||||
filePath: getStringValue(playlist.filePath),
|
||||
epgUrls:
|
||||
playlist.epgUrls !== undefined
|
||||
? JSON.stringify(getStringArrayValue(playlist.epgUrls))
|
||||
: undefined,
|
||||
detectedEpgUrls:
|
||||
playlist.detectedEpgUrls !== undefined
|
||||
? JSON.stringify(getStringArrayValue(playlist.detectedEpgUrls))
|
||||
: undefined,
|
||||
manualEpgUrls:
|
||||
playlist.manualEpgUrls !== undefined
|
||||
? JSON.stringify(getStringArrayValue(playlist.manualEpgUrls))
|
||||
: undefined,
|
||||
disabledEpgUrls:
|
||||
playlist.disabledEpgUrls !== undefined
|
||||
? JSON.stringify(getStringArrayValue(playlist.disabledEpgUrls))
|
||||
: undefined,
|
||||
autoRefresh: Boolean(playlist.autoRefresh),
|
||||
macAddress: getStringValue(playlist.macAddress),
|
||||
url: type === PLAYLIST_TYPES.STALKER ? (portalUrl ?? url) : url,
|
||||
portalUrl,
|
||||
count: getNumericValue(playlist.count),
|
||||
importDate: getStringValue(playlist.importDate),
|
||||
updateDate,
|
||||
position: getNumericValue(playlist.position),
|
||||
favorites:
|
||||
playlist.favorites !== undefined
|
||||
? JSON.stringify(playlist.favorites)
|
||||
: undefined,
|
||||
recentlyViewed:
|
||||
playlist.recentlyViewed !== undefined
|
||||
? JSON.stringify(playlist.recentlyViewed)
|
||||
: undefined,
|
||||
payload: JSON.stringify(playlist),
|
||||
lastUsage: getStringValue(playlist.lastUsage) ?? nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
function getPlaylistItemCount(
|
||||
playlist: Record<string, unknown>
|
||||
): number | undefined {
|
||||
try {
|
||||
const value = playlist.playlist;
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const items = Reflect.get(value, 'items');
|
||||
return Array.isArray(items) ? items.length : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAppPlaylist(
|
||||
row: schema.Playlist
|
||||
): Record<string, unknown> {
|
||||
const payload = parseJsonValue<Record<string, unknown> | null>(
|
||||
row.payload,
|
||||
null
|
||||
);
|
||||
const base = payload && typeof payload === 'object' ? payload : {};
|
||||
const favorites = parseJsonValue<unknown[]>(row.favorites, []);
|
||||
const recentlyViewed = parseJsonValue<unknown[]>(row.recentlyViewed, []);
|
||||
const epgUrls = getStringArrayValue(
|
||||
row.epgUrls ? parseJsonValue<unknown[]>(row.epgUrls, []) : base.epgUrls
|
||||
);
|
||||
const detectedEpgUrls = getStringArrayValue(
|
||||
row.detectedEpgUrls
|
||||
? parseJsonValue<unknown[]>(row.detectedEpgUrls, [])
|
||||
: (base.detectedEpgUrls ?? epgUrls)
|
||||
);
|
||||
const manualEpgUrls = getStringArrayValue(
|
||||
row.manualEpgUrls
|
||||
? parseJsonValue<unknown[]>(row.manualEpgUrls, [])
|
||||
: base.manualEpgUrls
|
||||
);
|
||||
const disabledEpgUrls = getStringArrayValue(
|
||||
row.disabledEpgUrls
|
||||
? parseJsonValue<unknown[]>(row.disabledEpgUrls, [])
|
||||
: base.disabledEpgUrls
|
||||
);
|
||||
const importDate =
|
||||
row.importDate ?? row.dateCreated ?? new Date().toISOString();
|
||||
const portalUrl =
|
||||
row.portalUrl ?? (row.type === PLAYLIST_TYPES.STALKER ? row.url : null);
|
||||
const updateDate =
|
||||
row.updateDate ??
|
||||
(row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined);
|
||||
|
||||
return {
|
||||
...base,
|
||||
_id: row.id,
|
||||
title:
|
||||
getStringValue(base.title) ?? getStringValue(base.name) ?? row.name,
|
||||
count: row.count ?? getNumericValue(base.count) ?? 0,
|
||||
importDate: getStringValue(base.importDate) ?? importDate,
|
||||
lastUsage:
|
||||
row.lastUsage ??
|
||||
getStringValue(base.lastUsage) ??
|
||||
getStringValue(base.importDate) ??
|
||||
importDate,
|
||||
favorites,
|
||||
recentlyViewed,
|
||||
autoRefresh: row.autoRefresh ?? Boolean(base.autoRefresh),
|
||||
url:
|
||||
row.type === PLAYLIST_TYPES.M3U_URL
|
||||
? (row.url ?? getStringValue(base.url))
|
||||
: getStringValue(base.url),
|
||||
filePath: row.filePath ?? getStringValue(base.filePath),
|
||||
epgUrls,
|
||||
detectedEpgUrls,
|
||||
manualEpgUrls,
|
||||
disabledEpgUrls,
|
||||
userAgent: row.userAgent ?? getStringValue(base.userAgent),
|
||||
referrer: row.referrer ?? getStringValue(base.referrer),
|
||||
origin: row.origin ?? getStringValue(base.origin),
|
||||
updateDate,
|
||||
position: row.position ?? getNumericValue(base.position),
|
||||
serverUrl: row.serverUrl ?? getStringValue(base.serverUrl),
|
||||
username: row.username ?? getStringValue(base.username),
|
||||
password: row.password ?? getStringValue(base.password),
|
||||
macAddress: row.macAddress ?? getStringValue(base.macAddress),
|
||||
portalUrl: portalUrl ?? getStringValue(base.portalUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPlaylist(
|
||||
db: AppDatabase,
|
||||
playlist: {
|
||||
@@ -351,7 +122,7 @@ export async function createPlaylist(
|
||||
url: playlist.url,
|
||||
portalUrl:
|
||||
playlist.type === PLAYLIST_TYPES.STALKER ? playlist.url : undefined,
|
||||
type: playlist.type as PlaylistType,
|
||||
type: playlist.type as AppPlaylistType,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -135,9 +135,10 @@ these limitations explicit in UI, troubleshooting, and release notes:
|
||||
programme-guide entry points, or EPG-fetching flows in browser/PWA mode, and
|
||||
do not use EPG as the readiness signal for Docker.
|
||||
- The PWA does not use the Electron SQLite database or DB worker. Playlist
|
||||
metadata uses `PlaylistsService` with IndexedDB; Xtream favorites, recently
|
||||
viewed items, playback positions, and cached collection snapshots use
|
||||
`PwaXtreamDataSource` browser storage.
|
||||
metadata uses `PlaylistsService` with IndexedDB, which
|
||||
`PlaylistStorageBackend` selects when no SQLite bridge exists; Xtream
|
||||
favorites, recently viewed items, playback positions, and cached collection
|
||||
snapshots use `PwaXtreamDataSource` browser storage.
|
||||
- Browser playlist deletion must go through `PlaylistsService`, not
|
||||
`DatabaseService`. `PlaylistsService.deletePlaylist()` runs registered
|
||||
cleanup hooks such as the PWA Xtream cleanup so localStorage sidecar data does
|
||||
@@ -234,6 +235,11 @@ and sidecar state in localStorage:
|
||||
- `xtream-playlists`
|
||||
- `xtream-playback-positions`
|
||||
|
||||
`PwaXtreamDataSource` and `PwaCollectionStorage` reach these keys through the
|
||||
`USER_DATA_STORAGE` token from `@iptvnator/services` (default:
|
||||
`localStorage`), never through `localStorage` directly, so a deployment can
|
||||
substitute a server-synced storage.
|
||||
|
||||
Entries should include a content snapshot when the item is added. Global
|
||||
collection routes and the dashboard can then restore titles, posters, content
|
||||
type, and category IDs after navigation or a page reload without relying on the
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# Server Storage for the Self-hosted PWA — Design
|
||||
|
||||
Date: 2026-10-08, revised 2026-10-11 against `master` at `7abf479de`.
|
||||
Status: approved direction ("the server is the single source of truth in
|
||||
self-hosted mode"). Implementation plans:
|
||||
|
||||
- `.plans/2026-10-11-server-storage.md` (overview and order)
|
||||
- `.plans/2026-10-11-server-storage-phase-1-seams.md`
|
||||
- `.plans/2026-10-11-server-storage-phase-2a-backend.md`
|
||||
- `.plans/2026-10-11-server-storage-phase-2b-pwa.md`
|
||||
|
||||
Upstream demand: #305 (store playlists on the server for the Docker version,
|
||||
open since 2023), #514 (closed as its duplicate), #1616 (sync watched state
|
||||
and favorites between devices).
|
||||
|
||||
## 1. Summary
|
||||
|
||||
The Docker image runs the browser PWA. All user data lives in the browser:
|
||||
playlists in IndexedDB, Xtream favorites, recently viewed, playback positions
|
||||
and collection snapshots in localStorage. The bundled `web-backend` is a
|
||||
stateless proxy, so every device that opens the NAS address starts empty.
|
||||
|
||||
This design gives `web-backend` an optional server storage: the same SQLite
|
||||
database the Electron app uses (`libs/shared/database`, `initDatabase()`),
|
||||
stored on a Docker volume, plus a small versioned HTTP API. When the instance
|
||||
reports server storage, the PWA keeps playlists and playlist-scoped user data
|
||||
on the server. Adding a playlist stays the ordinary "Add playlist" flow.
|
||||
|
||||
## 2. Goals
|
||||
|
||||
1. A playlist added on one device appears on every device that opens the same
|
||||
self-hosted instance (M3U, Xtream, Stalker).
|
||||
2. M3U and Stalker favorites and recently viewed (stored inside the playlist
|
||||
document) and Xtream favorites, recently viewed, collection snapshots and
|
||||
playback positions follow the playlist, not the browser.
|
||||
3. Browser data moves to the server once, with consent, and is never deleted.
|
||||
4. No behavior change for Electron and for PWA deployments without server
|
||||
storage.
|
||||
5. The HTTP contract mirrors the renderer's existing playlist document store,
|
||||
so a later Electron "connect to server" phase reuses it.
|
||||
|
||||
## 3. Non-goals for this release
|
||||
|
||||
- Server-side EPG (phase 3) and Electron connecting to a server (phase 4).
|
||||
- Syncing device settings (theme, language, player, EPG URLs), parental locks,
|
||||
the global favorites order and UI preferences. They stay per device.
|
||||
- Live push of changes (SSE). Devices read the server on every playlist read
|
||||
and re-read Xtream user data when the tab becomes visible.
|
||||
- User accounts. One optional shared access token.
|
||||
- Credential encryption at rest. Documented limitation.
|
||||
|
||||
## 4. Findings that shaped the design
|
||||
|
||||
| Finding | Consequence |
|
||||
| --- | --- |
|
||||
| `PlaylistsService` persists whole playlist documents through a narrow bridge (`dbGetAppPlaylist(s)`, `dbGetAppPlaylistMetas`, `dbUpsertAppPlaylist(s)`, `dbDeletePlaylist`, `dbDeleteAllPlaylists`, `dbGet/SetAppState`). Every mutation re-reads the document, patches it and writes it back. | The server implements the same document contract over HTTP. The client change is one adapter plus a resolver; `PlaylistsService` keeps its API. |
|
||||
| Electron stores the whole document as JSON in `playlists.payload`, with projected columns for fast metadata reads (`buildPlaylistRow` / `parseAppPlaylist`). | Move that codec into `libs/shared/database` and reuse it on the server. Same row format on desktop and server. |
|
||||
| `initDatabase()` in `libs/shared/database` creates the full schema and runs every migration; the path comes from `path-utils`. | The server calls `initDatabase()`. A new `IPTVNATOR_DATA_DIR` variable in `path-utils` points it at the Docker volume. |
|
||||
| better-sqlite3 13 is a Node-API addon. | One binary loads in Node and Electron. Only the Alpine Docker image needs its own native build stage. |
|
||||
| Read-modify-write of a document carrying a large M3U payload would move megabytes per favorite click over Wi-Fi. | `GET` answers `304` on a matching ETag; an upsert whose payload object is the one the client received sends `keepPayload=1` without the payload. |
|
||||
| Xtream PWA user data is four localStorage maps keyed by playlist id (`xtream-favorites`, `xtream-recent-items`, `xtream-collection-items`, `xtream-playback-positions`); `xtream-playlists` is a derived cache. | Server table `server_user_state(namespace, playlist_id, value)`. The client keeps a write-through cache under its own key prefix and sends only changed playlist entries. |
|
||||
| nginx proxies `/api/` with the default 1 MB body limit. | Raise `client_max_body_size` with the server body limit. |
|
||||
| The hosted PWA build points at an external parser API. | The client probes for server storage only when the deployment writes a runtime `BACKEND_URL` (the Docker entrypoint does). |
|
||||
| Settings sections are grouped (App, Library, Devices, Data) and gated by capability requirements. | New "Server" section in the Data group before Backup, requirement `server-storage`. |
|
||||
| New i18n keys must be translated in all 19 locales (`pnpm run i18n:check`). | Translation is a planned task. |
|
||||
| Production files: target < 300 lines, hard 400; `web-backend-app.ts` (673) and `playlists.service.ts` (1580) are baselined. | New code lives in new files; baselined files only gain mounting lines. |
|
||||
|
||||
## 5. Product decisions
|
||||
|
||||
1. In server mode the server is the authority for playlists and the synced
|
||||
user data. There is no per-playlist "local or server" choice.
|
||||
2. Server storage is opt-in on the backend: `IPTVNATOR_STORAGE=server`. The
|
||||
bare `web-backend` defaults to `none`; the Docker image sets `server`.
|
||||
3. Optional `IPTVNATOR_ACCESS_TOKEN`. When set, every route except
|
||||
`/`, `/health`, `/config.js` and `GET /v1/instance` requires
|
||||
`Authorization: Bearer <token>`, including the provider proxy routes.
|
||||
4. Migration:
|
||||
- First device (server has no playlists, browser has some, no marker for
|
||||
this instance): a dialog offers "Move to server". Playlists are copied
|
||||
with their ids, then the four Xtream user-data maps for those ids.
|
||||
- Any later device (server already has playlists): Settings → Server
|
||||
offers "Import this browser's playlists", which runs the existing backup
|
||||
export (browser) and import (server) with fingerprint merging, so the
|
||||
same provider added on two devices is not duplicated.
|
||||
- Browser data is never deleted. The server-mode cache uses its own
|
||||
localStorage key prefix, so legacy keys stay intact.
|
||||
5. Offline: reads and writes fail with a visible state. The rail shows an
|
||||
offline server icon, a banner explains that changes cannot be saved, and
|
||||
failed writes show a snackbar. No offline write queue.
|
||||
6. "Delete all playlists" in server mode warns that it clears the server for
|
||||
every device.
|
||||
|
||||
## 6. Architecture
|
||||
|
||||
```
|
||||
Browser (PWA) Docker container
|
||||
┌─────────────────────────────────────────┐ ┌───────────────────────────────────────┐
|
||||
│ main.ts: probeServerInstance() │ /api │ nginx (client_max_body_size 64m) │
|
||||
│ → provideServerStorage(instance) │────────►│ └─► web-backend (Express) │
|
||||
│ PlaylistsService (API unchanged) │ │ ├ proxy routes (unchanged) │
|
||||
│ └ PlaylistStorageBackend │ │ ├ access guard (optional) │
|
||||
│ ├ Electron bridge (desktop) │ │ └ /v1 storage router │
|
||||
│ ├ ServerPlaylistDocumentStore │ HTTP │ ├ instance, status │
|
||||
│ └ IndexedDB (plain PWA) │◄───────►│ ├ playlists (documents) │
|
||||
│ PwaXtreamDataSource │ │ ├ app-state │
|
||||
│ └ USER_DATA_STORAGE │ │ └ user-state │
|
||||
│ ├ localStorage │ │ ServerDatabase: initDatabase() │
|
||||
│ └ ServerUserDataStorage (cache) │ │ + server_user_state │
|
||||
│ ServerStorageStatusService (signals) │ │ /data/databases/iptvnator.db (WAL) │
|
||||
└─────────────────────────────────────────┘ └───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.1 HTTP API (`/v1`, mounted under nginx `/api`)
|
||||
|
||||
All JSON. Errors: `{ message, status, code? }`.
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /v1/instance` | Public. `{ service: 'iptvnator', apiVersion: 1, instanceId, storage: 'server' \| 'none', auth: 'none' \| 'token' }`. The frontend and backend ship in one image, so the PWA shows its own version. |
|
||||
| `GET /v1/instance/status` | `{ playlists, databaseBytes, databasePath, startedAt }`. Also the token check. |
|
||||
| `GET /v1/playlists?view=meta` | Metadata documents (no payload). |
|
||||
| `GET /v1/playlists` | Full documents (backup export, global favorites). |
|
||||
| `GET /v1/playlists/:id` | Full document, `ETag`; `If-None-Match` → `304`. |
|
||||
| `PUT /v1/playlists/:id[?keepPayload=1]` | Upsert. With `keepPayload`, the stored `playlist`, `items` and `header` fields are kept; `409 payload_missing` when there is no stored row. Responds with the new `ETag`. |
|
||||
| `DELETE /v1/playlists/:id` | Delete the row and its user-state entries. |
|
||||
| `DELETE /v1/playlists` | Delete all playlists and all user state. |
|
||||
| `GET/PUT /v1/app-state/:key` | `{ value }`. |
|
||||
| `GET /v1/user-state` | `{ entries: { [namespace]: { [playlistId]: value } } }`. |
|
||||
| `PUT/DELETE /v1/user-state/:namespace/:playlistId` | One entry. Namespaces are an allow-list. |
|
||||
|
||||
ETags are `"<bootId>-<revision>"` from an in-memory per-playlist revision
|
||||
counter; the server is the only writer of its database.
|
||||
|
||||
### 6.2 Server
|
||||
|
||||
`apps/web-backend/src/app/storage/`: config, database bootstrap
|
||||
(`initDatabase()` + `server_user_state` + instance id in `app_state`),
|
||||
playlist repository on the shared codec, user-state repository, revision
|
||||
tracker, access guard, routers. `main.ts` opens storage when enabled and
|
||||
closes it on `SIGTERM`. Storage errors are logged by name and route only.
|
||||
|
||||
### 6.3 Client
|
||||
|
||||
- `PlaylistStorageBackend` (libs/services) resolves the document store:
|
||||
server store in server mode, Electron bridge when `supportsSqlite`, else
|
||||
IndexedDB. It can switch to browser storage temporarily for the migration
|
||||
export.
|
||||
- `@iptvnator/services/server-storage` (sub-entry, kept out of the eager
|
||||
barrel): API client, document store, user-data storage, session, migration.
|
||||
- `ServerStorageStatusService` (main barrel, tiny): connection state for the
|
||||
rail, banner and Settings.
|
||||
- `apps/web/src/app/server-storage/`: probe, providers (dynamically imported
|
||||
from `main.ts` only in server mode), sign-in route, migration dialog,
|
||||
Settings section.
|
||||
|
||||
## 7. UX
|
||||
|
||||
Mockup (2026-10-08, settings navigation has since gained groups):
|
||||
https://claude.ai/artifact/3WheJAgeNL5ag6kmtPrJcD
|
||||
|
||||
1. Settings → Data → Server: connection pill and address,
|
||||
storage figures, "Export backup", "Import this browser's playlists" (when
|
||||
local playlists exist), access mode with "Sign out" when a token is used.
|
||||
2. Migration dialog over the workspace on first boot (rule 5.4).
|
||||
3. Rail: server icon above Settings with connected, offline and locked states.
|
||||
Offline banner under the header with "Retry".
|
||||
4. Sign-in page `/server-sign-in` when the instance requires a token.
|
||||
|
||||
## 8. Error handling
|
||||
|
||||
| Situation | Behavior |
|
||||
| --- | --- |
|
||||
| Probe fails or times out (2.5 s) | Browser mode, no server UI. |
|
||||
| Request fails after boot | Status `offline`, banner, write failures show a snackbar; retry on `visibilitychange` and every 30 s. |
|
||||
| `401` | Status `unauthorized`, stored token cleared, redirect to sign-in. |
|
||||
| `413` | Snackbar names the limit. |
|
||||
| `409 payload_missing` on `keepPayload` | Client retries once with the full document. |
|
||||
| Data directory not writable | Backend exits on start with a clear log line; the container restarts. |
|
||||
|
||||
## 9. Testing
|
||||
|
||||
- Unit: codec round trip, path variable, resolver, document store (ETag,
|
||||
keepPayload), user-data storage (diff, debounce, hydrate), probe, session,
|
||||
migration decision, settings section, rail indicator.
|
||||
- Backend: repositories on real SQLite in a temp directory; routes through a
|
||||
real server and `fetch`; access guard.
|
||||
- E2E: `@server-storage` spec against a storage-enabled backend on its own
|
||||
port and temp directory: a playlist added in one browser context appears in
|
||||
another; the migration dialog moves a seeded browser playlist. The token
|
||||
page and guards are covered by unit tests (an E2E run would need a third
|
||||
backend).
|
||||
- Docker: `docker compose config`, image build, start with a volume.
|
||||
|
||||
## 10. Phases
|
||||
|
||||
| Phase | Content | Closes |
|
||||
| --- | --- | --- |
|
||||
| 1 | Seams, no behavior change: shared codec, `IPTVNATOR_DATA_DIR`, `PlaylistStorageBackend`, `USER_DATA_STORAGE`. | — |
|
||||
| 2a | Server storage in `web-backend`, Docker volume, nginx limits, docs. | — |
|
||||
| 2b | PWA server mode, migration, Settings → Server, rail/banner, sign-in, i18n, E2E, release note. | #305, #514 |
|
||||
| 3 | Server-side EPG for the PWA. | PWA EPG gap |
|
||||
| 4 | Electron connects to a server. | #1616 for NAS owners |
|
||||
|
||||
## 11. Risks
|
||||
|
||||
- **Concurrent edits on two devices**: last write wins per document or per
|
||||
Xtream map entry. Every playlist mutation re-reads first, so the window is
|
||||
one round trip.
|
||||
- **Large payloads**: M3U global favorites and backup export read all full
|
||||
documents, as IndexedDB does today.
|
||||
- **Open instances**: storing credentials raises the cost of an unprotected
|
||||
public instance. Docs and the Settings page state it; use the token or
|
||||
reverse-proxy auth.
|
||||
- **Native module in multi-arch images**: built in a target-platform stage.
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
normalizeFavoriteStorage,
|
||||
normalizeRecentStorage,
|
||||
} from './pwa-collection-identity';
|
||||
import type { UserDataStorage } from '@iptvnator/services';
|
||||
import { XtreamContentItem } from './xtream-data-source.interface';
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
@@ -20,7 +21,8 @@ export class PwaCollectionStorage {
|
||||
private readonly resolveItems: (
|
||||
playlistId: string,
|
||||
ids: readonly CollectionKey[]
|
||||
) => Map<CollectionKey, XtreamContentItem>
|
||||
) => Map<CollectionKey, XtreamContentItem>,
|
||||
private readonly storage: UserDataStorage
|
||||
) {}
|
||||
|
||||
getLocalFavorites(playlistId: string): XtreamContentItem[] {
|
||||
@@ -50,7 +52,7 @@ export class PwaCollectionStorage {
|
||||
|
||||
getFavoritesFromStorage(): Record<string, CollectionKey[]> {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEYS.FAVORITES);
|
||||
const data = this.storage.getItem(STORAGE_KEYS.FAVORITES);
|
||||
return normalizeFavoriteStorage(data ? JSON.parse(data) : {});
|
||||
} catch {
|
||||
return {};
|
||||
@@ -58,7 +60,7 @@ export class PwaCollectionStorage {
|
||||
}
|
||||
|
||||
saveFavoritesToStorage(favorites: Record<string, CollectionKey[]>): void {
|
||||
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
|
||||
this.storage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
|
||||
}
|
||||
|
||||
clearFavoritesForPlaylist(playlistId: string): void {
|
||||
@@ -124,7 +126,7 @@ export class PwaCollectionStorage {
|
||||
|
||||
getRecentItemsFromStorage(): Record<string, StoredRecentItem[]> {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEYS.RECENT_ITEMS);
|
||||
const data = this.storage.getItem(STORAGE_KEYS.RECENT_ITEMS);
|
||||
return normalizeRecentStorage(data ? JSON.parse(data) : {});
|
||||
} catch {
|
||||
return {};
|
||||
@@ -134,7 +136,7 @@ export class PwaCollectionStorage {
|
||||
saveRecentItemsToStorage(
|
||||
recentItems: Record<string, StoredRecentItem[]>
|
||||
): void {
|
||||
localStorage.setItem(
|
||||
this.storage.setItem(
|
||||
STORAGE_KEYS.RECENT_ITEMS,
|
||||
JSON.stringify(recentItems)
|
||||
);
|
||||
@@ -151,7 +153,7 @@ export class PwaCollectionStorage {
|
||||
Record<string, XtreamContentItem>
|
||||
> {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEYS.COLLECTION_ITEMS);
|
||||
const data = this.storage.getItem(STORAGE_KEYS.COLLECTION_ITEMS);
|
||||
const parsed = data ? JSON.parse(data) : {};
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return {};
|
||||
@@ -165,7 +167,7 @@ export class PwaCollectionStorage {
|
||||
private saveCollectionItemsToStorage(
|
||||
items: Record<string, Record<string, XtreamContentItem>>
|
||||
): void {
|
||||
localStorage.setItem(
|
||||
this.storage.setItem(
|
||||
STORAGE_KEYS.COLLECTION_ITEMS,
|
||||
JSON.stringify(items)
|
||||
);
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
XtreamApiService,
|
||||
XtreamCredentials,
|
||||
} from '../services/xtream-api.service';
|
||||
import { ParentalLockService, PlaylistsService } from '@iptvnator/services';
|
||||
import {
|
||||
ParentalLockService,
|
||||
PlaylistsService,
|
||||
USER_DATA_STORAGE,
|
||||
} from '@iptvnator/services';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
describe('PwaXtreamDataSource', () => {
|
||||
@@ -916,4 +920,38 @@ describe('PwaXtreamDataSource', () => {
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an injected user data storage', () => {
|
||||
it('reads and writes favorites through it, not localStorage', async () => {
|
||||
const values = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
};
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PwaXtreamDataSource,
|
||||
{ provide: USER_DATA_STORAGE, useValue: storage },
|
||||
{ provide: ParentalLockService, useValue: parentalLock },
|
||||
{ provide: XtreamApiService, useValue: apiService },
|
||||
{ provide: PlaylistsService, useValue: playlistsService },
|
||||
],
|
||||
});
|
||||
const source = TestBed.inject(PwaXtreamDataSource);
|
||||
|
||||
values.set('xtream-favorites', JSON.stringify({ p1: ['live:1'] }));
|
||||
await source.deletePlaylist('p1');
|
||||
|
||||
expect(JSON.parse(values.get('xtream-favorites') ?? '{}')).toEqual(
|
||||
{}
|
||||
);
|
||||
expect(localStorage.getItem('xtream-favorites')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
XtreamApiService,
|
||||
XtreamCredentials,
|
||||
} from '../services/xtream-api.service';
|
||||
import { ParentalLockService, PlaylistsService } from '@iptvnator/services';
|
||||
import {
|
||||
ParentalLockService,
|
||||
PlaylistsService,
|
||||
USER_DATA_STORAGE,
|
||||
} from '@iptvnator/services';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
DbCategoryType,
|
||||
@@ -42,7 +46,7 @@ import {
|
||||
import { PwaCollectionStorage } from './pwa-collection-storage';
|
||||
|
||||
/**
|
||||
* LocalStorage keys for PWA persistence
|
||||
* Storage keys for PWA persistence
|
||||
*/
|
||||
const STORAGE_KEYS = {
|
||||
PLAYLISTS: 'xtream-playlists',
|
||||
@@ -79,7 +83,7 @@ type StoredXtreamPlaylistData = Omit<XtreamPlaylistData, 'password'> & {
|
||||
/**
|
||||
* PWA implementation of the Xtream data source.
|
||||
* Uses API-only strategy: always fetch from API, no database caching.
|
||||
* Favorites and recently viewed are stored in localStorage.
|
||||
* Favorites, recently viewed and positions are stored in USER_DATA_STORAGE.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
@@ -88,8 +92,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly logger = createLogger('PwaXtreamDataSource');
|
||||
private readonly contentTypes = ['live', 'movie', 'series'] as const;
|
||||
private readonly userDataStorage = inject(USER_DATA_STORAGE);
|
||||
private readonly collectionStorage = new PwaCollectionStorage(
|
||||
(playlistId, ids) => this.getCollectionItemsById(playlistId, ids)
|
||||
(playlistId, ids) => this.getCollectionItemsById(playlistId, ids),
|
||||
this.userDataStorage
|
||||
);
|
||||
|
||||
// In-memory cache for the current session
|
||||
@@ -98,7 +104,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
private playlistPasswords = new Map<string, string>();
|
||||
|
||||
// =========================================================================
|
||||
// Playlist Operations (localStorage)
|
||||
// Playlist Operations (user data storage)
|
||||
// =========================================================================
|
||||
|
||||
async getPlaylist(playlistId: string): Promise<XtreamPlaylistData | null> {
|
||||
@@ -181,7 +187,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
|
||||
private getPlaylistsFromStorage(): XtreamPlaylistData[] {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEYS.PLAYLISTS);
|
||||
const data = this.userDataStorage.getItem(STORAGE_KEYS.PLAYLISTS);
|
||||
const playlists = data
|
||||
? (JSON.parse(data) as StoredXtreamPlaylistData[])
|
||||
: [];
|
||||
@@ -198,7 +204,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
const persistedPlaylists = playlists.map((playlist) =>
|
||||
this.toStoredPlaylist(playlist)
|
||||
);
|
||||
localStorage.setItem(
|
||||
this.userDataStorage.setItem(
|
||||
STORAGE_KEYS.PLAYLISTS,
|
||||
JSON.stringify(persistedPlaylists)
|
||||
);
|
||||
@@ -683,7 +689,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Favorites Operations (localStorage)
|
||||
// Favorites Operations (user data storage)
|
||||
// =========================================================================
|
||||
|
||||
async getFavorites(playlistId: string): Promise<XtreamContentItem[]> {
|
||||
@@ -777,7 +783,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Playback Position Operations (localStorage)
|
||||
// Playback Position Operations (user data storage)
|
||||
// =========================================================================
|
||||
|
||||
async savePlaybackPosition(
|
||||
@@ -938,7 +944,9 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
PlaybackPositionData[]
|
||||
> {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEYS.PLAYBACK_POSITIONS);
|
||||
const data = this.userDataStorage.getItem(
|
||||
STORAGE_KEYS.PLAYBACK_POSITIONS
|
||||
);
|
||||
return data ? JSON.parse(data) : {};
|
||||
} catch {
|
||||
return {};
|
||||
@@ -948,7 +956,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
private savePlaybackPositionsToStorage(
|
||||
positions: Record<string, PlaybackPositionData[]>
|
||||
): void {
|
||||
localStorage.setItem(
|
||||
this.userDataStorage.setItem(
|
||||
STORAGE_KEYS.PLAYBACK_POSITIONS,
|
||||
JSON.stringify(positions)
|
||||
);
|
||||
@@ -961,7 +969,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Recently Viewed Operations (localStorage)
|
||||
// Recently Viewed Operations (user data storage)
|
||||
// =========================================================================
|
||||
|
||||
async getRecentItems(playlistId: string): Promise<XtreamContentItem[]> {
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from './lib/parental-lock';
|
||||
export * from './lib/playlist-cross-context-lock';
|
||||
export * from './lib/playlist-refresh.service';
|
||||
export * from './lib/playlists.service';
|
||||
export * from './lib/playlist-storage-backend';
|
||||
export * from './lib/portal-status.service';
|
||||
export * from './lib/runtime-capabilities.service';
|
||||
export * from './lib/settings-store.service';
|
||||
@@ -31,3 +32,4 @@ export * from './lib/source-health-evidence.service';
|
||||
|
||||
export * from './lib/source-activity.service';
|
||||
export * from './lib/translation-tick';
|
||||
export * from './lib/user-data-storage';
|
||||
@@ -36,7 +36,10 @@ describe('Electron legacy playlist migration', () => {
|
||||
) as PlaylistsService;
|
||||
Object.assign(service, {
|
||||
dbService,
|
||||
runtime: { supportsSqlite: true },
|
||||
storageBackend: {
|
||||
kind: () => 'electron',
|
||||
documentStore: () => window.electron,
|
||||
},
|
||||
electronMigrationPromise: null,
|
||||
});
|
||||
return { playlists, dbService, electron, service };
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PlaylistStorageBackend } from './playlist-storage-backend';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
|
||||
describe('PlaylistStorageBackend', () => {
|
||||
const testWindow = window as unknown as { electron?: unknown };
|
||||
const originalElectron = testWindow.electron;
|
||||
|
||||
afterEach(() => {
|
||||
testWindow.electron = originalElectron;
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
function create(supportsSqlite: boolean) {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsSqlite },
|
||||
},
|
||||
],
|
||||
});
|
||||
return TestBed.inject(PlaylistStorageBackend);
|
||||
}
|
||||
|
||||
it('uses the Electron bridge when the SQLite bridge is complete', () => {
|
||||
const bridge = { dbGetAppPlaylists: jest.fn() };
|
||||
testWindow.electron = bridge;
|
||||
|
||||
const backend = create(true);
|
||||
|
||||
expect(backend.kind()).toBe('electron');
|
||||
expect(backend.documentStore()).toBe(bridge);
|
||||
});
|
||||
|
||||
it('uses IndexedDB without the SQLite bridge', () => {
|
||||
testWindow.electron = { dbGetAppPlaylists: jest.fn() };
|
||||
|
||||
const backend = create(false);
|
||||
|
||||
expect(backend.kind()).toBe('indexeddb');
|
||||
expect(backend.documentStore()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
|
||||
/**
|
||||
* Whole-playlist document persistence. The Electron preload implements it
|
||||
* over the database worker; the self-hosted server storage implements the
|
||||
* same contract over HTTP.
|
||||
*/
|
||||
export type PlaylistDocumentStore = Pick<
|
||||
ElectronBridgeApi,
|
||||
| 'dbDeleteAllPlaylists'
|
||||
| 'dbDeletePlaylist'
|
||||
| 'dbGetAppPlaylist'
|
||||
| 'dbGetAppPlaylists'
|
||||
| 'dbGetAppState'
|
||||
| 'dbSetAppState'
|
||||
| 'dbUpsertAppPlaylist'
|
||||
| 'dbUpsertAppPlaylists'
|
||||
> &
|
||||
Partial<
|
||||
Pick<
|
||||
ElectronBridgeApi,
|
||||
| 'dbGetAppPlaylistFavoriteChannels'
|
||||
| 'dbGetAppPlaylistMetas'
|
||||
| 'dbMigrateAppPlaylists'
|
||||
| 'dbRecoverLegacyPlaylists'
|
||||
>
|
||||
>;
|
||||
|
||||
export type PlaylistStorageKind = 'electron' | 'indexeddb';
|
||||
|
||||
/** Single place that decides where `PlaylistsService` persists playlists. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaylistStorageBackend {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
|
||||
kind(): PlaylistStorageKind {
|
||||
return this.runtime.supportsSqlite ? 'electron' : 'indexeddb';
|
||||
}
|
||||
|
||||
/** The document store for `kind()`, or `null` for IndexedDB. */
|
||||
documentStore(): PlaylistDocumentStore | null {
|
||||
if (this.kind() !== 'electron' || typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return (window.electron as PlaylistDocumentStore | undefined) ?? null;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,10 @@ describe('PlaylistsService inventory reads', () => {
|
||||
) as PlaylistsService;
|
||||
Object.assign(service, {
|
||||
dbService: { getAll: jest.fn(() => of([])) },
|
||||
runtime: { supportsSqlite: true },
|
||||
storageBackend: {
|
||||
kind: () => 'electron',
|
||||
documentStore: () => window.electron,
|
||||
},
|
||||
electronMigrationPromise: null,
|
||||
playlistWriteQueues: new Map(),
|
||||
});
|
||||
|
||||
@@ -22,6 +22,23 @@ describe('PlaylistsService', () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function hasSqliteBridge(): boolean {
|
||||
const electron = testWindow.electron as
|
||||
Record<string, unknown> | undefined;
|
||||
return (
|
||||
!!electron &&
|
||||
typeof electron['dbGetAppPlaylists'] === 'function' &&
|
||||
typeof electron['dbUpsertAppPlaylist'] === 'function' &&
|
||||
typeof electron['dbGetAppState'] === 'function' &&
|
||||
typeof electron['dbSetAppState'] === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
const testStorageBackend = {
|
||||
kind: () => (hasSqliteBridge() ? 'electron' : 'indexeddb'),
|
||||
documentStore: () => (hasSqliteBridge() ? testWindow.electron : null),
|
||||
};
|
||||
|
||||
function createService(overrides: Record<string, unknown> = {}) {
|
||||
const service = Object.create(
|
||||
PlaylistsService.prototype
|
||||
@@ -45,19 +62,7 @@ describe('PlaylistsService', () => {
|
||||
translateService: {
|
||||
instant: jest.fn((key: string) => key),
|
||||
},
|
||||
runtime: {
|
||||
get supportsSqlite() {
|
||||
const electron = testWindow.electron as
|
||||
Record<string, unknown> | undefined;
|
||||
return (
|
||||
!!electron &&
|
||||
typeof electron['dbGetAppPlaylists'] === 'function' &&
|
||||
typeof electron['dbUpsertAppPlaylist'] === 'function' &&
|
||||
typeof electron['dbGetAppState'] === 'function' &&
|
||||
typeof electron['dbSetAppState'] === 'function'
|
||||
);
|
||||
},
|
||||
},
|
||||
storageBackend: testStorageBackend,
|
||||
electronMigrationPromise: null,
|
||||
indexedDbMigrationPromise: null,
|
||||
playlistWriteQueues: new Map(),
|
||||
@@ -135,9 +140,11 @@ describe('PlaylistsService', () => {
|
||||
finishCleanup = resolve;
|
||||
})
|
||||
);
|
||||
Object.defineProperties(service, {
|
||||
isElectronStorageAvailable: { value: true },
|
||||
electronApi: { value: { dbDeletePlaylist: jest.fn() } },
|
||||
Object.assign(service, {
|
||||
storageBackend: {
|
||||
kind: () => 'electron',
|
||||
documentStore: () => ({ dbDeletePlaylist: jest.fn() }),
|
||||
},
|
||||
});
|
||||
Object.assign(service, {
|
||||
ensureElectronPlaylistMigrations: jest
|
||||
|
||||
@@ -44,7 +44,10 @@ import {
|
||||
runWithPlaylistAuthorityMutation,
|
||||
runWithPlaylistAuthorityReset,
|
||||
} from './playlist-cross-context-lock';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
import {
|
||||
PlaylistDocumentStore,
|
||||
PlaylistStorageBackend,
|
||||
} from './playlist-storage-backend';
|
||||
|
||||
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG =
|
||||
@@ -60,33 +63,6 @@ type PlaylistRawItem = {
|
||||
raw?: string;
|
||||
};
|
||||
|
||||
type PlaylistStorageElectronApi = {
|
||||
dbDeleteAllPlaylists: () => Promise<unknown>;
|
||||
dbDeletePlaylist: (playlistId: string) => Promise<unknown>;
|
||||
dbGetAppPlaylist: (
|
||||
playlistId: string,
|
||||
operationId?: string
|
||||
) => Promise<Playlist | null>;
|
||||
dbGetAppPlaylistFavoriteChannels?: (
|
||||
playlistId: string
|
||||
) => Promise<M3uFavoriteChannel[]>;
|
||||
dbGetAppPlaylistMetas?: () => Promise<Playlist[]>;
|
||||
dbGetAppPlaylists: () => Promise<Playlist[]>;
|
||||
dbGetAppState: (key: string) => Promise<string | null>;
|
||||
dbSetAppState: (key: string, value: string) => Promise<unknown>;
|
||||
dbUpsertAppPlaylist: (
|
||||
playlist: Playlist,
|
||||
operationId?: string
|
||||
) => Promise<unknown>;
|
||||
dbMigrateAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
|
||||
dbRecoverLegacyPlaylists?: () => Promise<void>;
|
||||
dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type PlaylistStorageWindow = Window & {
|
||||
electron?: PlaylistStorageElectronApi;
|
||||
};
|
||||
|
||||
type AddManyPlaylistsResult = Playlist[] | IDBValidKey[];
|
||||
|
||||
type PlaylistParserModule = Partial<typeof import('iptv-playlist-parser')> & {
|
||||
@@ -112,7 +88,7 @@ export class PlaylistsService {
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly storageBackend = inject(PlaylistStorageBackend);
|
||||
private readonly playlistDeleteCleanups =
|
||||
inject(PLAYLIST_DELETE_CLEANUP, { optional: true }) ?? [];
|
||||
private electronMigrationPromise: Promise<void> | null = null;
|
||||
@@ -125,16 +101,18 @@ export class PlaylistsService {
|
||||
private indexedDbMigrationPromise: Promise<void> | null = null;
|
||||
private readonly playlistWriteQueues = new Map<string, Promise<unknown>>();
|
||||
|
||||
private get electronApi(): PlaylistStorageElectronApi | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (window as PlaylistStorageWindow).electron ?? null;
|
||||
/** Electron bridge or server store; `null` means IndexedDB. */
|
||||
private get documentStore(): PlaylistDocumentStore | null {
|
||||
return this.storageBackend.documentStore();
|
||||
}
|
||||
|
||||
private get isElectronStorageAvailable(): boolean {
|
||||
return this.runtime.supportsSqlite;
|
||||
private get usesDocumentStore(): boolean {
|
||||
return this.storageBackend.kind() !== 'indexeddb';
|
||||
}
|
||||
|
||||
/** Desktop-only paths: legacy IndexedDB import, worker deletion. */
|
||||
private get usesElectronStorage(): boolean {
|
||||
return this.storageBackend.kind() === 'electron';
|
||||
}
|
||||
|
||||
private runOnSqlite<T>(operation: () => Promise<T>) {
|
||||
@@ -150,14 +128,14 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
private async ensureElectronPlaylistMigrations(): Promise<void> {
|
||||
if (!this.isElectronStorageAvailable) {
|
||||
if (!this.usesElectronStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.electronMigrationPromise) {
|
||||
this.electronMigrationPromise = (async () => {
|
||||
await this.migrateIndexedDbPlaylistsToSqlite();
|
||||
await this.electronApi?.dbRecoverLegacyPlaylists?.();
|
||||
await this.documentStore?.dbRecoverLegacyPlaylists?.();
|
||||
await this.migrateStalkerPlaylistMetadataInSqlite();
|
||||
})().catch((error) => {
|
||||
this.electronMigrationPromise = null;
|
||||
@@ -169,7 +147,7 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
private async ensureIndexedDbPlaylistMigrations(): Promise<void> {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -196,12 +174,9 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
private async migrateIndexedDbPlaylistsToSqlite(): Promise<void> {
|
||||
const electron = this.electronApi;
|
||||
if (!electron) return;
|
||||
if (
|
||||
(await electron.dbGetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG)) ===
|
||||
'1'
|
||||
)
|
||||
const store = this.documentStore;
|
||||
if (!store?.dbMigrateAppPlaylists) return;
|
||||
if ((await store.dbGetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG)) === '1')
|
||||
return;
|
||||
const playlists = await firstValueFrom(
|
||||
this.dbService.getAll<Playlist>(DbStores.Playlists)
|
||||
@@ -209,9 +184,9 @@ export class PlaylistsService {
|
||||
if (playlists.length) {
|
||||
// The worker commits rows and the receipt atomically. Keep the
|
||||
// original IndexedDB as a recovery source, even after success.
|
||||
await electron.dbMigrateAppPlaylists(playlists);
|
||||
await store.dbMigrateAppPlaylists(playlists);
|
||||
} else {
|
||||
await electron.dbSetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG, '1');
|
||||
await store.dbSetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG, '1');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,12 +290,12 @@ export class PlaylistsService {
|
||||
|
||||
private async migrateStalkerPlaylistMetadataInSqlite(): Promise<void> {
|
||||
try {
|
||||
const electron = this.electronApi;
|
||||
if (!electron) {
|
||||
const store = this.documentStore;
|
||||
if (!store) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyMigrated = await electron.dbGetAppState(
|
||||
const alreadyMigrated = await store.dbGetAppState(
|
||||
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
|
||||
);
|
||||
if (alreadyMigrated === '1') {
|
||||
@@ -328,15 +303,15 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
const storedPlaylists =
|
||||
(await electron.dbGetAppPlaylists()) as Playlist[];
|
||||
(await store.dbGetAppPlaylists()) as Playlist[];
|
||||
const updates =
|
||||
this.collectStalkerMetadataMigrationUpdates(storedPlaylists);
|
||||
|
||||
if (updates.length > 0) {
|
||||
await electron.dbUpsertAppPlaylists(updates);
|
||||
await store.dbUpsertAppPlaylists(updates);
|
||||
}
|
||||
|
||||
await electron.dbSetAppState(
|
||||
await store.dbSetAppState(
|
||||
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG,
|
||||
'1'
|
||||
);
|
||||
@@ -398,16 +373,16 @@ export class PlaylistsService {
|
||||
|
||||
private upsertSqlitePlaylist(playlist: Playlist, operationId?: string) {
|
||||
return this.runOnSqlite(async () => {
|
||||
const electron = this.electronApi;
|
||||
if (!electron) {
|
||||
const store = this.documentStore;
|
||||
if (!store) {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
this.startupMetas = null;
|
||||
if (operationId === undefined) {
|
||||
await electron.dbUpsertAppPlaylist(playlist);
|
||||
await store.dbUpsertAppPlaylist(playlist);
|
||||
} else {
|
||||
await electron.dbUpsertAppPlaylist(playlist, operationId);
|
||||
await store.dbUpsertAppPlaylist(playlist, operationId);
|
||||
}
|
||||
this.healthEvidence?.connections.next({ id: playlist._id, playlist });
|
||||
return playlist;
|
||||
@@ -416,13 +391,13 @@ export class PlaylistsService {
|
||||
|
||||
private upsertManySqlitePlaylists(playlists: Playlist[]) {
|
||||
return this.runOnSqlite(async () => {
|
||||
const electron = this.electronApi;
|
||||
if (!electron) {
|
||||
const store = this.documentStore;
|
||||
if (!store) {
|
||||
return playlists;
|
||||
}
|
||||
|
||||
this.startupMetas = null;
|
||||
await electron.dbUpsertAppPlaylists(playlists);
|
||||
await store.dbUpsertAppPlaylists(playlists);
|
||||
playlists.forEach((playlist) => this.healthEvidence?.connections.next({ id: playlist._id, playlist }));
|
||||
return playlists;
|
||||
});
|
||||
@@ -457,7 +432,7 @@ export class PlaylistsService {
|
||||
nextPlaylist: Playlist,
|
||||
operationId?: string
|
||||
) {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
return firstValueFrom(
|
||||
this.upsertSqlitePlaylist(nextPlaylist, operationId)
|
||||
);
|
||||
@@ -499,16 +474,16 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
getAllPlaylists() {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
const shared = this.startupMetas;
|
||||
// A joining caller gets its own copy of the shared result.
|
||||
if (shared) return from(shared.then((p) => structuredClone(p)));
|
||||
const read = firstValueFrom(
|
||||
this.runOnSqlite(async () => {
|
||||
const electron = this.electronApi;
|
||||
const playlists = electron
|
||||
? await (electron.dbGetAppPlaylistMetas?.() ??
|
||||
electron.dbGetAppPlaylists())
|
||||
const store = this.documentStore;
|
||||
const playlists = store
|
||||
? await (store.dbGetAppPlaylistMetas?.() ??
|
||||
store.dbGetAppPlaylists())
|
||||
: [];
|
||||
return (playlists as Playlist[]).map((playlist) =>
|
||||
this.toPlaylistMeta(playlist)
|
||||
@@ -533,7 +508,7 @@ export class PlaylistsService {
|
||||
addPlaylist(playlist: Playlist) {
|
||||
return this.serializePlaylistWrite(playlist._id, () =>
|
||||
runWithPlaylistAuthorityMutation([playlist._id], async () => {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
return firstValueFrom(this.upsertSqlitePlaylist(playlist));
|
||||
}
|
||||
|
||||
@@ -570,12 +545,12 @@ export class PlaylistsService {
|
||||
playlistId,
|
||||
() =>
|
||||
runWithPlaylistAuthorityMutation([playlistId], async () => {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
await this.ensureElectronPlaylistMigrations();
|
||||
const electron = this.electronApi;
|
||||
if (electron) {
|
||||
const store = this.documentStore;
|
||||
if (store) {
|
||||
this.startupMetas = null;
|
||||
if (options) {
|
||||
if (options && this.usesElectronStorage) {
|
||||
const deleted =
|
||||
await this.databaseService.deletePlaylist(
|
||||
playlistId,
|
||||
@@ -585,7 +560,7 @@ export class PlaylistsService {
|
||||
throw new Error(
|
||||
'Playlist deletion did not complete'
|
||||
);
|
||||
} else await electron.dbDeletePlaylist(playlistId);
|
||||
} else await store.dbDeletePlaylist(playlistId);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -707,15 +682,15 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
getPlaylistById(id: string, operationId?: string) {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
return this.runOnSqlite(async () => {
|
||||
const electron = this.electronApi;
|
||||
const store = this.documentStore;
|
||||
let playlist: Playlist | null = null;
|
||||
if (electron) {
|
||||
if (store) {
|
||||
playlist =
|
||||
operationId === undefined
|
||||
? await electron.dbGetAppPlaylist(id)
|
||||
: await electron.dbGetAppPlaylist(id, operationId);
|
||||
? await store.dbGetAppPlaylist(id)
|
||||
: await store.dbGetAppPlaylist(id, operationId);
|
||||
}
|
||||
return playlist
|
||||
? this.createSqliteFallbackPlaylist(playlist as Playlist)
|
||||
@@ -754,7 +729,7 @@ export class PlaylistsService {
|
||||
isCurrent?: (playlist: Playlist) => boolean
|
||||
): Observable<Playlist | null> {
|
||||
return this.serializePlaylistWrite(updatedPlaylist._id, async () => {
|
||||
if (isCurrent && !this.isElectronStorageAvailable) {
|
||||
if (isCurrent && !this.usesDocumentStore) {
|
||||
await this.ensureIndexedDbPlaylistMigrations();
|
||||
return this.transformIndexedDbPlaylistMeta(
|
||||
updatedPlaylist._id,
|
||||
@@ -1010,7 +985,7 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
return this.serializePlaylistWrite(playlistId, async () => {
|
||||
if (!this.isElectronStorageAvailable) {
|
||||
if (!this.usesDocumentStore) {
|
||||
await this.ensureIndexedDbPlaylistMigrations();
|
||||
return this.transformIndexedDbPlaylistMeta(
|
||||
playlistId,
|
||||
@@ -1080,11 +1055,11 @@ export class PlaylistsService {
|
||||
getM3uFavoriteChannels(
|
||||
playlistId: string
|
||||
): Observable<M3uFavoriteChannel[] | null> {
|
||||
const electron = this.electronApi;
|
||||
const getFavoriteChannels = electron?.dbGetAppPlaylistFavoriteChannels;
|
||||
const store = this.documentStore;
|
||||
const getFavoriteChannels = store?.dbGetAppPlaylistFavoriteChannels;
|
||||
if (
|
||||
!electron ||
|
||||
!this.isElectronStorageAvailable ||
|
||||
!store ||
|
||||
!this.usesElectronStorage ||
|
||||
typeof getFavoriteChannels !== 'function'
|
||||
) {
|
||||
return of(null);
|
||||
@@ -1095,7 +1070,7 @@ export class PlaylistsService {
|
||||
// A favorite toggled just before this read may still be queued;
|
||||
// the queue tail never rejects.
|
||||
await this.playlistWriteQueues.get(playlistId);
|
||||
const alreadyMigrated = await electron.dbGetAppState(
|
||||
const alreadyMigrated = await store.dbGetAppState(
|
||||
SQLITE_PLAYLIST_MIGRATION_FLAG
|
||||
);
|
||||
if (alreadyMigrated !== '1') {
|
||||
@@ -1273,7 +1248,7 @@ export class PlaylistsService {
|
||||
runWithPlaylistAuthorityMutation(
|
||||
playlists.map((playlist) => playlist._id),
|
||||
async () => {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
return firstValueFrom(
|
||||
this.upsertManySqlitePlaylists(playlists)
|
||||
);
|
||||
@@ -1328,11 +1303,11 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
getAllData() {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
return this.runOnSqlite(async () => {
|
||||
const electron = this.electronApi;
|
||||
return electron
|
||||
? ((await electron.dbGetAppPlaylists()) as Playlist[])
|
||||
const store = this.documentStore;
|
||||
return store
|
||||
? ((await store.dbGetAppPlaylists()) as Playlist[])
|
||||
: [];
|
||||
});
|
||||
}
|
||||
@@ -1345,12 +1320,12 @@ export class PlaylistsService {
|
||||
removeAll(): Observable<void> {
|
||||
return defer(() =>
|
||||
runWithPlaylistAuthorityReset(async () => {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
await this.ensureElectronPlaylistMigrations();
|
||||
const electron = this.electronApi;
|
||||
if (electron) {
|
||||
const store = this.documentStore;
|
||||
if (store) {
|
||||
this.startupMetas = null;
|
||||
await electron.dbDeleteAllPlaylists();
|
||||
await store.dbDeleteAllPlaylists();
|
||||
this.healthEvidence?.connections.next({});
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { USER_DATA_STORAGE } from './user-data-storage';
|
||||
|
||||
describe('USER_DATA_STORAGE', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
jest.restoreAllMocks();
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
it('delegates to localStorage by default', () => {
|
||||
const storage = TestBed.inject(USER_DATA_STORAGE);
|
||||
|
||||
storage.setItem('xtream-favorites', '{}');
|
||||
|
||||
expect(localStorage.getItem('xtream-favorites')).toBe('{}');
|
||||
expect(storage.getItem('xtream-favorites')).toBe('{}');
|
||||
storage.removeItem('xtream-favorites');
|
||||
expect(localStorage.getItem('xtream-favorites')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not touch localStorage until a method is called', () => {
|
||||
// A browser that denies storage throws on the localStorage getter
|
||||
// itself; reading it only inside calls keeps that failure in the
|
||||
// callers' guarded reads instead of in service construction.
|
||||
const getter = jest.spyOn(globalThis, 'localStorage', 'get');
|
||||
|
||||
const storage = TestBed.inject(USER_DATA_STORAGE);
|
||||
expect(getter).not.toHaveBeenCalled();
|
||||
|
||||
storage.getItem('xtream-favorites');
|
||||
expect(getter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
/** The slice of the Web Storage API that PWA user-data stores rely on. */
|
||||
export type UserDataStorage = Pick<
|
||||
Storage,
|
||||
'getItem' | 'setItem' | 'removeItem'
|
||||
>;
|
||||
|
||||
/**
|
||||
* `localStorage` behind an adapter that reads the global only inside each
|
||||
* call: when the browser denies storage access, reading `localStorage`
|
||||
* itself throws, and that must happen in the callers' guarded reads and
|
||||
* writes, not while Angular constructs the service that injects this.
|
||||
*/
|
||||
export function createBrowserUserDataStorage(): UserDataStorage {
|
||||
return {
|
||||
getItem: (key) => globalThis.localStorage.getItem(key),
|
||||
setItem: (key, value) => globalThis.localStorage.setItem(key, value),
|
||||
removeItem: (key) => globalThis.localStorage.removeItem(key),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser persistence for PWA user data such as Xtream favorites, recently
|
||||
* viewed items and playback positions. Defaults to `localStorage`; the
|
||||
* self-hosted server mode provides a server-synced implementation.
|
||||
*/
|
||||
export const USER_DATA_STORAGE = new InjectionToken<UserDataStorage>(
|
||||
'USER_DATA_STORAGE',
|
||||
{ providedIn: 'root', factory: createBrowserUserDataStorage }
|
||||
);
|
||||
@@ -33,7 +33,13 @@ import { content, categories, playlists, type Content } from '@iptvnator/shared/
|
||||
|
||||
## Database Location
|
||||
|
||||
The SQLite database is stored at: `~/.iptvnator/databases/iptvnator.db`
|
||||
The SQLite database is stored at `<data root>/databases/iptvnator.db`. The data
|
||||
root is resolved in this order:
|
||||
|
||||
1. `IPTVNATOR_E2E_DATA_DIR` (E2E runs only)
|
||||
2. `IPTVNATOR_DATA_DIR` (an operator-selected data root for deployments
|
||||
with a mounted volume, for example `/data`)
|
||||
3. `~/.iptvnator`
|
||||
|
||||
## Upgrade Compatibility And Migrations
|
||||
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './lib/schema';
|
||||
export * from './lib/connection';
|
||||
export * from './lib/path-utils';
|
||||
export * from './lib/connection-observer';
|
||||
export * from './lib/app-playlist';
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
buildPlaylistRow,
|
||||
parseAppPlaylist,
|
||||
PLAYLIST_TYPES,
|
||||
} from './app-playlist-codec';
|
||||
import type { NewPlaylist, Playlist as PlaylistRow } from './schema';
|
||||
|
||||
function rowOf(document: Record<string, unknown>): NewPlaylist {
|
||||
const row = buildPlaylistRow(document);
|
||||
if (!row) {
|
||||
throw new Error('Expected the document to produce a row');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** What SQLite hands back for a row written from `row`. */
|
||||
function storedRow(row: NewPlaylist, payload?: string | null): PlaylistRow {
|
||||
const nullable = Object.fromEntries(
|
||||
Object.entries(row).map(([key, value]) => [key, value ?? null])
|
||||
);
|
||||
return {
|
||||
dateCreated: '2026-10-01 00:00:00',
|
||||
...nullable,
|
||||
payload: payload === undefined ? (row.payload ?? null) : payload,
|
||||
} as PlaylistRow;
|
||||
}
|
||||
|
||||
describe('app playlist codec', () => {
|
||||
const m3uDocument = {
|
||||
_id: 'm3u-1',
|
||||
title: 'Family',
|
||||
url: 'https://example.test/list.m3u',
|
||||
count: 1,
|
||||
importDate: '2026-10-01T00:00:00.000Z',
|
||||
lastUsage: '2026-10-02T00:00:00.000Z',
|
||||
favorites: ['https://example.test/a'],
|
||||
recentlyViewed: [],
|
||||
autoRefresh: true,
|
||||
hiddenGroupTitles: ['Adult'],
|
||||
playlist: {
|
||||
header: { raw: '#EXTM3U' },
|
||||
items: [
|
||||
{
|
||||
id: 'a',
|
||||
url: 'https://example.test/a',
|
||||
raw: '#EXTINF:-1,A',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('round-trips an M3U document through a stored row', () => {
|
||||
const row = rowOf(m3uDocument);
|
||||
|
||||
expect(row).toMatchObject({
|
||||
id: 'm3u-1',
|
||||
name: 'Family',
|
||||
type: PLAYLIST_TYPES.M3U_URL,
|
||||
url: 'https://example.test/list.m3u',
|
||||
autoRefresh: true,
|
||||
count: 1,
|
||||
});
|
||||
expect(parseAppPlaylist(storedRow(row))).toMatchObject({
|
||||
_id: 'm3u-1',
|
||||
title: 'Family',
|
||||
favorites: ['https://example.test/a'],
|
||||
hiddenGroupTitles: ['Adult'],
|
||||
playlist: m3uDocument.playlist,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads metadata from columns when the payload is not selected', () => {
|
||||
const row = rowOf(m3uDocument);
|
||||
const meta = parseAppPlaylist(storedRow(row, null));
|
||||
|
||||
expect(meta).toMatchObject({
|
||||
_id: 'm3u-1',
|
||||
title: 'Family',
|
||||
count: 1,
|
||||
favorites: ['https://example.test/a'],
|
||||
url: 'https://example.test/list.m3u',
|
||||
});
|
||||
expect(meta['playlist']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('infers the provider type from the document', () => {
|
||||
expect(
|
||||
buildPlaylistRow({ _id: 'x', serverUrl: 'http://panel.test' })?.type
|
||||
).toBe(PLAYLIST_TYPES.XTREAM);
|
||||
expect(
|
||||
buildPlaylistRow({
|
||||
_id: 's',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://portal.test/c',
|
||||
})
|
||||
).toMatchObject({
|
||||
type: PLAYLIST_TYPES.STALKER,
|
||||
url: 'http://portal.test/c',
|
||||
});
|
||||
expect(
|
||||
buildPlaylistRow({ _id: 'f', filePath: '/tmp/list.m3u' })?.type
|
||||
).toBe(PLAYLIST_TYPES.M3U_FILE);
|
||||
expect(buildPlaylistRow({ _id: 't' })?.type).toBe(
|
||||
PLAYLIST_TYPES.M3U_TEXT
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a document without an id', () => {
|
||||
expect(buildPlaylistRow({ title: 'No id' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Row codec for the `playlists` table. The whole renderer `Playlist`
|
||||
* document is stored as JSON in `payload`; the fields that metadata reads
|
||||
* need are projected into columns. The Electron database worker and the
|
||||
* self-hosted web backend both use this module, so both write the same rows.
|
||||
*/
|
||||
import * as schema from './schema';
|
||||
|
||||
export const PLAYLIST_TYPES = {
|
||||
XTREAM: 'xtream',
|
||||
STALKER: 'stalker',
|
||||
M3U_FILE: 'm3u-file',
|
||||
M3U_TEXT: 'm3u-text',
|
||||
M3U_URL: 'm3u-url',
|
||||
} as const;
|
||||
|
||||
export type AppPlaylistType =
|
||||
(typeof PLAYLIST_TYPES)[keyof typeof PLAYLIST_TYPES];
|
||||
const PLAYLIST_TYPE_VALUES = new Set<AppPlaylistType>(
|
||||
Object.values(PLAYLIST_TYPES)
|
||||
);
|
||||
|
||||
function getStringValue(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function getNumericValue(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseJsonValue<T>(
|
||||
value: string | null | undefined,
|
||||
fallback: T
|
||||
): T {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse JSON value from DB:', error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function getStringArrayValue(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function inferPlaylistType(playlist: Record<string, unknown>): AppPlaylistType {
|
||||
const explicitType = getStringValue(playlist['type']);
|
||||
if (
|
||||
explicitType &&
|
||||
PLAYLIST_TYPE_VALUES.has(explicitType as AppPlaylistType)
|
||||
) {
|
||||
return explicitType as AppPlaylistType;
|
||||
}
|
||||
|
||||
if (getStringValue(playlist['serverUrl'])) {
|
||||
return PLAYLIST_TYPES.XTREAM;
|
||||
}
|
||||
|
||||
if (getStringValue(playlist['macAddress'])) {
|
||||
return PLAYLIST_TYPES.STALKER;
|
||||
}
|
||||
|
||||
if (getStringValue(playlist['filePath'])) {
|
||||
return PLAYLIST_TYPES.M3U_FILE;
|
||||
}
|
||||
|
||||
if (getStringValue(playlist['url'])) {
|
||||
return PLAYLIST_TYPES.M3U_URL;
|
||||
}
|
||||
|
||||
return PLAYLIST_TYPES.M3U_TEXT;
|
||||
}
|
||||
|
||||
export function buildPlaylistRow(
|
||||
playlist: Record<string, unknown>
|
||||
): schema.NewPlaylist | null {
|
||||
const id =
|
||||
getStringValue(playlist['_id']) ?? getStringValue(playlist['id']);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = inferPlaylistType(playlist);
|
||||
const portalUrl = getStringValue(playlist['portalUrl']);
|
||||
const url = getStringValue(playlist['url']);
|
||||
const nowIso = new Date().toISOString();
|
||||
const updateDate = getNumericValue(playlist['updateDate']);
|
||||
|
||||
return {
|
||||
id,
|
||||
name:
|
||||
getStringValue(playlist['title']) ??
|
||||
getStringValue(playlist['name']) ??
|
||||
id,
|
||||
serverUrl: getStringValue(playlist['serverUrl']),
|
||||
username: getStringValue(playlist['username']),
|
||||
password: getStringValue(playlist['password']),
|
||||
lastUpdated:
|
||||
typeof updateDate === 'number'
|
||||
? new Date(updateDate).toISOString()
|
||||
: getStringValue(playlist['lastUpdated']),
|
||||
type,
|
||||
userAgent: getStringValue(playlist['userAgent']),
|
||||
origin: getStringValue(playlist['origin']),
|
||||
referrer: getStringValue(playlist['referrer']),
|
||||
filePath: getStringValue(playlist['filePath']),
|
||||
epgUrls:
|
||||
playlist['epgUrls'] !== undefined
|
||||
? JSON.stringify(getStringArrayValue(playlist['epgUrls']))
|
||||
: undefined,
|
||||
detectedEpgUrls:
|
||||
playlist['detectedEpgUrls'] !== undefined
|
||||
? JSON.stringify(
|
||||
getStringArrayValue(playlist['detectedEpgUrls'])
|
||||
)
|
||||
: undefined,
|
||||
manualEpgUrls:
|
||||
playlist['manualEpgUrls'] !== undefined
|
||||
? JSON.stringify(getStringArrayValue(playlist['manualEpgUrls']))
|
||||
: undefined,
|
||||
disabledEpgUrls:
|
||||
playlist['disabledEpgUrls'] !== undefined
|
||||
? JSON.stringify(
|
||||
getStringArrayValue(playlist['disabledEpgUrls'])
|
||||
)
|
||||
: undefined,
|
||||
autoRefresh: Boolean(playlist['autoRefresh']),
|
||||
macAddress: getStringValue(playlist['macAddress']),
|
||||
url: type === PLAYLIST_TYPES.STALKER ? (portalUrl ?? url) : url,
|
||||
portalUrl,
|
||||
count: getNumericValue(playlist['count']),
|
||||
importDate: getStringValue(playlist['importDate']),
|
||||
updateDate,
|
||||
position: getNumericValue(playlist['position']),
|
||||
favorites:
|
||||
playlist['favorites'] !== undefined
|
||||
? JSON.stringify(playlist['favorites'])
|
||||
: undefined,
|
||||
recentlyViewed:
|
||||
playlist['recentlyViewed'] !== undefined
|
||||
? JSON.stringify(playlist['recentlyViewed'])
|
||||
: undefined,
|
||||
payload: JSON.stringify(playlist),
|
||||
lastUsage: getStringValue(playlist['lastUsage']) ?? nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPlaylistItemCount(
|
||||
playlist: Record<string, unknown>
|
||||
): number | undefined {
|
||||
try {
|
||||
const value = playlist['playlist'];
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const items = Reflect.get(value, 'items');
|
||||
return Array.isArray(items) ? items.length : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAppPlaylist(
|
||||
row: schema.Playlist
|
||||
): Record<string, unknown> {
|
||||
const payload = parseJsonValue<Record<string, unknown> | null>(
|
||||
row.payload,
|
||||
null
|
||||
);
|
||||
const base = payload && typeof payload === 'object' ? payload : {};
|
||||
const favorites = parseJsonValue<unknown[]>(row.favorites, []);
|
||||
const recentlyViewed = parseJsonValue<unknown[]>(row.recentlyViewed, []);
|
||||
const epgUrls = getStringArrayValue(
|
||||
row.epgUrls
|
||||
? parseJsonValue<unknown[]>(row.epgUrls, [])
|
||||
: base['epgUrls']
|
||||
);
|
||||
const detectedEpgUrls = getStringArrayValue(
|
||||
row.detectedEpgUrls
|
||||
? parseJsonValue<unknown[]>(row.detectedEpgUrls, [])
|
||||
: (base['detectedEpgUrls'] ?? epgUrls)
|
||||
);
|
||||
const manualEpgUrls = getStringArrayValue(
|
||||
row.manualEpgUrls
|
||||
? parseJsonValue<unknown[]>(row.manualEpgUrls, [])
|
||||
: base['manualEpgUrls']
|
||||
);
|
||||
const disabledEpgUrls = getStringArrayValue(
|
||||
row.disabledEpgUrls
|
||||
? parseJsonValue<unknown[]>(row.disabledEpgUrls, [])
|
||||
: base['disabledEpgUrls']
|
||||
);
|
||||
const importDate =
|
||||
row.importDate ?? row.dateCreated ?? new Date().toISOString();
|
||||
const portalUrl =
|
||||
row.portalUrl ?? (row.type === PLAYLIST_TYPES.STALKER ? row.url : null);
|
||||
const updateDate =
|
||||
row.updateDate ??
|
||||
(row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined);
|
||||
|
||||
return {
|
||||
...base,
|
||||
_id: row.id,
|
||||
title:
|
||||
getStringValue(base['title']) ??
|
||||
getStringValue(base['name']) ??
|
||||
row.name,
|
||||
count: row.count ?? getNumericValue(base['count']) ?? 0,
|
||||
importDate: getStringValue(base['importDate']) ?? importDate,
|
||||
lastUsage:
|
||||
row.lastUsage ??
|
||||
getStringValue(base['lastUsage']) ??
|
||||
getStringValue(base['importDate']) ??
|
||||
importDate,
|
||||
favorites,
|
||||
recentlyViewed,
|
||||
autoRefresh: row.autoRefresh ?? Boolean(base['autoRefresh']),
|
||||
url:
|
||||
row.type === PLAYLIST_TYPES.M3U_URL
|
||||
? (row.url ?? getStringValue(base['url']))
|
||||
: getStringValue(base['url']),
|
||||
filePath: row.filePath ?? getStringValue(base['filePath']),
|
||||
epgUrls,
|
||||
detectedEpgUrls,
|
||||
manualEpgUrls,
|
||||
disabledEpgUrls,
|
||||
userAgent: row.userAgent ?? getStringValue(base['userAgent']),
|
||||
referrer: row.referrer ?? getStringValue(base['referrer']),
|
||||
origin: row.origin ?? getStringValue(base['origin']),
|
||||
updateDate,
|
||||
position: row.position ?? getNumericValue(base['position']),
|
||||
serverUrl: row.serverUrl ?? getStringValue(base['serverUrl']),
|
||||
username: row.username ?? getStringValue(base['username']),
|
||||
password: row.password ?? getStringValue(base['password']),
|
||||
macAddress: row.macAddress ?? getStringValue(base['macAddress']),
|
||||
portalUrl: portalUrl ?? getStringValue(base['portalUrl']),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The `DO UPDATE` half of an app-playlist upsert and the payload clock
|
||||
* reader, shared with the self-hosted web backend. The conditional writers
|
||||
* that complete this contract stay in the Electron backend's
|
||||
* `playlist-server-timezone.operations.ts` (issue #1562).
|
||||
*/
|
||||
import { sql, type SQL } from 'drizzle-orm';
|
||||
import * as schema from './schema';
|
||||
|
||||
export type PlaylistConflictUpdate = {
|
||||
[K in keyof schema.NewPlaylist]?: schema.NewPlaylist[K] | SQL;
|
||||
};
|
||||
|
||||
const stored = schema.playlists;
|
||||
|
||||
/**
|
||||
* The `DO UPDATE` half of an app-playlist upsert. A full upsert is built
|
||||
* from a snapshot the caller read earlier; when that snapshot carries no
|
||||
* panel clock while the stored row does — `setPlaylistServerTimezone`
|
||||
* landed in between — the stored clock is carried over as long as the row
|
||||
* still points at the same panel, so a favorites, recent-items or metadata
|
||||
* write cannot hand a clockless payload back to the past. A snapshot that
|
||||
* carries its own clock, or moves the source, wins as is. Nested CASE, not
|
||||
* AND: SQLite may reorder AND terms, and the json_* readers raise on a
|
||||
* malformed payload unless json_valid ran first.
|
||||
*/
|
||||
export function playlistConflictUpdate(
|
||||
row: schema.NewPlaylist,
|
||||
playlist: Record<string, unknown>
|
||||
): PlaylistConflictUpdate {
|
||||
if (readString(playlist['serverTimezone'])) {
|
||||
return row;
|
||||
}
|
||||
const excludedPayload = sql.raw(`excluded.${stored.payload.name}`);
|
||||
const payload = sql`CASE
|
||||
WHEN json_valid(${stored.payload})
|
||||
THEN CASE
|
||||
WHEN json_type(${stored.payload}, '$.serverTimezone') = 'text'
|
||||
AND ${stored.serverUrl} IS excluded.${sql.raw(stored.serverUrl.name)}
|
||||
AND ${stored.username} IS excluded.${sql.raw(stored.username.name)}
|
||||
AND ${stored.password} IS excluded.${sql.raw(stored.password.name)}
|
||||
THEN json_set(
|
||||
${excludedPayload},
|
||||
'$.serverTimezone',
|
||||
json_extract(${stored.payload}, '$.serverTimezone')
|
||||
)
|
||||
ELSE ${excludedPayload}
|
||||
END
|
||||
ELSE ${excludedPayload}
|
||||
END`;
|
||||
return { ...row, payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* The clock stored in a raw row's payload, for `DB_GET_PLAYLIST`: the
|
||||
* Xtream store seeds `currentPlaylist` from that read before (or without)
|
||||
* the account-info check that learns the value.
|
||||
*/
|
||||
export function readPayloadServerTimezone(
|
||||
payload: string | null | undefined
|
||||
): string | undefined {
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(payload);
|
||||
return parsed && typeof parsed === 'object'
|
||||
? readString((parsed as Record<string, unknown>)['serverTimezone'])
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './app-playlist-codec';
|
||||
export * from './app-playlist-conflict';
|
||||
@@ -9,6 +9,7 @@ import { existsSync, mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
IPTVNATOR_DATA_DIR_ENV,
|
||||
IPTVNATOR_E2E_DATA_DIR_ENV,
|
||||
getElectronConfigDirectory,
|
||||
getElectronUserDataPath,
|
||||
@@ -20,12 +21,15 @@ import {
|
||||
describe('path-utils', () => {
|
||||
let tempRoot: string;
|
||||
let originalEnvValue: string | undefined;
|
||||
let originalDataDirValue: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), 'iptvnator-path-utils-'));
|
||||
homedirMock.mockReturnValue(join(tempRoot, 'home'));
|
||||
originalEnvValue = process.env[IPTVNATOR_E2E_DATA_DIR_ENV];
|
||||
delete process.env[IPTVNATOR_E2E_DATA_DIR_ENV];
|
||||
originalDataDirValue = process.env[IPTVNATOR_DATA_DIR_ENV];
|
||||
delete process.env[IPTVNATOR_DATA_DIR_ENV];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -34,9 +38,41 @@ describe('path-utils', () => {
|
||||
} else {
|
||||
process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = originalEnvValue;
|
||||
}
|
||||
if (originalDataDirValue === undefined) {
|
||||
delete process.env[IPTVNATOR_DATA_DIR_ENV];
|
||||
} else {
|
||||
process.env[IPTVNATOR_DATA_DIR_ENV] = originalDataDirValue;
|
||||
}
|
||||
rmSync(tempRoot, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it('uses and creates IPTVNATOR_DATA_DIR as the data root', () => {
|
||||
const dataDir = join(tempRoot, 'volume');
|
||||
process.env[IPTVNATOR_DATA_DIR_ENV] = ` ${dataDir} `;
|
||||
|
||||
expect(getIptvnatorDataRoot()).toBe(dataDir);
|
||||
expect(existsSync(dataDir)).toBe(true);
|
||||
expect(getIptvnatorDatabasePath()).toBe(
|
||||
join(dataDir, 'databases', 'iptvnator.db')
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the E2E override win over IPTVNATOR_DATA_DIR', () => {
|
||||
const e2eDir = join(tempRoot, 'e2e');
|
||||
process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = e2eDir;
|
||||
process.env[IPTVNATOR_DATA_DIR_ENV] = join(tempRoot, 'volume');
|
||||
|
||||
expect(getIptvnatorDataRoot()).toBe(e2eDir);
|
||||
});
|
||||
|
||||
it('ignores a blank IPTVNATOR_DATA_DIR', () => {
|
||||
process.env[IPTVNATOR_DATA_DIR_ENV] = ' ';
|
||||
|
||||
expect(getIptvnatorDataRoot()).toBe(
|
||||
join(tempRoot, 'home', '.iptvnator')
|
||||
);
|
||||
});
|
||||
|
||||
it('uses and creates the E2E data dir override when the env variable is set', () => {
|
||||
const e2eDataDir = join(tempRoot, 'e2e-data');
|
||||
process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = e2eDataDir;
|
||||
|
||||
@@ -4,6 +4,12 @@ import { join } from 'path';
|
||||
|
||||
export const IPTVNATOR_E2E_DATA_DIR_ENV = 'IPTVNATOR_E2E_DATA_DIR';
|
||||
|
||||
/**
|
||||
* Operator-selected data root for deployments that keep the database on a
|
||||
* mounted volume, for example `/data`. The E2E override wins.
|
||||
*/
|
||||
export const IPTVNATOR_DATA_DIR_ENV = 'IPTVNATOR_DATA_DIR';
|
||||
|
||||
function ensureDirectory(dirPath: string): string {
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true });
|
||||
@@ -19,6 +25,12 @@ export function getIptvnatorDataRoot(): string {
|
||||
return ensureDirectory(e2eDataDir);
|
||||
}
|
||||
|
||||
const dataDir = process.env[IPTVNATOR_DATA_DIR_ENV]?.trim();
|
||||
|
||||
if (dataDir) {
|
||||
return ensureDirectory(dataDir);
|
||||
}
|
||||
|
||||
return ensureDirectory(join(homedir(), '.iptvnator'));
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,9 @@
|
||||
"@iptvnator/shared/database/schema": [
|
||||
"libs/shared/database/src/lib/schema.ts"
|
||||
],
|
||||
"@iptvnator/shared/database/app-playlist": [
|
||||
"libs/shared/database/src/lib/app-playlist.ts"
|
||||
],
|
||||
"@iptvnator/shared/database/path-utils": [
|
||||
"libs/shared/database/src/lib/path-utils.ts"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user