mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
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)
521 lines
27 KiB
HTML
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>
|