mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -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
@@ -12,6 +12,16 @@ export function parseDuration(duration: string | number | undefined): number {
|
||||
return duration;
|
||||
}
|
||||
|
||||
// Providers report "45 min" (Stalker VOD) and "1h 30min" (Xtream) —
|
||||
// the hour component must not be dropped when both are present.
|
||||
const minutesMatch = duration.match(/(?:(\d+)\s*h\w*)?\s*(\d+)\s*min/);
|
||||
if (minutesMatch) {
|
||||
return (
|
||||
parseInt(minutesMatch[1] ?? '0', 10) * 3600 +
|
||||
parseInt(minutesMatch[2], 10) * 60
|
||||
);
|
||||
}
|
||||
|
||||
const parts = duration.split(':').map(Number);
|
||||
if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
|
||||
@@ -7,6 +7,11 @@ describe('episode-progress.util', () => {
|
||||
it('parses duration strings', () => {
|
||||
expect(parseDuration('01:00:30')).toBe(3630);
|
||||
expect(parseDuration('45:12')).toBe(2712);
|
||||
// Stalker VOD episodes report minute strings; Xtream fixtures
|
||||
// report compound hour/minute strings.
|
||||
expect(parseDuration('45 min')).toBe(2700);
|
||||
expect(parseDuration('1h 30min')).toBe(5400);
|
||||
expect(parseDuration('2h 05min')).toBe(7500);
|
||||
expect(parseDuration(120)).toBe(120);
|
||||
expect(parseDuration(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
[batchRunning]="downloadPresenter.batchRunning()"
|
||||
[seasonDisabled]="downloadPresenter.seasonDisabled()"
|
||||
[eligibleEpisodeCount]="downloadPresenter.eligibleEpisodeCount()"
|
||||
[watchToggleVisible]="seasonWatchToggleVisible()"
|
||||
[watchBatchRunning]="seasonWatchBatchRunning()"
|
||||
[seasonFullyWatched]="selectedSeasonFullyWatched()"
|
||||
[watchEligibleCount]="seasonWatchEligibleCount()"
|
||||
[viewToggleVisible]="hasSeasons() && !showSeasonEmptyState()"
|
||||
[viewMode]="viewMode()"
|
||||
(downloadSeason)="downloadPresenter.enqueueSeason()"
|
||||
(toggleSeasonWatched)="toggleSeasonWatched()"
|
||||
(viewModeChange)="setViewMode($event)"
|
||||
/>
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ const EN_TRANSLATIONS = JSON.parse(
|
||||
|
||||
const DOWNLOAD_TRANSLATIONS = {
|
||||
DOWNLOADS: EN_TRANSLATIONS['DOWNLOADS'],
|
||||
XTREAM: EN_TRANSLATIONS['XTREAM'],
|
||||
};
|
||||
|
||||
function createEpisode(
|
||||
@@ -1131,4 +1132,191 @@ describe('SeasonContainerComponent', () => {
|
||||
{ duration: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
describe('season watched toggle', () => {
|
||||
// The Stalker-style '45 min' format parses to 2700 seconds.
|
||||
const PARSED_DURATION = 2700;
|
||||
const parseableInfo = {
|
||||
duration: '45 min',
|
||||
plot: 'Pilot episode',
|
||||
movie_image: 'https://example.com/poster.jpg',
|
||||
};
|
||||
|
||||
const watchedPosition = (contentXtreamId: number) => ({
|
||||
contentXtreamId,
|
||||
contentType: 'episode' as const,
|
||||
seriesXtreamId: 20,
|
||||
positionSeconds: 100,
|
||||
durationSeconds: 100,
|
||||
playlistId: 'playlist-1',
|
||||
});
|
||||
|
||||
const toggleButton = (): HTMLButtonElement | null =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="toggle-season-watched"]'
|
||||
);
|
||||
|
||||
const threeEpisodes = () => ({
|
||||
'1': [
|
||||
createEpisode({ info: parseableInfo }),
|
||||
createEpisode({
|
||||
id: '102',
|
||||
episode_num: 2,
|
||||
info: parseableInfo,
|
||||
}),
|
||||
createEpisode({
|
||||
id: '103',
|
||||
episode_num: 3,
|
||||
info: parseableInfo,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
it('emits watched requests only for unwatched episodes with parsed durations', () => {
|
||||
const emitted: unknown[] = [];
|
||||
component.seasonPlaybackToggleRequested.subscribe((request) =>
|
||||
emitted.push(request)
|
||||
);
|
||||
setRequiredInputs(threeEpisodes());
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([[101, watchedPosition(101)]])
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = toggleButton();
|
||||
expect(button?.textContent).toContain(
|
||||
'Mark season as watched (2)'
|
||||
);
|
||||
button?.click();
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
seasonKey: '1',
|
||||
markWatched: true,
|
||||
requests: [
|
||||
{
|
||||
contentXtreamId: 102,
|
||||
nextPosition: expect.objectContaining({
|
||||
contentXtreamId: 102,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 20,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
positionSeconds: PARSED_DURATION,
|
||||
durationSeconds: PARSED_DURATION,
|
||||
playlistId: 'playlist-1',
|
||||
}),
|
||||
},
|
||||
{
|
||||
contentXtreamId: 103,
|
||||
nextPosition: expect.objectContaining({
|
||||
contentXtreamId: 103,
|
||||
episodeNumber: 3,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('flips to unwatch and emits null positions for every episode when the season is fully watched', () => {
|
||||
const emitted: {
|
||||
markWatched: boolean;
|
||||
requests: { contentXtreamId: number; nextPosition: unknown }[];
|
||||
}[] = [];
|
||||
component.seasonPlaybackToggleRequested.subscribe((request) =>
|
||||
emitted.push(request)
|
||||
);
|
||||
setRequiredInputs(threeEpisodes());
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([
|
||||
[101, watchedPosition(101)],
|
||||
[102, watchedPosition(102)],
|
||||
[103, watchedPosition(103)],
|
||||
])
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = toggleButton();
|
||||
expect(button?.textContent).toContain('Mark season as unwatched');
|
||||
expect(button?.querySelector('mat-icon')?.textContent).toContain(
|
||||
'remove_done'
|
||||
);
|
||||
button?.click();
|
||||
|
||||
expect(emitted).toHaveLength(1);
|
||||
expect(emitted[0].markWatched).toBe(false);
|
||||
expect(
|
||||
emitted[0].requests.map((request) => [
|
||||
request.contentXtreamId,
|
||||
request.nextPosition,
|
||||
])
|
||||
).toEqual([
|
||||
[101, null],
|
||||
[102, null],
|
||||
[103, null],
|
||||
]);
|
||||
});
|
||||
|
||||
it('never bulk-marks the playing episode and disables an empty action', () => {
|
||||
const emitted: {
|
||||
requests: { contentXtreamId: number }[];
|
||||
}[] = [];
|
||||
component.seasonPlaybackToggleRequested.subscribe((request) =>
|
||||
emitted.push(request)
|
||||
);
|
||||
setRequiredInputs(threeEpisodes());
|
||||
fixture.componentRef.setInput('playingEpisodeId', 102);
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([[101, watchedPosition(101)]])
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = toggleButton();
|
||||
// 103 is the only markable episode: 101 watched, 102 playing.
|
||||
expect(button?.textContent).toContain(
|
||||
'Mark season as watched (1)'
|
||||
);
|
||||
button?.click();
|
||||
expect(
|
||||
emitted[0].requests.map((item) => item.contentXtreamId)
|
||||
).toEqual([103]);
|
||||
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([
|
||||
[101, watchedPosition(101)],
|
||||
[103, watchedPosition(103)],
|
||||
])
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(toggleButton()?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('disables the toggle and reports busy while the host batch is running', () => {
|
||||
setRequiredInputs(threeEpisodes());
|
||||
fixture.componentRef.setInput('seasonWatchBatchRunning', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = toggleButton();
|
||||
expect(button?.disabled).toBe(true);
|
||||
expect(button?.getAttribute('aria-busy')).toBe('true');
|
||||
expect(button?.querySelector('mat-spinner')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides the toggle without a playlist id and on empty seasons', () => {
|
||||
setRequiredInputs(threeEpisodes());
|
||||
fixture.componentRef.setInput('playlistId', '');
|
||||
fixture.detectChanges();
|
||||
expect(toggleButton()).toBeNull();
|
||||
|
||||
fixture.componentRef.setInput('playlistId', 'playlist-1');
|
||||
fixture.componentRef.setInput('seasons', {});
|
||||
fixture.detectChanges();
|
||||
expect(toggleButton()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,23 +35,28 @@ import {
|
||||
EpisodeInfoDialogComponent,
|
||||
buildEpisodeInfoDialogData,
|
||||
} from './episode-info-dialog.component';
|
||||
import {
|
||||
formatEpisodePositionText,
|
||||
parseDuration,
|
||||
} from './episode-progress.util';
|
||||
import { formatEpisodePositionText } from './episode-progress.util';
|
||||
import { SeasonDownloadPresenter } from './season-download-presenter';
|
||||
import {
|
||||
type EpisodeViewMode,
|
||||
SeasonHeaderComponent,
|
||||
} from './season-header.component';
|
||||
import { SeasonTabsComponent } from './season-tabs.component';
|
||||
import {
|
||||
type SeasonContainerPlaybackToggleRequest,
|
||||
type SeasonContainerSeasonPlaybackToggleRequest,
|
||||
buildSeasonWatchToggleRequest,
|
||||
buildWatchedEpisodePosition,
|
||||
listMarkableEpisodes,
|
||||
resolveEpisodeInfo,
|
||||
} from './season-watch-toggle.util';
|
||||
|
||||
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
|
||||
|
||||
export interface SeasonContainerPlaybackToggleRequest {
|
||||
contentXtreamId: number;
|
||||
nextPosition: PlaybackPositionData | null;
|
||||
}
|
||||
export type {
|
||||
SeasonContainerPlaybackToggleRequest,
|
||||
SeasonContainerSeasonPlaybackToggleRequest,
|
||||
} from './season-watch-toggle.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
@@ -94,10 +99,14 @@ export class SeasonContainerComponent implements OnInit {
|
||||
readonly playingEpisodeId = input<number | null>(null);
|
||||
/** Per-season descriptions (TMDB/provider), keyed by season key. */
|
||||
readonly seasonDescriptions = input<Record<string, string> | null>(null);
|
||||
/** True while a host is persisting a season-level watched toggle. */
|
||||
readonly seasonWatchBatchRunning = input(false);
|
||||
|
||||
readonly episodeClicked = output<XtreamSerieEpisode>();
|
||||
readonly playbackToggleRequested =
|
||||
output<SeasonContainerPlaybackToggleRequest>();
|
||||
readonly seasonPlaybackToggleRequested =
|
||||
output<SeasonContainerSeasonPlaybackToggleRequest>();
|
||||
readonly seasonSelected = output<string>();
|
||||
readonly viewMode = signal<EpisodeViewMode>('grid');
|
||||
|
||||
@@ -143,6 +152,51 @@ export class SeasonContainerComponent implements OnInit {
|
||||
return selected ? (this.seasons()[selected] ?? []) : [];
|
||||
});
|
||||
|
||||
readonly selectedSeasonUnwatchedCount = computed(
|
||||
() =>
|
||||
this.selectedSeasonEpisodes().filter(
|
||||
(episode) => !this.isEpisodeWatched(episode)
|
||||
).length
|
||||
);
|
||||
|
||||
readonly selectedSeasonFullyWatched = computed(
|
||||
() =>
|
||||
this.selectedSeasonEpisodes().length > 0 &&
|
||||
this.selectedSeasonUnwatchedCount() === 0
|
||||
);
|
||||
|
||||
/**
|
||||
* The episode currently playing (inline or externally) or launching is
|
||||
* never bulk-marked: the player persists its live position and would
|
||||
* immediately overwrite the full-progress row.
|
||||
*/
|
||||
private readonly seasonWatchExcludedIds = computed(() => {
|
||||
const ids = [
|
||||
this.playingEpisodeId(),
|
||||
this.activeEpisodeId(),
|
||||
this.openingEpisodeId(),
|
||||
].filter((id): id is number => id !== null);
|
||||
return new Set(ids);
|
||||
});
|
||||
|
||||
/** Count shown in the season toggle label: episodes the action touches. */
|
||||
readonly seasonWatchEligibleCount = computed(() =>
|
||||
this.selectedSeasonFullyWatched()
|
||||
? this.selectedSeasonEpisodes().length
|
||||
: listMarkableEpisodes(
|
||||
this.selectedSeasonEpisodes(),
|
||||
(episode) => this.isEpisodeWatched(episode),
|
||||
this.seasonWatchExcludedIds()
|
||||
).length
|
||||
);
|
||||
|
||||
readonly seasonWatchToggleVisible = computed(
|
||||
() =>
|
||||
this.sortedSeasonKeys().length > 0 &&
|
||||
this.selectedSeasonEpisodes().length > 0 &&
|
||||
!!this.playlistId()
|
||||
);
|
||||
|
||||
private readonly autoSelectKey = computed(
|
||||
() =>
|
||||
`${this.sortedSeasonKeys().join('|')}::${
|
||||
@@ -294,35 +348,44 @@ export class SeasonContainerComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const info = this.getEpisodeInfo(episode);
|
||||
const duration =
|
||||
info?.duration_secs || parseDuration(info?.duration) || 1;
|
||||
|
||||
this.playbackToggleRequested.emit({
|
||||
contentXtreamId,
|
||||
nextPosition: {
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: this.seriesId(),
|
||||
seasonNumber: Number(
|
||||
episode.season || this.selectedSeason() || 1
|
||||
),
|
||||
episodeNumber: Number(episode.episode_num || 1),
|
||||
positionSeconds: duration,
|
||||
durationSeconds: duration,
|
||||
nextPosition: buildWatchedEpisodePosition({
|
||||
episode,
|
||||
seriesId: this.seriesId(),
|
||||
playlistId: this.playlistId(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
fallbackSeasonKey: this.selectedSeason(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
toggleSeasonWatched() {
|
||||
const seasonKey = this.selectedSeason();
|
||||
if (!seasonKey || !this.playlistId()) {
|
||||
this.logger.warn('Cannot toggle season watched: no season/playlist');
|
||||
return;
|
||||
}
|
||||
if (this.seasonWatchBatchRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = buildSeasonWatchToggleRequest({
|
||||
episodes: this.selectedSeasonEpisodes(),
|
||||
seasonKey,
|
||||
seriesId: this.seriesId(),
|
||||
playlistId: this.playlistId(),
|
||||
isEpisodeWatched: (episode) => this.isEpisodeWatched(episode),
|
||||
excludedEpisodeIds: this.seasonWatchExcludedIds(),
|
||||
});
|
||||
if (request) {
|
||||
this.seasonPlaybackToggleRequested.emit(request);
|
||||
}
|
||||
}
|
||||
|
||||
getEpisodeInfo(
|
||||
episode: XtreamSerieEpisode
|
||||
): XtreamSerieEpisodeInfo | undefined {
|
||||
if (Array.isArray(episode.info) || !episode.info) {
|
||||
return undefined;
|
||||
}
|
||||
return episode.info;
|
||||
return resolveEpisodeInfo(episode);
|
||||
}
|
||||
|
||||
isEpisodeWatched(episode: XtreamSerieEpisode): boolean {
|
||||
|
||||
@@ -4,8 +4,46 @@
|
||||
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
|
||||
</h3>
|
||||
</div>
|
||||
@if (downloadVisible() || viewToggleVisible()) {
|
||||
@if (downloadVisible() || watchToggleVisible() || viewToggleVisible()) {
|
||||
<div class="section-header__actions">
|
||||
@if (watchToggleVisible()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="season-watch-toggle-button"
|
||||
data-test-id="toggle-season-watched"
|
||||
[disabled]="
|
||||
watchBatchRunning() ||
|
||||
(!seasonFullyWatched() && watchEligibleCount() === 0)
|
||||
"
|
||||
[attr.aria-label]="
|
||||
(seasonFullyWatched()
|
||||
? 'XTREAM.MARK_SEASON_UNWATCHED'
|
||||
: 'XTREAM.MARK_SEASON_WATCHED'
|
||||
) | translate: { count: watchEligibleCount() }
|
||||
"
|
||||
[attr.aria-busy]="watchBatchRunning() ? 'true' : null"
|
||||
(click)="toggleSeasonWatched.emit()"
|
||||
>
|
||||
@if (watchBatchRunning()) {
|
||||
<mat-spinner
|
||||
diameter="18"
|
||||
aria-hidden="true"
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
seasonFullyWatched() ? 'remove_done' : 'done_all'
|
||||
}}</mat-icon>
|
||||
}
|
||||
<span class="season-watch-toggle-button__label">
|
||||
{{
|
||||
(seasonFullyWatched()
|
||||
? 'XTREAM.MARK_SEASON_UNWATCHED'
|
||||
: 'XTREAM.MARK_SEASON_WATCHED'
|
||||
) | translate: { count: watchEligibleCount() }
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@if (downloadVisible()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
|
||||
@@ -84,7 +84,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.season-download-button {
|
||||
.season-download-button,
|
||||
.season-watch-toggle-button {
|
||||
min-height: 36px;
|
||||
|
||||
mat-spinner {
|
||||
|
||||
@@ -32,9 +32,14 @@ export class SeasonHeaderComponent {
|
||||
readonly batchRunning = input.required<boolean>();
|
||||
readonly seasonDisabled = input.required<boolean>();
|
||||
readonly eligibleEpisodeCount = input.required<number>();
|
||||
readonly watchToggleVisible = input.required<boolean>();
|
||||
readonly watchBatchRunning = input.required<boolean>();
|
||||
readonly seasonFullyWatched = input.required<boolean>();
|
||||
readonly watchEligibleCount = input.required<number>();
|
||||
readonly viewToggleVisible = input.required<boolean>();
|
||||
readonly viewMode = input.required<EpisodeViewMode>();
|
||||
|
||||
readonly downloadSeason = output<void>();
|
||||
readonly toggleSeasonWatched = output<void>();
|
||||
readonly viewModeChange = output<EpisodeViewMode>();
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
XtreamSerieEpisodeInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { parseDuration } from './episode-progress.util';
|
||||
|
||||
export interface SeasonContainerPlaybackToggleRequest {
|
||||
contentXtreamId: number;
|
||||
nextPosition: PlaybackPositionData | null;
|
||||
}
|
||||
|
||||
export interface SeasonContainerSeasonPlaybackToggleRequest {
|
||||
seasonKey: string;
|
||||
markWatched: boolean;
|
||||
requests: SeasonContainerPlaybackToggleRequest[];
|
||||
}
|
||||
|
||||
export function resolveEpisodeInfo(
|
||||
episode: XtreamSerieEpisode
|
||||
): XtreamSerieEpisodeInfo | undefined {
|
||||
if (Array.isArray(episode.info) || !episode.info) {
|
||||
return undefined;
|
||||
}
|
||||
return episode.info;
|
||||
}
|
||||
|
||||
/** Full-progress position row marking one episode as watched. */
|
||||
export function buildWatchedEpisodePosition(options: {
|
||||
episode: XtreamSerieEpisode;
|
||||
seriesId: number;
|
||||
playlistId: string;
|
||||
fallbackSeasonKey: string | undefined;
|
||||
}): PlaybackPositionData {
|
||||
const { episode } = options;
|
||||
const contentXtreamId = Number(episode.id);
|
||||
const info = resolveEpisodeInfo(episode);
|
||||
const duration = info?.duration_secs || parseDuration(info?.duration) || 1;
|
||||
|
||||
return {
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: options.seriesId,
|
||||
seasonNumber: Number(episode.season || options.fallbackSeasonKey || 1),
|
||||
episodeNumber: Number(episode.episode_num || 1),
|
||||
positionSeconds: duration,
|
||||
durationSeconds: duration,
|
||||
playlistId: options.playlistId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Episodes a mark-season-watched action would touch. */
|
||||
export function listMarkableEpisodes(
|
||||
episodes: readonly XtreamSerieEpisode[],
|
||||
isEpisodeWatched: (episode: XtreamSerieEpisode) => boolean,
|
||||
excludedEpisodeIds?: ReadonlySet<number>
|
||||
): XtreamSerieEpisode[] {
|
||||
return episodes.filter(
|
||||
(episode) =>
|
||||
!isEpisodeWatched(episode) &&
|
||||
!excludedEpisodeIds?.has(Number(episode.id))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Season-level bulk toggle request. Marking touches only unwatched episodes
|
||||
* so a watched episode's real duration is never overwritten with the parsed
|
||||
* fallback; unmarking clears every episode of the season. Returns null when
|
||||
* the action would touch nothing.
|
||||
*
|
||||
* `excludedEpisodeIds` (the episode playing inline or in an external
|
||||
* session, or one whose launch is in flight) is honored for MARKING only:
|
||||
* the player persists its real position every ~15 s, which would overwrite
|
||||
* a just-written full-progress row and silently flip the episode back to
|
||||
* in-progress. Unmarking still clears such an episode — the next position
|
||||
* tick recreating an in-progress row reflects the live playback truthfully.
|
||||
*/
|
||||
export function buildSeasonWatchToggleRequest(options: {
|
||||
episodes: readonly XtreamSerieEpisode[];
|
||||
seasonKey: string;
|
||||
seriesId: number;
|
||||
playlistId: string;
|
||||
isEpisodeWatched: (episode: XtreamSerieEpisode) => boolean;
|
||||
excludedEpisodeIds?: ReadonlySet<number>;
|
||||
}): SeasonContainerSeasonPlaybackToggleRequest | null {
|
||||
const markWatched = options.episodes.some(
|
||||
(episode) => !options.isEpisodeWatched(episode)
|
||||
);
|
||||
const targets = markWatched
|
||||
? listMarkableEpisodes(
|
||||
options.episodes,
|
||||
options.isEpisodeWatched,
|
||||
options.excludedEpisodeIds
|
||||
)
|
||||
: options.episodes;
|
||||
|
||||
const requests = targets.map((episode) => ({
|
||||
contentXtreamId: Number(episode.id),
|
||||
nextPosition: markWatched
|
||||
? buildWatchedEpisodePosition({
|
||||
episode,
|
||||
seriesId: options.seriesId,
|
||||
playlistId: options.playlistId,
|
||||
fallbackSeasonKey: options.seasonKey,
|
||||
})
|
||||
: null,
|
||||
}));
|
||||
if (requests.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { seasonKey: options.seasonKey, markWatched, requests };
|
||||
}
|
||||
Reference in new issue
Block a user