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:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-15 21:03:14 +02:00
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) =>
@@ -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
);
}
}
}
+72
View File
@@ -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,
}) => {
+105
View File
@@ -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 = () => [
+7
View File
@@ -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": "البيانات الوصفية للبوابة غير متاحة",
+7
View File
@@ -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": "البيانات الوصفية ديال البوابة ما متوفراش",
+7
View File
@@ -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": "Метаданыя партала недаступныя",
+7
View File
@@ -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",
+7
View File
@@ -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": "Τα μεταδεδομένα της πύλης δεν είναι διαθέσιμα",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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": "ポータルのメタデータは利用できません",
+7
View File
@@ -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": "포털 메타데이터를 사용할 수 없습니다",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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": "Метаданные портала недоступны",
+7
View File
@@ -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",
+7
View File
@@ -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": "门户元数据不可用",
+7
View File
@@ -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": "無法取得入口網站中繼資料",