mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(portals): mark a full season as watched in one click (#1447)
* feat(portals): mark a full season as watched in one click Series detail pages on both Xtream and Stalker portals get a season-level watched toggle next to "Download season": marking writes full-progress rows for the unwatched episodes only (real durations survive), a fully watched season flips the action to unwatch-all. Persistence goes through new batch IPC channels (DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH, one SQLite transaction with onConflictDoUpdate().run(); the PWA data source rewrites its localStorage blob once). Stalker deliberately bypasses the batch IPC and loops the existing position-mutation queue so legacy-row reconciliation still runs and the queue coalesces to a single reload; partial failures surface a dedicated snackbar. Also removes the dead toggleEpisodeWatched store method, splits season-container/serial-details-playback under the max-lines cap (season-watch-toggle.util.ts, SerialDetailsSeasonWatchService), and classifies *.spec-data.ts fixtures under the test max-lines ceiling (baseline shrinks by main.preload.spec-data.ts). Closes #1442 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): guard stale season batches and split partial-unwatch feedback Review follow-up (Codex on #1447): - A season batch completing after the user navigated to another series or playlist no longer writes the old series' rows into the freshly reset position state (episode ids can collide across playlists); the Xtream host captures the playlist/series identity before awaiting and skips the rendered-state mutation when it changed. The DB write is unaffected — it carries its own playlistId. - A partially failed "mark season as unwatched" on Stalker now reports a dedicated SEASON_MARKED_UNWATCHED_PARTIAL message instead of the watch-direction "marked" text; translated into all 18 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): exclude the playing episode from season marking and count partial saves Second review round (Codex on #1447): - The episode currently playing (inline or in an external session, or with a launch in flight) is excluded from a season's mark-watched batch: the player persists its live position every ~15 s and would immediately overwrite the just-written full-progress row. The button count reflects the exclusion and the action disables when nothing is markable. Unmarking still clears such an episode — the recreated in-progress row reflects live playback truthfully. - A Stalker StalkerSeriesPositionPartialSaveError (scoped watched row saved and published, only legacy cleanup failed) now counts as a watched success instead of feeding false total-failure feedback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): gate stale season-batch snackbars on the originating page Third review round (Codex on #1447): a batch resolving after the user navigated away no longer shows its contextless success/error snackbar on the newly opened detail page — the same ownership check that guards the state mutation now guards the feedback too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): sync catalog progress badges after toggles and gate Stalker feedback Fourth review round (Codex on #1447): - Any Xtream watched toggle (single episode or season batch) now refreshes XtreamStore.loadAllPositions after persisting — the catalog reads series-progress badges from the store, which otherwise loads positions once per playlist, so returning from the detail kept stale badges. Skipped when the playlist changed mid-flight (the store then belongs to the other playlist; its own init reloads positions). - Stalker's season snackbars are gated on the captured playlist/series identity, matching the Xtream ownership guard — a batch draining after navigation no longer reports on the newly opened page. - Stalker season-toggle specs moved to stalker-series-view.season-watch .spec.ts with their own harness; both prior spec files sat at the 1200-line test ceiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: describe the season watched toggle in CLAUDE.md Fifth review round (Codex on #1447): the canonical Seasons entry in the VOD/Series detail section now covers the bulk toggle, its playing-episode exclusion, both persistence paths, catalog badge sync, and the stale-completion contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): let only the latest positions load patch the Xtream store Sixth review round (Codex on #1447): loadAllPositions is now latest-load-wins — a fetch superseded while in flight (playlist switch before getAllPlaybackPositions resolves) no longer patches the singleton store with the previous playlist's position maps, which could leave the new catalog showing the old playlist's progress badges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: reflect the spec-data max-lines classification in CLAUDE.md and AGENTS.md Seventh review round (Codex on #1447): both canonical max-lines descriptions now list **/*.spec-data.ts among the test-ceiling globs so future agents neither treat these fixtures as production files nor remove the exemption unknowingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): parse "N min" durations when marking episodes watched Eighth review round (Codex on #1447): Stalker VOD episodes report durations like "45 min", which parseDuration could not read — bulk (and single) mark-watched then persisted 1/1-second rows. The minute format now parses to seconds, matching what the removed legacy store method already handled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): parse compound hour durations and cover the toggle end-to-end Ninth review round (Codex on #1447): - parseDuration now reads the compound "1h 30min" form the Xtream fixtures emit (hour group optional, so "45 min" keeps working) — bulk-marked episodes no longer persist a minutes-only duration. - New Playwright coverage exercises the season toggle through the real UI on both portals: Xtream (category → series detail → mark → reload-persistence → unmark) and Stalker (embedded-series flow, mark → unmark with the item's actual episode count). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): refresh Stalker catalog progress badges after watched toggles Tenth review round (Codex on #1447): the Stalker mirror of the Xtream catalog sync — StalkerCatalogFacadeService loads its position maps once per playlist and the runtime bridge only pushes external-player updates, so renderer-initiated toggles left grid badges stale. The series view now calls the facade's new ownership-checked refreshPositions after the season batch (including partial successes) and after single toggles; the reload is latest-load-wins like the Xtream store fix. Optional injection keeps collection-detail mounts outside the catalog working. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(portals): cover the season toggle batch IPC end-to-end in Electron Eleventh review round (Codex on #1447): the new Electron E2E marks a season through the real UI, asserts the eight SQLite rows written by DB_SAVE_PLAYBACK_POSITIONS_BATCH directly through the preload bridge, proves persistence with a full app relaunch (renderer and main process die, so state can only come from the database file), and clears again through DB_CLEAR_PLAYBACK_POSITIONS_BATCH back to zero rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dashboard): keep watched rows out of the series resume target Twelfth review round (Codex on #1447): a watched position row — a natural finish or a manual/bulk "mark watched" marker — is a completion record, not resumable progress. Continue Watching no longer auto-plays such an episode at its end; the handoff stays detail-only and the series page's quick-start picks the first unwatched episode instead. Card progress bars and SxxEyy badges keep their current source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): fail closed on refresh reads and gate batch APIs by capability Thirteenth review round (Codex on #1447): - Position-cache refreshes now use a failure-propagating read (getAllPlaybackPositionsOrThrow through the Electron data source): a transient IPC failure rejects instead of masquerading as an empty list, so a populated store/facade cache stays stale-but-populated rather than being wiped. All load/refresh call sites handle the new rejection (init loads may retry on the next activation; post-toggle refreshes log and keep the snackbar flow). - The season-batch bridge methods joined playbackPositionStorageMethods, so a bridge lacking them degrades to the in-memory path wholesale instead of throwing mid-action. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
00000de798
commit
7fc9380bff
79 files changed
+3317
-199
No files matched your search
@@ -0,0 +1,264 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
clickFirstGridListCard,
|
||||
clickGridListCardByTitle,
|
||||
closeElectronApp,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSources,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
restartElectronApp,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
waitForXtreamImportToFinish,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import { fetchXtreamSeriesFixture } from './portal-mock-fixtures';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Season-level watched toggle on the Electron serial details page
|
||||
//
|
||||
// The season header exposes a bulk toggle carrying `data-test-id` (this
|
||||
// suite's testIdAttribute, so getByTestId matches it), while the per-episode
|
||||
// toggles carry `data-testid` and need raw attribute locators. In Electron
|
||||
// the toggle routes every unwatched episode of the selected season through
|
||||
// the DB_SAVE_PLAYBACK_POSITIONS_BATCH IPC (preload → main → database worker
|
||||
// → one SQLite transaction) and the unmark path through
|
||||
// DB_CLEAR_PLAYBACK_POSITIONS_BATCH. The runtime bridge has no per-episode
|
||||
// fallback for these calls (`PlaybackPositionRuntimeBridgeService` throws
|
||||
// when the batch methods are missing), so the rows asserted below via
|
||||
// `dbGetAllPlaybackPositions` prove the batch IPC executed end to end.
|
||||
// Durability is proven with a full app relaunch: the renderer and the main
|
||||
// process restart, so the watched state can only come from SQLite on disk.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The default `user1:pass1` mock scenario generates 3 seasons × 8 episodes
|
||||
// per series (season/episode numbers starting at 1), matching the PWA spec.
|
||||
const seasonCount = 3;
|
||||
const seasonEpisodeCount = 8;
|
||||
const expectedEpisodeNumbers = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
const portalName = 'Season Watched Toggle';
|
||||
|
||||
const watchedEpisodeToggleSelector =
|
||||
'[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched';
|
||||
|
||||
type EpisodePositionRow = {
|
||||
episodeNumber: number | null;
|
||||
positionSeconds: number;
|
||||
seasonNumber: number | null;
|
||||
};
|
||||
|
||||
/** Reads the persisted episode positions straight from SQLite over IPC. */
|
||||
async function readEpisodePositions(
|
||||
page: Page,
|
||||
playlistId: string
|
||||
): Promise<EpisodePositionRow[]> {
|
||||
return page.evaluate(async (id) => {
|
||||
const positions =
|
||||
(await window.electron?.dbGetAllPlaybackPositions?.(id)) ?? [];
|
||||
return positions
|
||||
.filter((position) => position.contentType === 'episode')
|
||||
.map((position) => ({
|
||||
episodeNumber: position.episodeNumber ?? null,
|
||||
positionSeconds: position.positionSeconds,
|
||||
seasonNumber: position.seasonNumber ?? null,
|
||||
}));
|
||||
}, playlistId);
|
||||
}
|
||||
|
||||
function extractPlaylistId(page: Page): string {
|
||||
const match = new URL(page.url()).pathname.match(
|
||||
/\/workspace\/xtreams\/([^/]+)\//
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(`Expected an Xtream route, got ${page.url()}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the given category's series grid and clicks into a series detail.
|
||||
* Without `seriesTitle` it clicks the first card (the grid sorts by date, so
|
||||
* fixture order is not grid order) and returns the clicked title so the
|
||||
* post-restart navigation can target the exact same series.
|
||||
*/
|
||||
async function openSeriesDetail(
|
||||
page: Page,
|
||||
categoryName: string,
|
||||
seriesTitle?: string
|
||||
): Promise<string> {
|
||||
await openWorkspaceSection(page, 'Series');
|
||||
await clickCategoryByNameExact(page, categoryName);
|
||||
|
||||
let clickedTitle: string;
|
||||
if (seriesTitle === undefined) {
|
||||
clickedTitle = await clickFirstGridListCard(page);
|
||||
} else {
|
||||
await clickGridListCardByTitle(page, seriesTitle);
|
||||
clickedTitle = seriesTitle;
|
||||
}
|
||||
|
||||
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/);
|
||||
return clickedTitle;
|
||||
}
|
||||
|
||||
test.describe('Electron Season Watched Toggle', () => {
|
||||
test('@xtream @persistence @electron marks a season watched via the batch IPC, survives an app restart, and clears again', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
// Full app relaunch mid-test: startup alone can eat half the default
|
||||
// 60s budget on a real Electron app.
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const seriesFixture = await fetchXtreamSeriesFixture(request, {
|
||||
password: defaultXtreamPassword,
|
||||
username: defaultXtreamUsername,
|
||||
});
|
||||
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, { name: portalName });
|
||||
await waitForXtreamImportToFinish(app.mainWindow);
|
||||
|
||||
const seriesTitle = await openSeriesDetail(
|
||||
app.mainWindow,
|
||||
seriesFixture.categoryName
|
||||
);
|
||||
const playlistId = extractPlaylistId(app.mainWindow);
|
||||
|
||||
// Season 1 auto-selects fully unwatched: the toggle offers to mark
|
||||
// all 8 episodes and neither the DOM nor SQLite knows any watched
|
||||
// episode yet.
|
||||
const seasonToggle = app.mainWindow.getByTestId(
|
||||
'toggle-season-watched'
|
||||
);
|
||||
await expect(seasonToggle).toBeVisible({ timeout: 20_000 });
|
||||
await expect(seasonToggle).toContainText(
|
||||
`Mark season as watched (${seasonEpisodeCount})`
|
||||
);
|
||||
|
||||
const episodeCards = app.mainWindow.locator('.episode-card');
|
||||
const watchedCards = app.mainWindow.locator(
|
||||
'.episode-card--watched'
|
||||
);
|
||||
await expect(episodeCards).toHaveCount(seasonEpisodeCount, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(watchedCards).toHaveCount(0);
|
||||
expect(
|
||||
await readEpisodePositions(app.mainWindow, playlistId)
|
||||
).toEqual([]);
|
||||
|
||||
await seasonToggle.click();
|
||||
|
||||
// The batch save flips the button, marks every card, and fills
|
||||
// the per-episode toggles; the untouched second season stays
|
||||
// unmarked while the selected season's tab shows the check.
|
||||
await expect(seasonToggle).toContainText(
|
||||
'Mark season as unwatched',
|
||||
{ timeout: 15_000 }
|
||||
);
|
||||
await expect(watchedCards).toHaveCount(seasonEpisodeCount);
|
||||
await expect(
|
||||
app.mainWindow.locator(watchedEpisodeToggleSelector)
|
||||
).toHaveCount(seasonEpisodeCount);
|
||||
|
||||
const seasonTabs = app.mainWindow.locator('.season-tabs__pill');
|
||||
await expect(seasonTabs).toHaveCount(seasonCount);
|
||||
await expect(
|
||||
seasonTabs.first().locator('.season-tabs__done')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
seasonTabs.nth(1).locator('.season-tabs__done')
|
||||
).toHaveCount(0);
|
||||
|
||||
// The single batch transaction landed 8 full-progress episode
|
||||
// rows for season 1 in SQLite.
|
||||
await expect
|
||||
.poll(() => readEpisodePositions(app.mainWindow, playlistId), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toHaveLength(seasonEpisodeCount);
|
||||
const savedRows = await readEpisodePositions(
|
||||
app.mainWindow,
|
||||
playlistId
|
||||
);
|
||||
expect(
|
||||
savedRows
|
||||
.map((row) => row.episodeNumber)
|
||||
.sort((left, right) => (left ?? 0) - (right ?? 0))
|
||||
).toEqual(expectedEpisodeNumbers);
|
||||
expect(
|
||||
savedRows.every(
|
||||
(row) => row.seasonNumber === 1 && row.positionSeconds > 0
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
// Relaunch the whole app: the watched state must be re-read from
|
||||
// the SQLite database file, not from any renderer memory.
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, portalName).first().click();
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openSeriesDetail(
|
||||
app.mainWindow,
|
||||
seriesFixture.categoryName,
|
||||
seriesTitle
|
||||
);
|
||||
|
||||
const restartedToggle = app.mainWindow.getByTestId(
|
||||
'toggle-season-watched'
|
||||
);
|
||||
const restartedWatchedCards = app.mainWindow.locator(
|
||||
'.episode-card--watched'
|
||||
);
|
||||
await expect(restartedToggle).toBeVisible({ timeout: 20_000 });
|
||||
await expect(restartedToggle).toContainText(
|
||||
'Mark season as unwatched'
|
||||
);
|
||||
await expect(restartedWatchedCards).toHaveCount(
|
||||
seasonEpisodeCount,
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.locator('.season-tabs__pill')
|
||||
.first()
|
||||
.locator('.season-tabs__done')
|
||||
).toBeVisible();
|
||||
|
||||
// Unmark: the clear batch removes every row again, in the DOM and
|
||||
// in SQLite.
|
||||
await restartedToggle.click();
|
||||
await expect(restartedToggle).toContainText(
|
||||
`Mark season as watched (${seasonEpisodeCount})`,
|
||||
{ timeout: 15_000 }
|
||||
);
|
||||
await expect(restartedWatchedCards).toHaveCount(0);
|
||||
await expect(
|
||||
app.mainWindow.locator(watchedEpisodeToggleSelector)
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
app.mainWindow.locator('.season-tabs__done')
|
||||
).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => readEpisodePositions(app.mainWindow, playlistId), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toEqual([]);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -376,6 +376,21 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
|
||||
channel: 'DB_CLEAR_PLAYBACK_POSITION',
|
||||
forwardedArgs: [playlistId, 42, 'vod'],
|
||||
},
|
||||
{
|
||||
method: 'dbSavePlaybackPositionsBatch',
|
||||
args: [playlistId, [playbackData]],
|
||||
channel: 'DB_SAVE_PLAYBACK_POSITIONS_BATCH',
|
||||
forwardedArgs: [playlistId, [playbackData]],
|
||||
},
|
||||
{
|
||||
method: 'dbClearPlaybackPositionsBatch',
|
||||
args: [playlistId, [{ contentXtreamId: 42, contentType: 'episode' }]],
|
||||
channel: 'DB_CLEAR_PLAYBACK_POSITIONS_BATCH',
|
||||
forwardedArgs: [
|
||||
playlistId,
|
||||
[{ contentXtreamId: 42, contentType: 'episode' }],
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'dbGetTmdbMetadata',
|
||||
args: ['movie', 'id:603', 'en-US'],
|
||||
|
||||
@@ -1005,6 +1005,24 @@ const electronApi: ElectronBridgeApi = {
|
||||
contentXtreamId,
|
||||
contentType
|
||||
),
|
||||
dbSavePlaybackPositionsBatch: (
|
||||
playlistId: string,
|
||||
items: ElectronBridgePlaybackPositionInput[]
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_SAVE_PLAYBACK_POSITIONS_BATCH',
|
||||
playlistId,
|
||||
items
|
||||
),
|
||||
dbClearPlaybackPositionsBatch: (
|
||||
playlistId: string,
|
||||
items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[]
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_CLEAR_PLAYBACK_POSITIONS_BATCH',
|
||||
playlistId,
|
||||
items
|
||||
),
|
||||
getLocalIpAddresses: () => ipcRenderer.invoke('get-local-ip-addresses'),
|
||||
// Downloads
|
||||
downloadsStart: (data: ElectronBridgeDownloadStartPayload) =>
|
||||
|
||||
+225
@@ -11,13 +11,31 @@ import * as schema from '@iptvnator/shared/database/schema';
|
||||
import {
|
||||
clearAllPlaybackPositions,
|
||||
clearPlaybackPosition,
|
||||
clearPlaybackPositionsBatch,
|
||||
getAllPlaybackPositions,
|
||||
getPlaybackPosition,
|
||||
getRecentPlaybackPositions,
|
||||
getSeriesPlaybackPositions,
|
||||
savePlaybackPosition,
|
||||
savePlaybackPositionsBatch,
|
||||
} from './playback-position.operations';
|
||||
|
||||
/**
|
||||
* Batch saves upsert inside a synchronous transaction, so the insert chain
|
||||
* has to end in a synchronous `.run()` rather than resolving — exactly as
|
||||
* the better-sqlite3 driver requires (issue #1137). The wrapper exposes
|
||||
* both dispatch methods so the specs can assert the correct one is used.
|
||||
*/
|
||||
function createBatchUpsertDbMock(selectResultsByCall: unknown[][] = []) {
|
||||
const mock = createDbMock(selectResultsByCall);
|
||||
const insertExecute = jest.fn().mockResolvedValue(undefined);
|
||||
const onConflictDoUpdate = jest
|
||||
.fn()
|
||||
.mockReturnValue({ run: mock.insertRun, execute: insertExecute });
|
||||
mock.insertValues.mockReturnValue({ onConflictDoUpdate });
|
||||
return { ...mock, insertExecute, onConflictDoUpdate };
|
||||
}
|
||||
|
||||
describe('playback-position.operations', () => {
|
||||
beforeEach(() => {
|
||||
resetDrizzleMocks();
|
||||
@@ -138,6 +156,153 @@ describe('playback-position.operations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('savePlaybackPositionsBatch', () => {
|
||||
it('returns a zero count without touching the database for empty input', async () => {
|
||||
const { db, select, insert, transaction } = createDbMock();
|
||||
|
||||
await expect(
|
||||
savePlaybackPositionsBatch(db, 'playlist-1', [])
|
||||
).resolves.toEqual({ success: true, count: 0 });
|
||||
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upserts every item inside one transaction with the composite conflict target', async () => {
|
||||
const {
|
||||
db,
|
||||
select,
|
||||
insert,
|
||||
insertValues,
|
||||
onConflictDoUpdate,
|
||||
insertRun,
|
||||
insertExecute,
|
||||
transaction,
|
||||
} = createBatchUpsertDbMock([[{ id: 'playlist-1' }]]);
|
||||
|
||||
const result = await savePlaybackPositionsBatch(db, 'playlist-1', [
|
||||
{
|
||||
contentXtreamId: 500,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 120,
|
||||
durationSeconds: 3600,
|
||||
},
|
||||
{
|
||||
contentXtreamId: 42,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 7,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 5,
|
||||
positionSeconds: 480,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ success: true, count: 2 });
|
||||
// The playlist existence check runs exactly once for the whole
|
||||
// batch, and it must complete before the synchronous transaction
|
||||
// callback starts.
|
||||
expect(select).toHaveBeenCalledTimes(1);
|
||||
expect(select.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
transaction.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(insert).toHaveBeenCalledTimes(2);
|
||||
expect(insert).toHaveBeenCalledWith(schema.playbackPositions);
|
||||
expect(insertValues).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 500,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 120,
|
||||
durationSeconds: 3600,
|
||||
updatedAt: expect.objectContaining({ kind: 'sql' }),
|
||||
})
|
||||
);
|
||||
expect(insertValues).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 7,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 5,
|
||||
positionSeconds: 480,
|
||||
})
|
||||
);
|
||||
expect(onConflictDoUpdate).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
target: [
|
||||
schema.playbackPositions.contentXtreamId,
|
||||
schema.playbackPositions.playlistId,
|
||||
schema.playbackPositions.contentType,
|
||||
],
|
||||
set: expect.objectContaining({
|
||||
positionSeconds: 120,
|
||||
durationSeconds: 3600,
|
||||
updatedAt: expect.objectContaining({ kind: 'sql' }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(onConflictDoUpdate).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
set: expect.objectContaining({
|
||||
seriesXtreamId: 7,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 5,
|
||||
positionSeconds: 480,
|
||||
}),
|
||||
})
|
||||
);
|
||||
// Regression (issue #1137): the upsert must be dispatched with
|
||||
// synchronous `.run()`. `.execute()` defers to a promise that
|
||||
// never settles inside the synchronous transaction callback, so
|
||||
// the batch write would silently no-op.
|
||||
expect(insertExecute).not.toHaveBeenCalled();
|
||||
expect(insertRun).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('creates the missing playlist once, honoring the first item playlist type', async () => {
|
||||
const { db, insert, insertValues } = createBatchUpsertDbMock([[]]);
|
||||
|
||||
await savePlaybackPositionsBatch(db, 'playlist-xt', [
|
||||
{
|
||||
contentXtreamId: 1,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 10,
|
||||
playlistType: 'xtream',
|
||||
},
|
||||
{
|
||||
contentXtreamId: 2,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 20,
|
||||
},
|
||||
]);
|
||||
|
||||
// One playlist insert despite two items, then one position
|
||||
// upsert per item.
|
||||
expect(insert).toHaveBeenCalledTimes(3);
|
||||
expect(insert).toHaveBeenNthCalledWith(1, schema.playlists);
|
||||
expect(insertValues).toHaveBeenNthCalledWith(1, {
|
||||
id: 'playlist-xt',
|
||||
name: 'Imported Playlist',
|
||||
type: 'xtream',
|
||||
});
|
||||
expect(insert).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
schema.playbackPositions
|
||||
);
|
||||
expect(insert).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
schema.playbackPositions
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlaybackPosition', () => {
|
||||
it('returns the matching row scoped by playlist, content, and type', async () => {
|
||||
const row = {
|
||||
@@ -275,4 +440,64 @@ describe('playback-position.operations', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearPlaybackPositionsBatch', () => {
|
||||
it('returns a zero count without touching the database for empty input', async () => {
|
||||
const { db, deleteFn, transaction } = createDbMock();
|
||||
|
||||
await expect(
|
||||
clearPlaybackPositionsBatch(db, 'playlist-1', [])
|
||||
).resolves.toEqual({ success: true, count: 0 });
|
||||
|
||||
expect(deleteFn).not.toHaveBeenCalled();
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs one prepared placeholder delete per item inside a transaction', async () => {
|
||||
const {
|
||||
db,
|
||||
deleteFn,
|
||||
deletePrepare,
|
||||
deleteRun,
|
||||
deleteExecute,
|
||||
transaction,
|
||||
} = createDbMock();
|
||||
|
||||
await expect(
|
||||
clearPlaybackPositionsBatch(db, 'playlist-1', [
|
||||
{ contentXtreamId: 500, contentType: 'vod' },
|
||||
{ contentXtreamId: 42, contentType: 'episode' },
|
||||
])
|
||||
).resolves.toEqual({ success: true, count: 2 });
|
||||
|
||||
expect(deleteFn).toHaveBeenCalledWith(schema.playbackPositions);
|
||||
expect(deletePrepare).toHaveBeenCalledTimes(1);
|
||||
// The playlist id is bound directly; content id and type are
|
||||
// per-item placeholders.
|
||||
expect(mockDrizzle.eq).toHaveBeenCalledWith(
|
||||
schema.playbackPositions.playlistId,
|
||||
'playlist-1'
|
||||
);
|
||||
expect(mockDrizzle.sql.placeholder).toHaveBeenCalledWith(
|
||||
'contentXtreamId'
|
||||
);
|
||||
expect(mockDrizzle.sql.placeholder).toHaveBeenCalledWith(
|
||||
'contentType'
|
||||
);
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
// Regression (issue #1137): the prepared delete must be
|
||||
// dispatched with synchronous `.run()`. `.execute()` defers to a
|
||||
// promise that never settles inside the synchronous transaction
|
||||
// callback, so the batch delete would silently no-op.
|
||||
expect(deleteExecute).not.toHaveBeenCalled();
|
||||
expect(deleteRun).toHaveBeenNthCalledWith(1, {
|
||||
contentXtreamId: 500,
|
||||
contentType: 'vod',
|
||||
});
|
||||
expect(deleteRun).toHaveBeenNthCalledWith(2, {
|
||||
contentXtreamId: 42,
|
||||
contentType: 'episode',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,96 @@ export async function savePlaybackPosition(
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function savePlaybackPositionsBatch(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
items: PlaybackPositionPayload[]
|
||||
): Promise<{ success: boolean; count: number }> {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return { success: true, count: 0 };
|
||||
}
|
||||
|
||||
// ensurePlaylistExists awaits, so it must run before the synchronous
|
||||
// transaction callback below.
|
||||
await ensurePlaylistExists(db, playlistId, items[0]?.playlistType);
|
||||
|
||||
await db.transaction(() => {
|
||||
for (const item of items) {
|
||||
// .run() (synchronous), NOT .execute(): the better-sqlite3
|
||||
// driver's .execute() defers the write to a resolved promise
|
||||
// that never settles inside this synchronous transaction
|
||||
// callback, so the upsert would silently do nothing. See the
|
||||
// matching note in recently-viewed.operations.ts (issue #1137).
|
||||
db.insert(schema.playbackPositions)
|
||||
.values({
|
||||
playlistId,
|
||||
contentXtreamId: item.contentXtreamId,
|
||||
contentType: item.contentType,
|
||||
seriesXtreamId: item.seriesXtreamId,
|
||||
seasonNumber: item.seasonNumber,
|
||||
episodeNumber: item.episodeNumber,
|
||||
positionSeconds: item.positionSeconds,
|
||||
durationSeconds: item.durationSeconds,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.playbackPositions.contentXtreamId,
|
||||
schema.playbackPositions.playlistId,
|
||||
schema.playbackPositions.contentType,
|
||||
],
|
||||
set: {
|
||||
seriesXtreamId: item.seriesXtreamId,
|
||||
seasonNumber: item.seasonNumber,
|
||||
episodeNumber: item.episodeNumber,
|
||||
positionSeconds: item.positionSeconds,
|
||||
durationSeconds: item.durationSeconds,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, count: items.length };
|
||||
}
|
||||
|
||||
export async function clearPlaybackPositionsBatch(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[]
|
||||
): Promise<{ success: boolean; count: number }> {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return { success: true, count: 0 };
|
||||
}
|
||||
|
||||
const stmt = db
|
||||
.delete(schema.playbackPositions)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.playbackPositions.playlistId, playlistId),
|
||||
eq(
|
||||
schema.playbackPositions.contentXtreamId,
|
||||
sql.placeholder('contentXtreamId')
|
||||
),
|
||||
eq(
|
||||
schema.playbackPositions.contentType,
|
||||
sql.placeholder('contentType')
|
||||
)
|
||||
)
|
||||
)
|
||||
.prepare();
|
||||
|
||||
await db.transaction(() => {
|
||||
for (const { contentXtreamId, contentType } of items) {
|
||||
// .run(), not .execute() — see savePlaybackPositionsBatch.
|
||||
stmt.run({ contentXtreamId, contentType });
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, count: items.length };
|
||||
}
|
||||
|
||||
export async function getPlaybackPosition(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
|
||||
@@ -65,3 +65,22 @@ handleWorkerRequest(
|
||||
contentType,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_SAVE_PLAYBACK_POSITIONS_BATCH',
|
||||
(playlistId: string, items: unknown[]) => ({
|
||||
playlistId,
|
||||
items,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_CLEAR_PLAYBACK_POSITIONS_BATCH',
|
||||
(
|
||||
playlistId: string,
|
||||
items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[]
|
||||
) => ({
|
||||
playlistId,
|
||||
items,
|
||||
})
|
||||
);
|
||||
@@ -30,6 +30,18 @@ const playbackData = {
|
||||
contentType: 'vod',
|
||||
positionSeconds: 120,
|
||||
};
|
||||
const playbackBatchItems = [
|
||||
{
|
||||
contentXtreamId: 42,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 88,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 3,
|
||||
positionSeconds: 1200,
|
||||
durationSeconds: 1200,
|
||||
},
|
||||
];
|
||||
const playbackClearBatchItems = [{ contentXtreamId: 42, contentType: 'episode' }];
|
||||
const tmdbCacheEntry = {
|
||||
mediaType: 'movie',
|
||||
lookupKey: 'id:603',
|
||||
@@ -330,6 +342,16 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
|
||||
args: [playlistId, 42, 'vod'],
|
||||
payload: { playlistId, contentXtreamId: 42, contentType: 'vod' },
|
||||
},
|
||||
{
|
||||
operation: 'DB_SAVE_PLAYBACK_POSITIONS_BATCH',
|
||||
args: [playlistId, playbackBatchItems],
|
||||
payload: { playlistId, items: playbackBatchItems },
|
||||
},
|
||||
{
|
||||
operation: 'DB_CLEAR_PLAYBACK_POSITIONS_BATCH',
|
||||
args: [playlistId, playbackClearBatchItems],
|
||||
payload: { playlistId, items: playbackClearBatchItems },
|
||||
},
|
||||
{
|
||||
operation: 'DB_GET_TMDB_METADATA',
|
||||
args: ['movie', 'id:603', 'en-US'],
|
||||
|
||||
@@ -51,6 +51,8 @@ export const DB_WORKER_OPERATIONS = [
|
||||
'DB_GET_ALL_PLAYBACK_POSITIONS',
|
||||
'DB_CLEAR_ALL_PLAYBACK_POSITIONS',
|
||||
'DB_CLEAR_PLAYBACK_POSITION',
|
||||
'DB_SAVE_PLAYBACK_POSITIONS_BATCH',
|
||||
'DB_CLEAR_PLAYBACK_POSITIONS_BATCH',
|
||||
'DB_GET_TMDB_METADATA',
|
||||
'DB_SET_TMDB_METADATA',
|
||||
'DB_GET_TMDB_CACHE_STATS',
|
||||
|
||||
@@ -49,11 +49,13 @@ import { setContentMetadataIfMissing } from '../database/operations/content-meta
|
||||
import {
|
||||
clearAllPlaybackPositions,
|
||||
clearPlaybackPosition,
|
||||
clearPlaybackPositionsBatch,
|
||||
getAllPlaybackPositions,
|
||||
getPlaybackPosition,
|
||||
getRecentPlaybackPositions,
|
||||
getSeriesPlaybackPositions,
|
||||
savePlaybackPosition,
|
||||
savePlaybackPositionsBatch,
|
||||
} from '../database/operations/playback-position.operations';
|
||||
import {
|
||||
createPlaylist,
|
||||
@@ -1139,6 +1141,47 @@ async function executeRequest(
|
||||
payload.contentType
|
||||
);
|
||||
}
|
||||
|
||||
case 'DB_SAVE_PLAYBACK_POSITIONS_BATCH': {
|
||||
const payload = message.payload as {
|
||||
playlistId: string;
|
||||
items: {
|
||||
contentXtreamId: number;
|
||||
contentType: 'vod' | 'episode';
|
||||
seriesXtreamId?: number;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
positionSeconds: number;
|
||||
durationSeconds?: number;
|
||||
playlistType?:
|
||||
| 'xtream'
|
||||
| 'stalker'
|
||||
| 'm3u-file'
|
||||
| 'm3u-text'
|
||||
| 'm3u-url';
|
||||
}[];
|
||||
};
|
||||
return savePlaybackPositionsBatch(
|
||||
db,
|
||||
payload.playlistId,
|
||||
payload.items
|
||||
);
|
||||
}
|
||||
|
||||
case 'DB_CLEAR_PLAYBACK_POSITIONS_BATCH': {
|
||||
const payload = message.payload as {
|
||||
playlistId: string;
|
||||
items: {
|
||||
contentXtreamId: number;
|
||||
contentType: 'vod' | 'episode';
|
||||
}[];
|
||||
};
|
||||
return clearPlaybackPositionsBatch(
|
||||
db,
|
||||
payload.playlistId,
|
||||
payload.items
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -952,6 +952,78 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('@stalker season watched toggle — embedded series marks and clears every episode', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Reuse the modeled embedded-series flow: find a VOD item carrying an
|
||||
// embedded series[] array, open it from its category, and land on the
|
||||
// series detail with its episode list.
|
||||
const listResponse = await request.get(
|
||||
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml`
|
||||
);
|
||||
const listBody = await listResponse.json();
|
||||
const embeddedItem = listBody.payload.js.data.find(
|
||||
(item: { series?: unknown[] }) =>
|
||||
Array.isArray(item.series) && item.series.length > 0
|
||||
);
|
||||
expect(embeddedItem).toBeDefined();
|
||||
const episodeCount: number = embeddedItem.series.length;
|
||||
|
||||
await addStalkerPortal(page, {
|
||||
name: 'Embedded Series Watch Portal',
|
||||
mac: EMBEDDED_SERIES_MAC,
|
||||
});
|
||||
|
||||
const categories = page.locator('.category-item');
|
||||
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
|
||||
await categories.nth(1).click();
|
||||
const card = page.getByText(embeddedItem.name).first();
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await card.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: `${episodeCount}. Episode ${episodeCount}`,
|
||||
exact: true,
|
||||
})
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The season header's bulk toggle (data-test-id with a dash — getByTestId
|
||||
// only matches data-testid in this suite) counts every unwatched episode.
|
||||
const seasonToggle = page.locator(
|
||||
'[data-test-id="toggle-season-watched"]'
|
||||
);
|
||||
await expect(seasonToggle).toBeVisible();
|
||||
await expect(seasonToggle).toContainText(
|
||||
`Mark season as watched (${episodeCount})`
|
||||
);
|
||||
const watchedCards = page.locator('.episode-card--watched');
|
||||
await expect(watchedCards).toHaveCount(0);
|
||||
|
||||
await seasonToggle.click();
|
||||
|
||||
// All episodes get full-progress positions: the button flips and every
|
||||
// episode card plus per-episode toggle shows the watched state.
|
||||
await expect(seasonToggle).toContainText('Mark season as unwatched', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(watchedCards).toHaveCount(episodeCount);
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched'
|
||||
)
|
||||
).toHaveCount(episodeCount);
|
||||
|
||||
// Second click clears every episode's position again.
|
||||
await seasonToggle.click();
|
||||
await expect(seasonToggle).toContainText(
|
||||
`Mark season as watched (${episodeCount})`,
|
||||
{ timeout: 15_000 }
|
||||
);
|
||||
await expect(watchedCards).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('@stalker series — seasons load for a series item', async ({
|
||||
request,
|
||||
}) => {
|
||||
|
||||
@@ -667,6 +667,111 @@ test('@xtream the saved engine mounts the live player first time — no default-
|
||||
).toEqual(['app-html-video-player']);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Season-level watched toggle on the serial details page
|
||||
//
|
||||
// The season header exposes a bulk toggle (note: data-test-id with a dash —
|
||||
// getByTestId only matches data-testid in this suite) that writes
|
||||
// full-progress playback positions for every unwatched episode of the
|
||||
// selected season and clears them again on the second click. The PWA
|
||||
// persists positions to localStorage, so the state must survive a reload.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('@xtream season watched toggle — marks a season, survives reload, and clears again', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Resolve a concrete series (category + name) from the mock so the card
|
||||
// click below targets a known item of the default scenario.
|
||||
const categories = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
|
||||
)
|
||||
).json()) as Array<{ category_id: string; category_name: string }>;
|
||||
const category = categories[0];
|
||||
|
||||
const seriesItems = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
|
||||
)
|
||||
).json()) as Array<{ name: string; series_id: number }>;
|
||||
const targetSeries = seriesItems[0];
|
||||
|
||||
await addXtreamPortal(page);
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
|
||||
|
||||
const categoryItem = page
|
||||
.locator('.context-panel .category-item')
|
||||
.filter({ hasText: category.category_name })
|
||||
.first();
|
||||
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
|
||||
await categoryItem.click();
|
||||
|
||||
const seriesCard = page
|
||||
.locator('app-grid-list mat-card')
|
||||
.filter({ hasText: targetSeries.name })
|
||||
.first();
|
||||
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
|
||||
await seriesCard.click();
|
||||
|
||||
// Serial details: default scenario has 3 seasons × 8 episodes and no
|
||||
// playback positions yet, so season 1 is auto-selected fully unwatched.
|
||||
const seasonToggle = page.locator(
|
||||
'[data-test-id="toggle-season-watched"]'
|
||||
);
|
||||
await expect(seasonToggle).toBeVisible({ timeout: 15_000 });
|
||||
await expect(seasonToggle).toContainText('Mark season as watched (8)');
|
||||
|
||||
const episodeCards = page.locator('.episode-card');
|
||||
await expect(episodeCards).toHaveCount(8, { timeout: 10_000 });
|
||||
const watchedCards = page.locator('.episode-card--watched');
|
||||
await expect(watchedCards).toHaveCount(0);
|
||||
|
||||
await seasonToggle.click();
|
||||
|
||||
// Full-progress rows land for all 8 episodes: the button flips, every
|
||||
// episode card gets the watched state, and the per-episode toggles show
|
||||
// the filled check.
|
||||
await expect(seasonToggle).toContainText('Mark season as unwatched', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(watchedCards).toHaveCount(8);
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched'
|
||||
)
|
||||
).toHaveCount(8);
|
||||
|
||||
// The selected season's tab shows the completed check; the untouched
|
||||
// seasons stay unmarked.
|
||||
const seasonTabs = page.locator('.season-tabs__pill');
|
||||
await expect(seasonTabs).toHaveCount(3);
|
||||
await expect(
|
||||
seasonTabs.first().locator('.season-tabs__done')
|
||||
).toBeVisible();
|
||||
await expect(seasonTabs.nth(1).locator('.season-tabs__done')).toHaveCount(
|
||||
0
|
||||
);
|
||||
|
||||
// PWA persistence: positions live in localStorage, so a reload of the
|
||||
// detail route must come back fully watched.
|
||||
await page.reload();
|
||||
await expect(seasonToggle).toBeVisible({ timeout: 20_000 });
|
||||
await expect(seasonToggle).toContainText('Mark season as unwatched');
|
||||
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
|
||||
await expect(
|
||||
seasonTabs.first().locator('.season-tabs__done')
|
||||
).toBeVisible();
|
||||
|
||||
// Second click clears every episode's position again.
|
||||
await seasonToggle.click();
|
||||
await expect(seasonToggle).toContainText('Mark season as watched (8)', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(watchedCards).toHaveCount(0);
|
||||
await expect(page.locator('.season-tabs__done')).toHaveCount(0);
|
||||
});
|
||||
|
||||
type XtreamLiveStream = {
|
||||
category_id: string;
|
||||
name: string;
|
||||
|
||||
@@ -108,6 +108,20 @@ export class AppPortalPlaybackPositionsService
|
||||
contentType
|
||||
);
|
||||
}
|
||||
|
||||
async savePlaybackPositionsBatch(
|
||||
playlistId: string,
|
||||
items: PlaybackPositionData[]
|
||||
): Promise<void> {
|
||||
await this.dataSource.savePlaybackPositionsBatch(playlistId, items);
|
||||
}
|
||||
|
||||
async clearPlaybackPositionsBatch(
|
||||
playlistId: string,
|
||||
items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[]
|
||||
): Promise<void> {
|
||||
await this.dataSource.clearPlaybackPositionsBatch(playlistId, items);
|
||||
}
|
||||
}
|
||||
|
||||
export const providePortalPlaybackPositions = () => [
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "قيد التقدم",
|
||||
"MARK_WATCHED": "وضع علامة كمشاهد",
|
||||
"MARK_UNWATCHED": "وضع علامة كغير مشاهد",
|
||||
"MARK_SEASON_WATCHED": "وضع علامة على الموسم كمشاهد ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "وضع علامة على الموسم كغير مشاهد",
|
||||
"SEASON_MARKED_WATCHED": "تم وضع علامة على {{count}} حلقة كمشاهدة",
|
||||
"SEASON_MARKED_UNWATCHED": "تم وضع علامة على الموسم كغير مشاهد",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "تم وضع علامة على {{count}} · فشل {{failed}}",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "تمت إزالة العلامة عن {{count}} · فشل {{failed}}",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "فشل تحديث الموسم",
|
||||
"EPISODES_WATCHED": "تمت مشاهدة {{count}} من {{total}} حلقة",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "البيانات الوصفية للبوابة غير متاحة",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "جاري",
|
||||
"MARK_WATCHED": "علم كمتشاف",
|
||||
"MARK_UNWATCHED": "علم كما متشافش",
|
||||
"MARK_SEASON_WATCHED": "علم الموسم كمتشاف ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "علم الموسم كما متشافش",
|
||||
"SEASON_MARKED_WATCHED": "{{count}} ديال الحلقات تعلمو كمتشافين",
|
||||
"SEASON_MARKED_UNWATCHED": "الموسم تعلم كما متشافش",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} تعلمو · {{failed}} فشلو",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} تعلمو كما متشافينش · {{failed}} فشلو",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "فشل تحديث الموسم",
|
||||
"EPISODES_WATCHED": "{{count}} من {{total}} ديال الحلقات متشافين",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "البيانات الوصفية ديال البوابة ما متوفراش",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "У працэсе",
|
||||
"MARK_WATCHED": "Адзначыць як прагледжанае",
|
||||
"MARK_UNWATCHED": "Адзначыць як непрагледжанае",
|
||||
"MARK_SEASON_WATCHED": "Адзначыць сезон як прагледжаны ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "Адзначыць сезон як непрагледжаны",
|
||||
"SEASON_MARKED_WATCHED": "{{count}} эпізодаў адзначана як прагледжаныя",
|
||||
"SEASON_MARKED_UNWATCHED": "Сезон адзначаны як непрагледжаны",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} адзначана · {{failed}} не ўдалося",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} знята · {{failed}} не ўдалося",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Не ўдалося абнавіць сезон",
|
||||
"EPISODES_WATCHED": "{{count}} з {{total}} эпізодаў прагледжана",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Метаданыя партала недаступныя",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "In Wiedergabe",
|
||||
"MARK_WATCHED": "Als angesehen markieren",
|
||||
"MARK_UNWATCHED": "Als nicht angesehen markieren",
|
||||
"MARK_SEASON_WATCHED": "Staffel als angesehen markieren ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "Staffel als nicht angesehen markieren",
|
||||
"SEASON_MARKED_WATCHED": "{{count}} Folgen als angesehen markiert",
|
||||
"SEASON_MARKED_UNWATCHED": "Staffel als nicht angesehen markiert",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} markiert · {{failed}} fehlgeschlagen",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} als nicht angesehen markiert · {{failed}} fehlgeschlagen",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Aktualisierung der Staffel fehlgeschlagen",
|
||||
"EPISODES_WATCHED": "{{count}} von {{total}} Folgen angesehen",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Portal-Metadaten fehlen",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "Σε εξέλιξη",
|
||||
"MARK_WATCHED": "Σήμανση ως προβληθέν",
|
||||
"MARK_UNWATCHED": "Σήμανση ως μη προβληθέν",
|
||||
"MARK_SEASON_WATCHED": "Σήμανση σεζόν ως προβληθείσα ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "Σήμανση σεζόν ως μη προβληθείσα",
|
||||
"SEASON_MARKED_WATCHED": "{{count}} επεισόδια σημειώθηκαν ως προβληθέντα",
|
||||
"SEASON_MARKED_UNWATCHED": "Η σεζόν σημειώθηκε ως μη προβληθείσα",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "Σημειώθηκαν {{count}} · Απέτυχαν {{failed}}",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "Αφαιρέθηκε η σήμανση από {{count}} · Απέτυχαν {{failed}}",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Η ενημέρωση της σεζόν απέτυχε",
|
||||
"EPISODES_WATCHED": "{{count}} από {{total}} επεισόδια προβλήθηκαν",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Τα μεταδεδομένα της πύλης δεν είναι διαθέσιμα",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "In Progress",
|
||||
"MARK_WATCHED": "Mark as Watched",
|
||||
"MARK_UNWATCHED": "Mark as Unwatched",
|
||||
"MARK_SEASON_WATCHED": "Mark season as watched ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "Mark season as unwatched",
|
||||
"SEASON_MARKED_WATCHED": "{{count}} episodes marked as watched",
|
||||
"SEASON_MARKED_UNWATCHED": "Season marked as unwatched",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} marked · {{failed}} failed",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} unmarked · {{failed}} failed",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Updating the season failed",
|
||||
"EPISODES_WATCHED": "{{count}} of {{total}} episodes watched",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Portal metadata unavailable",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "En progreso",
|
||||
"MARK_WATCHED": "Marcar como visto",
|
||||
"MARK_UNWATCHED": "Marcar como no visto",
|
||||
"MARK_SEASON_WATCHED": "Marcar temporada como vista ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "Marcar temporada como no vista",
|
||||
"SEASON_MARKED_WATCHED": "{{count}} episodios marcados como vistos",
|
||||
"SEASON_MARKED_UNWATCHED": "Temporada marcada como no vista",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} marcados · {{failed}} fallidos",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} desmarcados · {{failed}} fallidos",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "La actualización de la temporada falló",
|
||||
"EPISODES_WATCHED": "{{count}} de {{total}} episodios vistos",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Metadatos del portal no disponibles",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "En cours",
|
||||
"MARK_WATCHED": "Marquer comme vu",
|
||||
"MARK_UNWATCHED": "Marquer comme non vu",
|
||||
"MARK_SEASON_WATCHED": "Marquer la saison comme vue ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "Marquer la saison comme non vue",
|
||||
"SEASON_MARKED_WATCHED": "{{count}} épisodes marqués comme vus",
|
||||
"SEASON_MARKED_UNWATCHED": "Saison marquée comme non vue",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} marqués · {{failed}} échoués",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} marqués comme non vus · {{failed}} échoués",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Échec de la mise à jour de la saison",
|
||||
"EPISODES_WATCHED": "{{count}} sur {{total}} épisodes vus",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Métadonnées du portail indisponibles",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "Folyamatban",
|
||||
"MARK_WATCHED": "Megjelölés megtekintettként",
|
||||
"MARK_UNWATCHED": "Megjelölés nem megtekintettként",
|
||||
"MARK_SEASON_WATCHED": "Évad megjelölése megtekintettként ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "Évad megjelölése nem megtekintettként",
|
||||
"SEASON_MARKED_WATCHED": "{{count}} epizód megjelölve megtekintettként",
|
||||
"SEASON_MARKED_UNWATCHED": "Évad megjelölve nem megtekintettként",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} megjelölve · {{failed}} sikertelen",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} jelölés törölve · {{failed}} sikertelen",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Nem sikerült frissíteni az évadot",
|
||||
"EPISODES_WATCHED": "Megtekintett epizódok: {{count}} / {{total}}",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "A portál metaadatai nem állnak rendelkezésre",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "In corso",
|
||||
"MARK_WATCHED": "Segna come visto",
|
||||
"MARK_UNWATCHED": "Segna come non visto",
|
||||
"MARK_SEASON_WATCHED": "Segna la stagione come vista ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "Segna la stagione come non vista",
|
||||
"SEASON_MARKED_WATCHED": "{{count}} episodi segnati come visti",
|
||||
"SEASON_MARKED_UNWATCHED": "Stagione segnata come non vista",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} segnati · {{failed}} non riusciti",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} segnati come non visti · {{failed}} non riusciti",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Aggiornamento della stagione non riuscito",
|
||||
"EPISODES_WATCHED": "{{count}} di {{total}} episodi visti",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Metadati del portale non disponibili",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "視聴中",
|
||||
"MARK_WATCHED": "視聴済みにする",
|
||||
"MARK_UNWATCHED": "未視聴にする",
|
||||
"MARK_SEASON_WATCHED": "シーズンを視聴済みにする({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "シーズンを未視聴にする",
|
||||
"SEASON_MARKED_WATCHED": "{{count}}話を視聴済みにしました",
|
||||
"SEASON_MARKED_UNWATCHED": "シーズンを未視聴にしました",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "視聴済み {{count}} · 失敗 {{failed}}",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "未視聴 {{count}} · 失敗 {{failed}}",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "シーズンの更新に失敗しました",
|
||||
"EPISODES_WATCHED": "{{count}} / {{total}}エピソード視聴済み",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "ポータルのメタデータは利用できません",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "시청 중",
|
||||
"MARK_WATCHED": "시청한 것으로 표시",
|
||||
"MARK_UNWATCHED": "시청하지 않은 것으로 표시",
|
||||
"MARK_SEASON_WATCHED": "시즌을 시청한 것으로 표시 ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "시즌을 시청하지 않은 것으로 표시",
|
||||
"SEASON_MARKED_WATCHED": "{{count}}개 에피소드가 시청한 것으로 표시되었습니다",
|
||||
"SEASON_MARKED_UNWATCHED": "시즌이 시청하지 않은 것으로 표시되었습니다",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}}개 표시됨 · {{failed}}개 실패",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}}개 표시 해제됨 · {{failed}}개 실패",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "시즌 업데이트에 실패했습니다",
|
||||
"EPISODES_WATCHED": "{{total}}개 중 {{count}}개 에피소드 시청",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "포털 메타데이터를 사용할 수 없습니다",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "Bezig",
|
||||
"MARK_WATCHED": "Markeren als bekeken",
|
||||
"MARK_UNWATCHED": "Markeren als niet bekeken",
|
||||
"MARK_SEASON_WATCHED": "Seizoen markeren als bekeken ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "Seizoen markeren als niet bekeken",
|
||||
"SEASON_MARKED_WATCHED": "{{count}} afleveringen gemarkeerd als bekeken",
|
||||
"SEASON_MARKED_UNWATCHED": "Seizoen gemarkeerd als niet bekeken",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} gemarkeerd · {{failed}} mislukt",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} als niet bekeken gemarkeerd · {{failed}} mislukt",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Bijwerken van het seizoen is mislukt",
|
||||
"EPISODES_WATCHED": "{{count}} van {{total}} afleveringen bekeken",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Portaalmetadata niet beschikbaar",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "W trakcie",
|
||||
"MARK_WATCHED": "Oznacz jako obejrzane",
|
||||
"MARK_UNWATCHED": "Oznacz jako nieobejrzane",
|
||||
"MARK_SEASON_WATCHED": "Oznacz sezon jako obejrzany ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "Oznacz sezon jako nieobejrzany",
|
||||
"SEASON_MARKED_WATCHED": "Oznaczono {{count}} odcinków jako obejrzane",
|
||||
"SEASON_MARKED_UNWATCHED": "Oznaczono sezon jako nieobejrzany",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "Oznaczono {{count}} · Nieudane {{failed}}",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "Odznaczono {{count}} · Nieudane {{failed}}",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Nie udało się zaktualizować sezonu",
|
||||
"EPISODES_WATCHED": "Obejrzano {{count}} z {{total}} odcinków",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Metadane portalu niedostępne",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "Em andamento",
|
||||
"MARK_WATCHED": "Marcar como assistido",
|
||||
"MARK_UNWATCHED": "Marcar como não assistido",
|
||||
"MARK_SEASON_WATCHED": "Marcar temporada como assistida ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "Marcar temporada como não assistida",
|
||||
"SEASON_MARKED_WATCHED": "{{count}} episódios marcados como assistidos",
|
||||
"SEASON_MARKED_UNWATCHED": "Temporada marcada como não assistida",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} marcados · {{failed}} com falha",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} desmarcados · {{failed}} com falha",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "A atualização da temporada falhou",
|
||||
"EPISODES_WATCHED": "{{count}} de {{total}} episódios assistidos",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Metadados do portal indisponíveis",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "В процессе",
|
||||
"MARK_WATCHED": "Отметить как просмотренное",
|
||||
"MARK_UNWATCHED": "Отметить как непросмотренное",
|
||||
"MARK_SEASON_WATCHED": "Отметить сезон как просмотренный ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "Отметить сезон как непросмотренный",
|
||||
"SEASON_MARKED_WATCHED": "Отмечено как просмотренные: {{count}} эпизодов",
|
||||
"SEASON_MARKED_UNWATCHED": "Сезон отмечен как непросмотренный",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "Отмечено: {{count}} · Ошибок: {{failed}}",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "Снято отметок: {{count}} · Ошибок: {{failed}}",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Не удалось обновить сезон",
|
||||
"EPISODES_WATCHED": "Просмотрено {{count}} из {{total}} эпизодов",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Метаданные портала недоступны",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "Devam Ediyor",
|
||||
"MARK_WATCHED": "İzlendi Olarak İşaretle",
|
||||
"MARK_UNWATCHED": "İzlenmedi Olarak İşaretle",
|
||||
"MARK_SEASON_WATCHED": "Sezonu İzlendi Olarak İşaretle ({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "Sezonu İzlenmedi Olarak İşaretle",
|
||||
"SEASON_MARKED_WATCHED": "{{count}} bölüm izlendi olarak işaretlendi",
|
||||
"SEASON_MARKED_UNWATCHED": "Sezon izlenmedi olarak işaretlendi",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} işaretlendi · {{failed}} başarısız",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} işaret kaldırıldı · {{failed}} başarısız",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Sezon güncellemesi başarısız oldu",
|
||||
"EPISODES_WATCHED": "{{total}} bölümden {{count}} tanesi izlendi",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Portal üst verisi mevcut değil",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "观看中",
|
||||
"MARK_WATCHED": "标记为已观看",
|
||||
"MARK_UNWATCHED": "标记为未观看",
|
||||
"MARK_SEASON_WATCHED": "标记整季为已观看({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "标记整季为未观看",
|
||||
"SEASON_MARKED_WATCHED": "{{count}} 集已标记为已观看",
|
||||
"SEASON_MARKED_UNWATCHED": "本季已标记为未观看",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "已标记 {{count}} · 失败 {{failed}}",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "已取消标记 {{count}} · 失败 {{failed}}",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "无法更新本季",
|
||||
"EPISODES_WATCHED": "已观看 {{count}} / {{total}} 集",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "门户元数据不可用",
|
||||
|
||||
@@ -893,6 +893,13 @@
|
||||
"IN_PROGRESS": "觀看中",
|
||||
"MARK_WATCHED": "標記為已觀看",
|
||||
"MARK_UNWATCHED": "標記為未觀看",
|
||||
"MARK_SEASON_WATCHED": "標記整季為已觀看({{count}})",
|
||||
"MARK_SEASON_UNWATCHED": "標記整季為未觀看",
|
||||
"SEASON_MARKED_WATCHED": "已將 {{count}} 集標記為已觀看",
|
||||
"SEASON_MARKED_UNWATCHED": "本季已標記為未觀看",
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} 集已標記 · {{failed}} 集失敗",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} 集已取消標記 · {{failed}} 集失敗",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "本季更新失敗",
|
||||
"EPISODES_WATCHED": "已觀看 {{total}} 集中的 {{count}} 集",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "無法取得入口網站中繼資料",
|
||||
|
||||
Reference in new issue
Block a user