mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(xtream): enforce restore preflight and favorite acknowledgements
This commit is contained in:
1 parent
fcbf57db3e
commit
a9955ce781
13 files changed
+212
-12
No files matched your search
@@ -60,8 +60,9 @@ waiting for another import. Blank catalog fields retain their saved values.
|
||||
Conflicting known TMDB IDs or release years prevent automatic reconnection;
|
||||
changed provider IDs never reconnect by title.
|
||||
Adding a conflicting replacement returns an explicit failure and retains the
|
||||
older favorite. The detail toggle stays inactive and explains that the older
|
||||
entry must be removed from Favorites before the replacement can be saved.
|
||||
older favorite. Detail and recent-collection toggles stay inactive and explain
|
||||
that the older entry must be removed from Favorites before the replacement can
|
||||
be saved. Collection hearts change only after the storage write succeeds.
|
||||
|
||||
`catalog_status` is `present`, `missing`, or `unknown`. Only a completed import
|
||||
of the relevant type can prove absence. Failed, cancelled and in-progress
|
||||
@@ -99,6 +100,10 @@ favorite conversion bridge; activity reads also require playlist lookup. The
|
||||
SQLite capability also requires the unfiltered favorite backup reader. An
|
||||
unsupported atomic restore performs no favorite writes: a fresh archive stays
|
||||
parked, while an older pending restore is retained without being overwritten.
|
||||
Subsequent collection reads and early catalog imports must enforce the same
|
||||
preflight before replaying those favorites. Ordinary reads still return existing
|
||||
SQLite records. Exports that need to materialize parked favorites fail while
|
||||
atomic restore is unsupported; exports with no pending favorites remain available.
|
||||
|
||||
Records already deleted by an older build cannot be recovered without a
|
||||
surviving pending state or backup. Opening an Xtream section may initialize
|
||||
|
||||
+33
@@ -12,10 +12,14 @@ import {
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
|
||||
import { UnifiedFavoritesDataService } from './unified-favorites-data.service';
|
||||
import { UnifiedCollectionDataService } from './unified-collection-data.service';
|
||||
import { UnifiedRecentDataService } from './unified-recent-data.service';
|
||||
|
||||
describe('UnifiedFavoritesDataService', () => {
|
||||
let service: UnifiedFavoritesDataService;
|
||||
let electronApi: {
|
||||
dbGetPlaylist: jest.Mock;
|
||||
dbRestoreXtreamFavorites: jest.Mock;
|
||||
dbAddFavorite: jest.Mock;
|
||||
dbAddRecentItem: jest.Mock;
|
||||
dbClearPlaylistRecentItems: jest.Mock;
|
||||
@@ -108,6 +112,8 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
electronApi = {
|
||||
dbGetPlaylist: jest.fn(),
|
||||
dbRestoreXtreamFavorites: jest.fn(),
|
||||
dbAddFavorite: jest.fn().mockResolvedValue({ success: true }),
|
||||
dbAddRecentItem: jest.fn(),
|
||||
dbClearPlaylistRecentItems: jest.fn(),
|
||||
@@ -198,6 +204,8 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
UnifiedFavoritesDataService,
|
||||
UnifiedCollectionDataService,
|
||||
{ provide: UnifiedRecentDataService, useValue: {} },
|
||||
{
|
||||
provide: Store,
|
||||
useValue: store,
|
||||
@@ -677,6 +685,31 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a recent item heart empty when SQLite rejects its saved identity', async () => {
|
||||
const item: UnifiedCollectionItem = {
|
||||
uid: 'xtream::xtream-1::movie:101',
|
||||
name: 'Replacement movie',
|
||||
contentType: 'movie',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream One',
|
||||
xtreamId: 101,
|
||||
contentId: 42,
|
||||
};
|
||||
electronApi.dbAddFavorite.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'XTREAM_FAVORITE_IDENTITY_CONFLICT',
|
||||
});
|
||||
const collection = TestBed.inject(UnifiedCollectionDataService);
|
||||
await expect(collection.toggleFavorite(item)).rejects.toThrow(
|
||||
'XTREAM_FAVORITE_IDENTITY_CONFLICT'
|
||||
);
|
||||
expect(collection.favoriteUidSet().has(item.uid)).toBe(false);
|
||||
electronApi.dbAddFavorite.mockResolvedValue({ success: true });
|
||||
await collection.toggleFavorite(item);
|
||||
expect(collection.favoriteUidSet().has(item.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the Xtream id as the favorite key in PWA when cached content is cold', async () => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: undefined,
|
||||
|
||||
@@ -162,11 +162,14 @@ export class UnifiedFavoritesDataService {
|
||||
return;
|
||||
}
|
||||
|
||||
await electron.dbAddFavorite(
|
||||
const result = await electron.dbAddFavorite(
|
||||
contentId,
|
||||
item.playlistId,
|
||||
item.posterUrl ?? item.logo ?? undefined
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? 'Favorite could not be saved');
|
||||
}
|
||||
}
|
||||
|
||||
private async addStalkerFavorite(
|
||||
|
||||
@@ -99,6 +99,8 @@ describe('UnifiedRecentDataService', () => {
|
||||
|
||||
const createPortalActivityElectronApi = (): Window['electron'] =>
|
||||
({
|
||||
dbGetPlaylist: jest.fn(),
|
||||
dbRestoreXtreamFavorites: jest.fn(),
|
||||
dbGetRecentlyViewed: jest.fn(),
|
||||
dbClearRecentlyViewed: jest.fn(),
|
||||
dbGetAllGlobalFavorites: jest.fn(),
|
||||
|
||||
+37
@@ -8,6 +8,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatButtonToggleGroup } from '@angular/material/button-toggle';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
liveSidebarStateStorageKey,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
UnifiedCollectionDataService,
|
||||
UnifiedFavoritesDataService,
|
||||
UnifiedRecentDataService,
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
@@ -158,6 +160,7 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
const dialogService = {
|
||||
openConfirmDialog: jest.fn(),
|
||||
};
|
||||
const snackBar = { open: jest.fn() };
|
||||
const workspaceViewCommands = {
|
||||
registerCommand: jest.fn(),
|
||||
};
|
||||
@@ -251,6 +254,7 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{ provide: MatSnackBar, useValue: snackBar },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: route,
|
||||
@@ -323,6 +327,39 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
fixture.componentRef.setInput('defaultScope', 'all');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'XTREAM_FAVORITE_IDENTITY_CONFLICT',
|
||||
'PORTALS.FAVORITE_IDENTITY_CONFLICT',
|
||||
],
|
||||
['SQLite write failed', 'PORTALS.FAVORITE_SAVE_FAILED'],
|
||||
])(
|
||||
'reports %s without filling the recent favorite heart',
|
||||
async (error, message) => {
|
||||
fixture.componentRef.setInput('mode', 'recent');
|
||||
const item: UnifiedCollectionItem = {
|
||||
uid: 'xtream::source::live:7',
|
||||
name: 'Recent channel',
|
||||
sourceType: 'xtream',
|
||||
contentType: 'live',
|
||||
playlistId: 'source',
|
||||
playlistName: 'Source',
|
||||
contentId: 42,
|
||||
};
|
||||
favoritesData.addFavorite.mockRejectedValueOnce(new Error(error));
|
||||
await expect(
|
||||
fixture.componentInstance.onFavoriteToggled(item)
|
||||
).resolves.toBeUndefined();
|
||||
expect(snackBar.open).toHaveBeenCalledWith(message, undefined, {
|
||||
duration: 6000,
|
||||
});
|
||||
const data = fixture.debugElement.injector.get(
|
||||
UnifiedCollectionDataService
|
||||
);
|
||||
expect(data.favoriteUidSet().has(item.uid)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('uses compact loading rows when the runtime has no EPG support', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
|
||||
+20
-3
@@ -1,4 +1,7 @@
|
||||
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
isCatalogUnavailable,
|
||||
XTREAM_FAVORITE_IDENTITY_CONFLICT,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import {
|
||||
AfterContentInit,
|
||||
@@ -18,9 +21,10 @@ import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
ChannelListSkeletonComponent,
|
||||
MenuItemRadioCheckDirective,
|
||||
@@ -105,6 +109,8 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly hostElement = inject(ElementRef<HTMLElement>);
|
||||
private readonly data = inject(UnifiedCollectionDataService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly liveSidebarStateService = inject(
|
||||
LiveLayoutSidebarStateService
|
||||
@@ -342,7 +348,18 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.data.toggleFavorite(item);
|
||||
try {
|
||||
await this.data.toggleFavorite(item);
|
||||
} catch (error) {
|
||||
const key =
|
||||
error instanceof Error &&
|
||||
error.message === XTREAM_FAVORITE_IDENTITY_CONFLICT
|
||||
? 'PORTALS.FAVORITE_IDENTITY_CONFLICT'
|
||||
: 'PORTALS.FAVORITE_SAVE_FAILED';
|
||||
this.snackBar.open(this.translate.instant(key), undefined, {
|
||||
duration: 6000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clearAllCurrent(): void {
|
||||
|
||||
@@ -689,6 +689,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
playlistId: string,
|
||||
state: XtreamPendingRestoreState
|
||||
): Promise<void> {
|
||||
this.playbackService.assertSupportsAtomicReplacement();
|
||||
await this.dbService.restoreXtreamFavoriteSnapshots(
|
||||
playlistId,
|
||||
state.favorites,
|
||||
|
||||
+22
@@ -379,6 +379,28 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects the early pending-favorite restore before calling the database on an unsupported bridge', async () => {
|
||||
harness.playbackService.assertSupportsAtomicReplacement.mockImplementation(
|
||||
() => {
|
||||
throw new Error(
|
||||
'Playback position replacement method is unavailable'
|
||||
);
|
||||
}
|
||||
);
|
||||
await expect(
|
||||
harness.dataSource.restoreFavoriteSnapshots(playlistId, {
|
||||
favorites: [{ xtreamId: 7, contentType: 'movie' }],
|
||||
favoritesRestoreId: 'parked-backup',
|
||||
hiddenCategories: [],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
})
|
||||
).rejects.toThrow('unavailable');
|
||||
expect(
|
||||
harness.dbService.restoreXtreamFavoriteSnapshots
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects deferred restore when position replacement fails, retaining the caller retry', async () => {
|
||||
const restoreState = {
|
||||
hiddenCategories: [],
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('DatabaseService browser guards', () => {
|
||||
JSON.stringify(pending)
|
||||
);
|
||||
const bridge = {
|
||||
dbReplaceAllPlaybackPositions: jest.fn(),
|
||||
dbGetPlaylist: jest.fn().mockResolvedValue({ type: 'xtream' }),
|
||||
dbRestoreXtreamFavorites: jest
|
||||
.fn()
|
||||
@@ -115,6 +116,7 @@ describe('DatabaseService browser guards', () => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: {
|
||||
dbReplaceAllPlaybackPositions: jest.fn(),
|
||||
dbGetPlaylist: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'xtream' }),
|
||||
@@ -164,6 +166,7 @@ describe('DatabaseService browser guards', () => {
|
||||
const rows = [{ favorite_id: 1, title: 'Saved favorite' }];
|
||||
const read = jest.fn().mockResolvedValue(rows);
|
||||
const bridge = {
|
||||
dbReplaceAllPlaybackPositions: jest.fn(),
|
||||
dbGetPlaylist: jest.fn().mockResolvedValue({ type: 'xtream' }),
|
||||
dbRestoreXtreamFavorites: jest
|
||||
.fn()
|
||||
@@ -231,6 +234,62 @@ describe('DatabaseService browser guards', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('does not replay a parked backup through collection, export or direct snapshot reads until restore is supported', async () => {
|
||||
const favorites = [{ contentType: 'movie' as const, xtreamId: 7 }];
|
||||
const pending = JSON.stringify({
|
||||
favorites,
|
||||
favoritesRestoreId: 'parked-backup',
|
||||
});
|
||||
localStorage.setItem('xtream-restore-playlist-1', pending);
|
||||
const rows = [{ favorite_id: 1, title: 'Previously saved favorite' }];
|
||||
const bridge = {
|
||||
dbGetPlaylist: jest.fn().mockResolvedValue({ type: 'xtream' }),
|
||||
dbRestoreXtreamFavorites: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true }),
|
||||
dbGetFavorites: jest.fn().mockResolvedValue(rows),
|
||||
dbGetGlobalFavorites: jest.fn().mockResolvedValue(rows),
|
||||
dbGetAllGlobalFavorites: jest.fn().mockResolvedValue(rows),
|
||||
dbGetFavoritesForBackup: jest.fn().mockResolvedValue(rows),
|
||||
dbReplaceAllPlaybackPositions: undefined as jest.Mock | undefined,
|
||||
};
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: bridge,
|
||||
});
|
||||
for (const method of [
|
||||
'getFavorites',
|
||||
'getGlobalFavorites',
|
||||
'getAllGlobalFavorites',
|
||||
] as const) {
|
||||
await expect(service[method]('playlist-1')).resolves.toEqual(rows);
|
||||
}
|
||||
expect(bridge.dbRestoreXtreamFavorites).not.toHaveBeenCalled();
|
||||
await expect(service.getFavoritesOrThrow('playlist-1')).rejects.toThrow(
|
||||
'replacement method is unavailable'
|
||||
);
|
||||
await expect(
|
||||
service.restoreXtreamFavoriteSnapshots(
|
||||
'playlist-1',
|
||||
favorites,
|
||||
'parked-backup'
|
||||
)
|
||||
).rejects.toThrow('replacement method is unavailable');
|
||||
expect(bridge.dbRestoreXtreamFavorites).not.toHaveBeenCalled();
|
||||
expect(bridge.dbGetFavoritesForBackup).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem('xtream-restore-playlist-1')).toBe(pending);
|
||||
bridge.dbReplaceAllPlaybackPositions = jest.fn();
|
||||
await expect(
|
||||
service.getFavoritesOrThrow('playlist-1')
|
||||
).resolves.toEqual(rows);
|
||||
expect(bridge.dbRestoreXtreamFavorites).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
favorites,
|
||||
'parked-backup'
|
||||
);
|
||||
expect(bridge.dbReplaceAllPlaybackPositions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores orphan legacy state but never treats failed conversion as an empty backup', async () => {
|
||||
localStorage.setItem(
|
||||
'xtream-restore-orphan',
|
||||
@@ -239,6 +298,7 @@ describe('DatabaseService browser guards', () => {
|
||||
})
|
||||
);
|
||||
const bridge = {
|
||||
dbReplaceAllPlaybackPositions: jest.fn(),
|
||||
dbGetPlaylist: jest.fn().mockResolvedValue(null),
|
||||
dbRestoreXtreamFavorites: jest
|
||||
.fn()
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { materializePendingXtreamFavorites } from './xtream-pending-favorites';
|
||||
import {
|
||||
assertXtreamFavoriteRestoreSupported,
|
||||
materializePendingXtreamFavorites,
|
||||
} from './xtream-pending-favorites';
|
||||
import type {
|
||||
ElectronBridgeXtreamFavorite,
|
||||
XtreamFavoriteRemovalRef,
|
||||
@@ -303,6 +306,7 @@ export class DatabaseService {
|
||||
restoreId?: string
|
||||
): Promise<void> {
|
||||
if (favorites.length === 0) return;
|
||||
assertXtreamFavoriteRestoreSupported(window.electron);
|
||||
const result = await window.electron.dbRestoreXtreamFavorites(
|
||||
playlistId,
|
||||
favorites,
|
||||
|
||||
@@ -436,10 +436,8 @@ describe('PlaylistBackupService export → import round-trip', () => {
|
||||
expect(
|
||||
collaborators.pendingRestoreService.getOrThrow('xtream-1')
|
||||
).toEqual({ ...userState, favoritesRestoreId: expect.any(String) });
|
||||
const parkedExport = await service.exportBackup();
|
||||
expect(xtreamEntry(parkedExport.manifest).userState).toEqual(
|
||||
userState
|
||||
);
|
||||
await expect(service.exportBackup()).rejects.toThrow('unavailable');
|
||||
expect(favorites).not.toHaveBeenCalled();
|
||||
|
||||
preflight.mockImplementation(() => undefined);
|
||||
collaborators.databaseService.getXtreamImportStatus = async () =>
|
||||
|
||||
@@ -324,6 +324,9 @@ export class PlaylistBackupService {
|
||||
// then export the current saved records (including explicit removals).
|
||||
let favorites = pending.favorites;
|
||||
if (this.runtime.supportsXtreamSqliteDataSource) {
|
||||
if (favorites.length) {
|
||||
this.playbackPositionService.assertSupportsAtomicReplacement();
|
||||
}
|
||||
await this.databaseService.restoreXtreamFavoriteSnapshots(
|
||||
playlist._id,
|
||||
pending.favorites,
|
||||
|
||||
@@ -4,12 +4,26 @@ import {
|
||||
normalizeXtreamPendingRestoreState,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
type FavoriteRestoreSupport = Partial<
|
||||
Pick<ElectronBridgeApi, 'dbReplaceAllPlaybackPositions'>
|
||||
>;
|
||||
|
||||
/** Parked archives must not bypass the restore-only atomic replacement preflight. */
|
||||
export function assertXtreamFavoriteRestoreSupported(
|
||||
bridge: FavoriteRestoreSupport
|
||||
): void {
|
||||
if (typeof bridge.dbReplaceAllPlaybackPositions !== 'function') {
|
||||
throw new Error('Playback position replacement method is unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads must recover older parked favorites without entering a network bootstrap. */
|
||||
export async function materializePendingXtreamFavorites(
|
||||
bridge: Pick<
|
||||
ElectronBridgeApi,
|
||||
'dbGetPlaylist' | 'dbRestoreXtreamFavorites'
|
||||
>,
|
||||
> &
|
||||
FavoriteRestoreSupport,
|
||||
playlistId?: string,
|
||||
failureMode: 'throw' | 'continue' = 'throw'
|
||||
): Promise<void> {
|
||||
@@ -49,6 +63,7 @@ export async function materializePendingXtreamFavorites(
|
||||
// Never recreate a playlist or let an orphan block other saved entries.
|
||||
const playlist = await bridge.dbGetPlaylist(id);
|
||||
if (!playlist || playlist.type !== 'xtream') continue;
|
||||
assertXtreamFavoriteRestoreSupported(bridge);
|
||||
const result = await bridge.dbRestoreXtreamFavorites(
|
||||
id,
|
||||
state.favorites,
|
||||
|
||||
Reference in new issue
Block a user