mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(favorites): persist custom drag-and-drop order for Xtream favorites (#1143)
* fix(favorites): persist custom drag-and-drop order for Xtream favorites
Prepared-statement writes dispatched via drizzle's `.execute()` on the
better-sqlite3 driver return a promise and defer the write to a microtask.
Inside a synchronous `db.transaction(() => ...)` callback (which cannot
await), the transaction commits before that promise settles, so the write
is a silent no-op — no error, no rows changed.
This bit `reorderGlobalFavorites`: the custom favorites order never
persisted for the per-playlist ("This playlist") Xtream scope, which relies
solely on the `favorites.position` column. The global ("All playlists")
scope masked the bug because it also persists an order to the `appState`
`global-favorites-channel-order-v1` key and re-applies it on read.
`removeRecentItemsBatch` had the same latent bug — batch "clear recent
items" silently did nothing.
Switch both writers to synchronous `.run()`. Add regression coverage that
asserts `.run()` (not `.execute()`) is used and would fail on the old
behavior, and document the gotcha in the DB worker architecture doc.
Verified over CDP against a live Electron instance: reorder writes
positions 0..N, and the order survives navigation and a full reload.
Fixes #1137
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(favorites): scope reorder position writes by playlist
The global favorites reorder wrote the new position filtering only by
content_id, so two Xtream playlists holding a favorite with the same
content_id would clobber each other's persisted order (greptile P1).
Thread playlist_id through the whole reorder path — the renderer builder
(UnifiedCollectionItem already carries playlistId), the IPC contract
(ElectronBridgeFavoriteReorderUpdate + inline payload types), the worker
op — and scope the prepared UPDATE by (contentId, playlistId), matching
the favorites composite unique index.
Tests: favorites.operations.spec asserts the playlistId placeholder and
per-row playlistId payload; preload contract fixture updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(favorites): include playlist_id in workspace global favorites reorder payload
The workspace global-favorites reorder path still sent updates with only
content_id and position. Since the backend UPDATE is now scoped by
(contentId, playlistId), that payload binds an undefined playlist id and
matches no rows — the DB write silently no-ops (flagged by Greptile P1).
Also scope the prepared-statement example in the sqlite-db-worker gotcha
doc by (contentId, playlistId) so it no longer documents the
cross-playlist rewrite this PR fixes (flagged by Codex P3).
Regression spec asserts the reorder payload carries playlist_id per item
(fails on the old payload shape) and that the appState uid order is
still persisted for non-Xtream items.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
f856226dc3
commit
54265755ee
14 files changed
+309
-18
No files matched your search
+116
@@ -0,0 +1,116 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
|
||||
import { UnifiedFavoriteChannel } from '@iptvnator/portal/shared/util';
|
||||
import { GlobalFavoritesService } from './global-favorites.service';
|
||||
|
||||
describe('GlobalFavoritesService', () => {
|
||||
let service: GlobalFavoritesService;
|
||||
let electronApi: {
|
||||
dbReorderGlobalFavorites: jest.Mock;
|
||||
dbSetAppState: jest.Mock;
|
||||
};
|
||||
|
||||
const makeChannel = (
|
||||
overrides: Partial<UnifiedFavoriteChannel> &
|
||||
Pick<UnifiedFavoriteChannel, 'uid' | 'sourceType' | 'playlistId'>
|
||||
): UnifiedFavoriteChannel => ({
|
||||
name: 'Channel',
|
||||
logo: null,
|
||||
playlistName: 'Playlist',
|
||||
addedAt: new Date(0).toISOString(),
|
||||
position: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
electronApi = {
|
||||
dbReorderGlobalFavorites: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true }),
|
||||
dbSetAppState: jest.fn().mockResolvedValue({ success: true }),
|
||||
};
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: electronApi as unknown as Window['electron'],
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
GlobalFavoritesService,
|
||||
{ provide: Store, useValue: { select: jest.fn() } },
|
||||
{ provide: DatabaseService, useValue: {} },
|
||||
{ provide: PlaylistsService, useValue: {} },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: (key: string) => key },
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(GlobalFavoritesService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorder', () => {
|
||||
it('sends playlist-scoped position updates for Xtream favorites', async () => {
|
||||
// The backend UPDATE filters by (contentId, playlistId); a payload
|
||||
// without playlist_id silently matches no rows (PR #1143 review).
|
||||
const channels: UnifiedFavoriteChannel[] = [
|
||||
makeChannel({
|
||||
uid: 'xtream::playlist-b::20',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'playlist-b',
|
||||
contentId: 202,
|
||||
}),
|
||||
makeChannel({
|
||||
uid: 'm3u::playlist-m::url',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'playlist-m',
|
||||
}),
|
||||
makeChannel({
|
||||
uid: 'xtream::playlist-a::10',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'playlist-a',
|
||||
contentId: 101,
|
||||
}),
|
||||
];
|
||||
|
||||
await service.reorder(channels);
|
||||
|
||||
expect(electronApi.dbReorderGlobalFavorites).toHaveBeenCalledWith([
|
||||
{ content_id: 202, playlist_id: 'playlist-b', position: 0 },
|
||||
{ content_id: 101, playlist_id: 'playlist-a', position: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('persists the full uid order and skips the DB write without Xtream items', async () => {
|
||||
const channels: UnifiedFavoriteChannel[] = [
|
||||
makeChannel({
|
||||
uid: 'm3u::playlist-m::url',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'playlist-m',
|
||||
}),
|
||||
makeChannel({
|
||||
uid: 'stalker::playlist-s::5',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'playlist-s',
|
||||
}),
|
||||
];
|
||||
|
||||
await service.reorder(channels);
|
||||
|
||||
expect(electronApi.dbReorderGlobalFavorites).not.toHaveBeenCalled();
|
||||
expect(electronApi.dbSetAppState).toHaveBeenCalledWith(
|
||||
'global-favorites-channel-order-v1',
|
||||
JSON.stringify(['m3u::playlist-m::url', 'stalker::playlist-s::5'])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -141,6 +141,9 @@ export class GlobalFavoritesService {
|
||||
)
|
||||
.map((ch, index) => ({
|
||||
content_id: ch.contentId,
|
||||
// The backend UPDATE is scoped by (contentId, playlistId) —
|
||||
// without the playlist id the write matches no rows.
|
||||
playlist_id: ch.playlistId,
|
||||
position: index,
|
||||
}));
|
||||
|
||||
|
||||
Reference in new issue
Block a user