Files
iptvnator/libs/ui/components/src/lib/season-container/season-container.component.html
T
genrichh93-ui 188f5c4b56 feat(downloads): pause and resume support for the download manager (#1147)
Adds a paused state to the Electron download manager with a full partial-file lifecycle:

- Pause keeps the .part and byte progress; cancel discards them; every lifecycle stage (queued, active, mid-transfer) is pausable.
- Resume continues via HTTP Range with If-Range entity validation (strong ETag / Last-Modified persisted in the new resume_validator column, idempotent migration incl. legacy-table rebuild). Non-206 answers restart from zero over the same .part; the 206 Content-Range offset is verified; responses that end before the advertised size are retained for a Range retry instead of being committed as completed.
- Crash recovery converts interrupted transfers to paused, keeps queued-with-partial rows resumable, and commits finalizations that crashed before the DB update.
- Destination collisions are non-destructive (retained partials finalize to the next numbered name); locked .part files never lose their DB owner across cancel/remove/restart; resume claims rows atomically and the queue dedupes ids.
- Stored request headers are re-filtered through the User-Agent/Origin/Referer allowlist on read, URL-derived extensions are sanitized, resume appends never follow symlinks, and transfer errors are logged by message only.
- UI: pause/resume/cancel/retry/remove surface failures in a snackbar; paused items show an active Resume button in VOD/episode detail views; translations for all 18 locales.
- Runtime split into download-runtime/transfer/finalize/broadcast modules; +30 unit tests and an Electron E2E covering pause -> retained .part -> Range/If-Range resume -> byte-exact assembly.

Co-authored-by: genrichh93-ui <genrichh93@users.noreply.github.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-24 18:40:30 +02:00

521 lines
27 KiB
HTML

<section class="seasons-section">
<div class="section-header">
<div class="section-header__left">
<h3 class="section-title">
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
</h3>
</div>
@if (hasSeasons() && !showSeasonEmptyState()) {
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
(change)="setViewMode($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle
value="grid"
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
>
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
<mat-button-toggle
value="list"
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
}
</div>
@if (hasSeasons()) {
<app-season-tabs
[seasonKeys]="sortedSeasonKeys()"
[selectedSeason]="selectedSeason()"
[episodeCounts]="episodeCounts()"
[watchedCounts]="watchedCounts()"
[seasonDescription]="selectedSeasonDescription()"
[playingSeasonKey]="playingSeasonKey()"
(seasonSelected)="selectSeason($event)"
(backToPlayingRequested)="scrollToPlayingEpisode()"
/>
}
<div class="content-container">
@if (isLoading()) {
<div class="loading-container">
<mat-spinner diameter="40"></mat-spinner>
</div>
} @else if (showSeriesEmptyState()) {
<div class="empty-state-panel" role="status">
<div class="empty-state-panel__icon" aria-hidden="true">
<mat-icon>video_library</mat-icon>
</div>
<h4 class="empty-state-panel__title">
{{ 'PORTALS.NO_EPISODES_AVAILABLE' | translate }}
</h4>
<p class="empty-state-panel__message">
{{ 'PORTALS.NO_EPISODES_AVAILABLE_MESSAGE' | translate }}
</p>
</div>
} @else if (showSeasonEmptyState()) {
<div
class="empty-state-panel empty-state-panel--season"
role="status"
>
<div class="empty-state-panel__icon" aria-hidden="true">
<mat-icon>video_library</mat-icon>
</div>
<h4 class="empty-state-panel__title">
{{ 'PORTALS.SEASON_EMPTY' | translate }}
</h4>
<p class="empty-state-panel__message">
{{ 'PORTALS.SEASON_EMPTY_MESSAGE' | translate }}
</p>
</div>
} @else {
@if (viewMode() === 'grid') {
<!-- Episodes Grid View -->
<div class="episodes-grid">
@for (episode of selectedSeasonEpisodes(); track $index) {
@let episodeInfo = getEpisodeInfo(episode);
<article
class="episode-card"
[attr.data-episode-id]="
getEpisodeContentId(episode)
"
(click)="selectEpisode(episode)"
[class.episode-card--watched]="
isEpisodeWatched(episode)
"
[class.episode-card--in-progress]="
isEpisodeInProgress(episode)
"
[class.episode-card--launching]="
isEpisodeLaunching(episode)
"
[class.episode-card--active-external]="
isEpisodeActiveExternal(episode)
"
[class.episode-card--playing]="
isEpisodePlayingInline(episode)
"
>
<div class="episode-card__thumbnail">
@if (episodeInfo?.movie_image) {
<img
[src]="episodeInfo?.movie_image ?? ''"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
alt="Episode thumbnail"
loading="lazy"
/>
} @else {
<div class="episode-card__placeholder">
<mat-icon>play_circle_outline</mat-icon>
</div>
}
@if (getEpisodeProgress(episode) > 0) {
<app-progress-capsule
[progress]="getEpisodeProgress(episode)"
/>
@if (
getEpisodePositionText(episode);
as time
) {
<div
class="episode-card__position-text"
>
{{ time }}
</div>
}
}
<button
mat-icon-button
class="episode-card__watched-toggle"
[class.episode-card__watched-toggle--watched]="
isEpisodeWatched(episode)
"
data-testid="episode-watched-toggle"
(click)="toggleWatched($event, episode)"
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED'
) | translate
"
>
<mat-icon>{{
isEpisodeWatched(episode)
? 'check_circle'
: 'radio_button_unchecked'
}}</mat-icon>
</button>
<div class="episode-card__actions">
@if (isElectron()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="download-btn download-btn--completed"
(click)="
playFromLocal(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.PLAY_LOCAL'
| translate
"
>
<mat-icon>play_circle</mat-icon>
</button>
} @else if (
isEpisodePaused(episode)
) {
<button
mat-icon-button
class="download-btn download-btn--paused"
(click)="
resumeEpisodeDownload(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.RESUME'
| translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
} @else if (
isEpisodeDownloading(episode)
) {
<button
mat-icon-button
class="download-btn download-btn--downloading"
disabled
[matTooltip]="
'DOWNLOADS.STATUS.DOWNLOADING'
| translate
"
>
<mat-icon>downloading</mat-icon>
</button>
} @else {
<button
mat-icon-button
class="download-btn"
(click)="
downloadEpisode(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.DOWNLOAD'
| translate
"
>
<mat-icon>download</mat-icon>
</button>
}
}
</div>
<div class="episode-card__overlay">
<div class="episode-card__play">
@if (isEpisodeLaunching(episode)) {
<mat-spinner
diameter="26"
></mat-spinner>
} @else {
<mat-icon>{{
isEpisodeActiveExternal(
episode
) ||
isEpisodePlayingInline(episode)
? 'equalizer'
: 'play_arrow'
}}</mat-icon>
}
</div>
</div>
<div class="episode-card__number">
{{ episode.episode_num }}
</div>
</div>
<div class="episode-card__info">
<div class="episode-card__title-row">
<h4 class="episode-card__title">
{{ episode.episode_num }}.
{{ episode.title }}
</h4>
@if (episodeInfo?.plot) {
<button
mat-icon-button
class="episode-card__info-btn"
data-testid="episode-info-button"
(click)="
openEpisodeInfo(
$event,
episode
)
"
[matTooltip]="
'PORTALS.EPISODE_INFO'
| translate
"
>
<mat-icon>info_outline</mat-icon>
</button>
}
</div>
@if (episodeInfo?.plot) {
<p class="episode-card__plot">
{{ episodeInfo?.plot }}
</p>
}
@if (episodeInfo?.duration) {
<span class="episode-card__duration">
{{ episodeInfo?.duration }}
</span>
}
</div>
</article>
}
</div>
} @else {
<!-- Episodes List View -->
<div class="episodes-list">
@for (episode of selectedSeasonEpisodes(); track $index) {
@let episodeInfo = getEpisodeInfo(episode);
<article
class="episode-list-item"
[attr.data-episode-id]="
getEpisodeContentId(episode)
"
(click)="selectEpisode(episode)"
[class.episode-list-item--watched]="
isEpisodeWatched(episode)
"
[class.episode-list-item--in-progress]="
isEpisodeInProgress(episode)
"
[class.episode-list-item--launching]="
isEpisodeLaunching(episode)
"
[class.episode-list-item--active-external]="
isEpisodeActiveExternal(episode)
"
[class.episode-list-item--playing]="
isEpisodePlayingInline(episode)
"
>
@if (listThumbnailsEnabled()) {
<div class="episode-list-item__thumb">
@if (episodeInfo?.movie_image) {
<img
[src]="
episodeInfo?.movie_image ?? ''
"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
alt="Episode thumbnail"
loading="lazy"
/>
} @else {
<mat-icon
class="episode-list-item__thumb-placeholder"
>play_circle_outline</mat-icon
>
}
<span
class="episode-list-item__thumb-number"
>{{ episode.episode_num }}</span
>
</div>
} @else {
<div class="episode-list-item__number">
{{ episode.episode_num }}
</div>
}
<div class="episode-list-item__content">
<div class="episode-list-item__header">
<h4 class="episode-list-item__title">
{{ episode.title }}
@if (isEpisodeWatched(episode)) {
<mat-icon class="watched-icon"
>check_circle</mat-icon
>
}
</h4>
<div class="episode-list-item__meta">
@if (episodeInfo?.duration) {
<span
class="episode-list-item__duration"
>
{{ episodeInfo?.duration }}
</span>
}
@if (
getEpisodePositionText(episode);
as time
) {
<span
class="episode-list-item__progress-text"
>
{{ time }}
</span>
}
</div>
</div>
@if (episodeInfo?.plot) {
<p class="episode-list-item__description">
{{ episodeInfo?.plot }}
</p>
}
@if (getEpisodeProgress(episode) > 0) {
<div
class="episode-list-item__progress-bar"
>
<div
class="episode-list-item__progress-fill"
[style.width.%]="
getEpisodeProgress(episode)
"
></div>
</div>
}
</div>
<div class="episode-list-item__actions">
<button
mat-icon-button
class="action-btn play-btn"
(click)="
selectEpisode(episode);
$event.stopPropagation()
"
[matTooltip]="'Play'"
[disabled]="isEpisodeLaunching(episode)"
>
@if (isEpisodeLaunching(episode)) {
<mat-spinner
diameter="18"
></mat-spinner>
} @else {
<mat-icon>{{
isEpisodeActiveExternal(episode) ||
isEpisodePlayingInline(episode)
? 'equalizer'
: 'play_arrow'
}}</mat-icon>
}
</button>
@if (isElectron()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="action-btn action-btn--success"
(click)="
playFromLocal($event, episode)
"
[matTooltip]="
'DOWNLOADS.PLAY_LOCAL'
| translate
"
>
<mat-icon>folder_open</mat-icon>
</button>
} @else if (isEpisodePaused(episode)) {
<button
mat-icon-button
class="action-btn"
(click)="
resumeEpisodeDownload(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.RESUME' | translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
} @else if (isEpisodeDownloading(episode)) {
<button
mat-icon-button
class="action-btn"
disabled
[matTooltip]="
'DOWNLOADS.STATUS.DOWNLOADING'
| translate
"
>
<mat-icon>downloading</mat-icon>
</button>
} @else {
<button
mat-icon-button
class="action-btn"
(click)="
downloadEpisode($event, episode)
"
[matTooltip]="
'DOWNLOADS.DOWNLOAD' | translate
"
>
<mat-icon>download</mat-icon>
</button>
}
}
@if (episodeInfo?.plot) {
<button
mat-icon-button
class="action-btn"
data-testid="episode-info-button"
(click)="
openEpisodeInfo($event, episode)
"
[matTooltip]="
'PORTALS.EPISODE_INFO' | translate
"
>
<mat-icon>info_outline</mat-icon>
</button>
}
<button
mat-icon-button
class="action-btn"
(click)="toggleWatched($event, episode)"
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED'
) | translate
"
[class.action-btn--success]="
isEpisodeWatched(episode)
"
>
<mat-icon>{{
isEpisodeWatched(episode)
? 'check_circle'
: 'radio_button_unchecked'
}}</mat-icon>
</button>
</div>
</article>
}
</div>
}
}
</div>
</section>