mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(server-storage): conflict-safe Stalker metadata migration on the server; newest document views in the move
The Stalker metadata migration wrote server rows back through the unconditional bulk upsert, from the list read: a device that edited a Stalker playlist in between (or a second browser migrating the same copied database) lost its change. Server rows are now read again one by one and written as conditional mutations on that version; a conflict leaves the flag unset, so the next start retries. Moving a document's history kept the server's copy of an item both held, even when the browser had viewed it later, and skipped the write when the length did not change. The combined history is now newest first by added_at, one entry per item, and any content change is written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
176108b682
commit
797aeae307
6 files changed
+170
-63
No files matched your search
@@ -357,13 +357,15 @@ settings and guards read them in every build.
|
||||
playlists this browser kept in IndexedDB (`planServerMigration`), Stalker
|
||||
rows from before the portal-mode flag normalized first (server rows get
|
||||
the same Stalker metadata migration once per server, as a desktop
|
||||
database copied there can predate it). A
|
||||
database copied there can predate it: each row read again and written as
|
||||
a conditional mutation, a conflict retried at the next start). A
|
||||
playlist the server already has, by id or by source (Xtream server and
|
||||
user, Stalker portal and MAC, M3U URL), merges into the server's copy; the
|
||||
others upload with their ids. A merged playlist gains the browser
|
||||
document's favorites and history (M3U favorites name channels by stream
|
||||
URL; older ones by a channel id that is random per import and is
|
||||
translated through the URL; Stalker items by id, category and series
|
||||
translated through the URL; recently viewed newest first by `added_at`,
|
||||
one per item with its newest view; Stalker items by id, category and series
|
||||
flag, since one provider category can hold a movie and a series of one
|
||||
id; nothing is dropped), and the legacy Xtream maps follow under
|
||||
the server id; each copy's bare legacy Xtream ids are typed from its own
|
||||
|
||||
@@ -578,58 +578,83 @@ describe('PlaylistsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('migrates legacy Stalker portal flags on the server too', async () => {
|
||||
describe('legacy Stalker portal flags on the server', () => {
|
||||
// A desktop database copied to the server, from before the flag.
|
||||
let storedPlaylists: Playlist[] = [
|
||||
{
|
||||
_id: 'stalker-1',
|
||||
title: 'Copied Stalker',
|
||||
count: 0,
|
||||
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
|
||||
lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(),
|
||||
autoRefresh: false,
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'http://example.com/stalker_portal/c/',
|
||||
} as Playlist,
|
||||
];
|
||||
const appState = new Map<string, string>();
|
||||
const server = {
|
||||
dbGetAppPlaylists: jest.fn(async () => storedPlaylists),
|
||||
dbGetAppState: jest.fn(
|
||||
async (key: string) => appState.get(key) ?? null
|
||||
),
|
||||
dbSetAppState: jest.fn(async (key: string, value: string) => {
|
||||
appState.set(key, value);
|
||||
}),
|
||||
dbUpsertAppPlaylist: jest.fn(),
|
||||
dbUpsertAppPlaylists: jest.fn(async (playlists: Playlist[]) => {
|
||||
storedPlaylists = playlists;
|
||||
}),
|
||||
dbRecoverLegacyPlaylists: jest.fn(),
|
||||
};
|
||||
testWindow.electron = undefined;
|
||||
const service = createService();
|
||||
Object.assign(service as object, {
|
||||
storageBackend: {
|
||||
kind: () => 'server',
|
||||
documentStore: () => server,
|
||||
},
|
||||
const copied = {
|
||||
_id: 'stalker-1',
|
||||
title: 'Copied Stalker',
|
||||
count: 0,
|
||||
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
|
||||
lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(),
|
||||
autoRefresh: false,
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'http://example.com/stalker_portal/c/',
|
||||
} as Playlist;
|
||||
|
||||
function serverService(
|
||||
fresh: Playlist,
|
||||
upsert: jest.Mock = jest.fn(async () => ({ success: true }))
|
||||
) {
|
||||
const appState = new Map<string, string>();
|
||||
const server = {
|
||||
dbGetAppPlaylists: jest.fn(async () => [copied]),
|
||||
// Another device may have changed it since the list read.
|
||||
dbGetAppPlaylist: jest.fn(async () => fresh),
|
||||
dbGetAppState: jest.fn(
|
||||
async (key: string) => appState.get(key) ?? null
|
||||
),
|
||||
dbSetAppState: jest.fn(async (key: string, value: string) => {
|
||||
appState.set(key, value);
|
||||
}),
|
||||
dbUpsertAppPlaylist: upsert,
|
||||
dbUpsertAppPlaylists: jest.fn(),
|
||||
dbRecoverLegacyPlaylists: jest.fn(),
|
||||
};
|
||||
testWindow.electron = undefined;
|
||||
const service = createService();
|
||||
Object.assign(service as object, {
|
||||
storageBackend: {
|
||||
kind: () => 'server',
|
||||
documentStore: () => server,
|
||||
},
|
||||
});
|
||||
return { service, server, appState };
|
||||
}
|
||||
|
||||
it('are written per row on a fresh read, as conditional mutations', async () => {
|
||||
const edited = { ...copied, title: 'Renamed elsewhere' };
|
||||
const { service, server, appState } = serverService(edited);
|
||||
|
||||
await firstValueFrom(service.getAllPlaylists());
|
||||
|
||||
// Never the stale list copy, never an unconditional bulk write.
|
||||
expect(server.dbUpsertAppPlaylists).not.toHaveBeenCalled();
|
||||
expect(server.dbUpsertAppPlaylist).toHaveBeenCalledWith({
|
||||
...edited,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
expect(appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)).toBe(
|
||||
'1'
|
||||
);
|
||||
// The desktop-only steps stay with the desktop.
|
||||
expect(server.dbRecoverLegacyPlaylists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await expect(
|
||||
firstValueFrom(service.getAllPlaylists())
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
_id: 'stalker-1',
|
||||
isFullStalkerPortal: true,
|
||||
}),
|
||||
]);
|
||||
it('stay unflagged for the next start after a conflict', async () => {
|
||||
const { service, appState } = serverService(
|
||||
copied,
|
||||
jest.fn(async () => {
|
||||
throw new PlaylistWriteConflict('stalker-1');
|
||||
})
|
||||
);
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
expect(appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)).toBe(
|
||||
'1'
|
||||
);
|
||||
// The desktop-only steps stay with the desktop.
|
||||
expect(server.dbRecoverLegacyPlaylists).not.toHaveBeenCalled();
|
||||
await firstValueFrom(service.getAllPlaylists());
|
||||
|
||||
expect(
|
||||
appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not rerun the SQLite Stalker metadata migration after the flag is set', async () => {
|
||||
|
||||
@@ -304,7 +304,20 @@ export class PlaylistsService {
|
||||
const updates =
|
||||
this.collectStalkerMetadataMigrationUpdates(storedPlaylists);
|
||||
|
||||
if (updates.length > 0) {
|
||||
if (this.storageBackend.kind() === 'server') {
|
||||
// Shared with other devices: each row is read again and
|
||||
// written on that version, never over a newer copy. A
|
||||
// conflict leaves the flag unset, so the next start retries.
|
||||
for (const { _id } of updates) {
|
||||
const fresh = await store.dbGetAppPlaylist(_id);
|
||||
const [update] = fresh
|
||||
? this.collectStalkerMetadataMigrationUpdates([fresh])
|
||||
: [];
|
||||
if (update) {
|
||||
await store.dbUpsertAppPlaylist(update);
|
||||
}
|
||||
}
|
||||
} else if (updates.length > 0) {
|
||||
await store.dbUpsertAppPlaylists(updates);
|
||||
}
|
||||
|
||||
|
||||
@@ -151,12 +151,19 @@ export function combineRecentViews(values: readonly unknown[]): unknown {
|
||||
);
|
||||
}
|
||||
|
||||
/** Items with a `viewedAt` (recently viewed), newest first; stable. */
|
||||
export function newestViewsFirst<T>(items: readonly T[]): T[] {
|
||||
/**
|
||||
* Recently viewed items newest first, by `viewedAt` (Xtream maps) or
|
||||
* `added_at` (playlist documents: an ISO date or epoch milliseconds);
|
||||
* stable, undated items after dated ones.
|
||||
*/
|
||||
export function newestViewsFirst<T>(
|
||||
items: readonly T[],
|
||||
field: 'viewedAt' | 'added_at' = 'viewedAt'
|
||||
): T[] {
|
||||
const viewedAt = (item: unknown) => {
|
||||
const at = isPlainObject(item)
|
||||
? Date.parse(String(item['viewedAt']))
|
||||
: NaN;
|
||||
const value = isPlainObject(item) ? item[field] : undefined;
|
||||
const at =
|
||||
typeof value === 'number' ? value : Date.parse(String(value));
|
||||
return Number.isNaN(at) ? 0 : at;
|
||||
};
|
||||
return [...items].sort((a, b) => viewedAt(b) - viewedAt(a));
|
||||
|
||||
@@ -300,6 +300,55 @@ describe('ServerMigrationService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a document's newest view of an item both copies hold", async () => {
|
||||
const view = (day: number, extra = {}) => ({
|
||||
source: 'm3u',
|
||||
id: 'b1',
|
||||
url: 'http://s/1',
|
||||
title: 'One',
|
||||
category_id: 'live',
|
||||
added_at: `2026-10-0${day}T20:00:00.000Z`,
|
||||
...extra,
|
||||
});
|
||||
TestBed.overrideProvider(NgxIndexedDBService, {
|
||||
useValue: {
|
||||
getAll: () =>
|
||||
of([
|
||||
{
|
||||
...browserPlaylists[0],
|
||||
recentlyViewed: [
|
||||
view(3, { poster_url: 'new.png' }),
|
||||
],
|
||||
playlist: {
|
||||
items: [{ id: 'b1', url: 'http://s/1' }],
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([
|
||||
{ _id: 'server-m', title: 'Family', url: browserPlaylists[0].url },
|
||||
]);
|
||||
const stored = {
|
||||
_id: 'server-m',
|
||||
title: 'Family',
|
||||
url: browserPlaylists[0].url,
|
||||
favorites: [],
|
||||
// The same channel, watched there before this browser's view.
|
||||
recentlyViewed: [view(1, { id: 's1' })],
|
||||
playlist: { items: [{ id: 's1', url: 'http://s/1' }] },
|
||||
};
|
||||
session.documentStore.dbGetAppPlaylist.mockResolvedValue(stored);
|
||||
|
||||
await TestBed.inject(ServerMigrationService).moveBrowserData();
|
||||
|
||||
// Not skipped for an unchanged length: the newer view replaces it.
|
||||
expect(session.documentStore.dbUpsertAppPlaylist).toHaveBeenCalledWith({
|
||||
...stored,
|
||||
recentlyViewed: [view(3, { id: 's1', poster_url: 'new.png' })],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the move on offer when a playlist could not be uploaded', async () => {
|
||||
session.documentStore.dbUpsertAppPlaylist
|
||||
.mockRejectedValueOnce(new Error('413'))
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
combineRecentViews,
|
||||
combineUserData,
|
||||
newestViewsFirst,
|
||||
remapChannelReferences,
|
||||
retargetUserData,
|
||||
unionItems,
|
||||
@@ -178,18 +179,28 @@ export class ServerMigrationService {
|
||||
serverChannels
|
||||
)
|
||||
),
|
||||
// Newest first, one per item: its newest view (the server's on
|
||||
// a tie), as the app keeps the list.
|
||||
recentlyViewed: unionItems(
|
||||
stored.recentlyViewed ?? [],
|
||||
remapChannelReferences(
|
||||
recentlyViewed,
|
||||
browserChannels,
|
||||
serverChannels
|
||||
newestViewsFirst(
|
||||
[
|
||||
...(stored.recentlyViewed ?? []),
|
||||
...remapChannelReferences(
|
||||
recentlyViewed,
|
||||
browserChannels,
|
||||
serverChannels
|
||||
),
|
||||
],
|
||||
'added_at'
|
||||
)
|
||||
),
|
||||
};
|
||||
// Also when only a newer view of an item it has changed.
|
||||
if (
|
||||
next.favorites.length !== (stored.favorites ?? []).length ||
|
||||
next.recentlyViewed.length !== (stored.recentlyViewed ?? []).length
|
||||
JSON.stringify(next.favorites) !==
|
||||
JSON.stringify(stored.favorites ?? []) ||
|
||||
JSON.stringify(next.recentlyViewed) !==
|
||||
JSON.stringify(stored.recentlyViewed ?? [])
|
||||
) {
|
||||
await this.session.documentStore.dbUpsertAppPlaylist(next);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user