Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
@@ -0,0 +1,13 @@
{
"name": "portal-downloads-feature",
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/portal/downloads/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:portal", "domain:portal-shared", "type:feature"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1 @@
export * from './lib/downloads.component';
@@ -0,0 +1,331 @@
<div class="downloads-layout">
<div class="downloads">
<header class="downloads__header">
<div class="downloads__header-left">
<div class="settings-group__header-text">
<h1>{{ 'DOWNLOADS.TITLE' | translate }}</h1>
@if (activeCount() > 0) {
<span class="downloads__badge">{{
activeCount()
}}</span>
}
</div>
</div>
<div class="downloads__header-actions">
@if (isAvailable()) {
<div
class="downloads__folder-inline"
[attr.title]="
downloadFolder() ||
('DOWNLOADS.NO_FOLDER' | translate)
"
>
<mat-icon>folder_open</mat-icon>
<span class="downloads__folder-inline-path">
{{
downloadFolder() ||
('DOWNLOADS.NO_FOLDER' | translate)
}}
</span>
</div>
<button mat-stroked-button (click)="changeFolder()">
{{ 'DOWNLOADS.CHANGE_FOLDER' | translate }}
</button>
@if (hasClearableDownloads()) {
<button
mat-stroked-button
(click)="clearCompleted()"
[matTooltip]="'DOWNLOADS.CLEAR_COMPLETED' | translate"
[attr.aria-label]="
'DOWNLOADS.CLEAR_COMPLETED' | translate
"
>
<mat-icon>delete_sweep</mat-icon>
{{ 'DOWNLOADS.CLEAR_COMPLETED' | translate }}
</button>
}
}
</div>
</header>
@if (!isAvailable()) {
<div class="downloads__empty">
<mat-icon>desktop_access_disabled</mat-icon>
<p>{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}</p>
<span>{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }}</span>
</div>
} @else if (hasNoPlaylists()) {
<app-empty-state
type="no-data"
icon="download_for_offline"
titleKey="DOWNLOADS.NO_PLAYLISTS_TITLE"
descriptionKey="DOWNLOADS.NO_PLAYLISTS_BODY"
primaryActionLabelKey="HOME.PLAYLISTS.ADD_YOUR_FIRST"
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_SOURCES"
(primaryActionClicked)="addPlaylist()"
(secondaryActionClicked)="goToSources()"
/>
} @else if (!hasDownloads()) {
<app-empty-state
type="no-data"
icon="download_for_offline"
titleKey="DOWNLOADS.EMPTY"
descriptionKey="DOWNLOADS.NO_ITEMS_BODY"
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_DASHBOARD"
(secondaryActionClicked)="goToDashboard()"
/>
} @else if (filteredDownloads().length === 0) {
<div class="downloads__empty">
<mat-icon>search_off</mat-icon>
<p>{{ 'DOWNLOADS.NO_FILTER_MATCH' | translate }}</p>
<span>{{ 'DOWNLOADS.NO_FILTER_MATCH_HINT' | translate }}</span>
</div>
} @else {
<div class="downloads__list-wrapper">
<div class="downloads__list">
@for (item of filteredDownloads(); track item.id) {
<div
class="downloads__item"
[class]="getStatusColor(item.status)"
[class.downloads__item--clickable]="
isItemNavigable(item)
"
[class.downloads__item--unavailable]="
!hasSourcePlaylist(item)
"
[attr.role]="
isItemNavigable(item) ? 'button' : null
"
[attr.tabindex]="isItemNavigable(item) ? 0 : null"
[matTooltip]="
!hasSourcePlaylist(item)
? ('DOWNLOADS.SOURCE_PLAYLIST_MISSING'
| translate)
: null
"
(click)="openInLibrary(item)"
(keydown)="onItemCardKeydown($event, item)"
>
<div class="downloads__item-poster">
@if (hasPoster(item)) {
<img
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
(error)="markPosterFailed(item)"
/>
} @else {
<mat-icon>{{
getPosterPlaceholderIcon(item)
}}</mat-icon>
}
</div>
<div class="downloads__item-info">
<div class="downloads__item-title">
<span class="downloads__item-title-text">
{{ item.title }}
</span>
@if (item.contentType === 'episode') {
<span class="downloads__episode-badge">
{{ formatEpisodeLabel(item) }}
</span>
}
</div>
<div class="downloads__item-meta">
<span
class="downloads__item-status"
[class]="getStatusColor(item.status)"
>
<mat-icon>{{
getStatusIcon(item.status)
}}</mat-icon>
{{
'DOWNLOADS.STATUS.' +
item.status.toUpperCase()
| translate
}}
</span>
@if (
item.status === 'downloading' ||
item.status === 'completed'
) {
<span class="downloads__item-size">
{{
formatBytes(
item.bytesDownloaded
)
}}
@if (item.totalBytes) {
/
{{
formatBytes(item.totalBytes)
}}
}
</span>
}
@if (item.errorMessage) {
<span class="downloads__item-error">
{{ item.errorMessage }}
</span>
}
</div>
@if (item.status === 'downloading') {
<mat-progress-bar
mode="determinate"
[value]="getProgress(item)"
></mat-progress-bar>
}
<div class="downloads__item-actions">
<button
mat-icon-button
(click)="
$event.stopPropagation();
copyUrl(item)
"
[matTooltip]="
'DOWNLOADS.COPY_URL' | translate
"
>
<mat-icon>content_copy</mat-icon>
</button>
@switch (item.status) {
@case ('queued') {
<button
mat-icon-button
(click)="
$event.stopPropagation();
cancel(item)
"
[matTooltip]="
'DOWNLOADS.CANCEL' | translate
"
>
<mat-icon>close</mat-icon>
</button>
}
@case ('downloading') {
<button
mat-icon-button
(click)="
$event.stopPropagation();
cancel(item)
"
[matTooltip]="
'DOWNLOADS.CANCEL' | translate
"
>
<mat-icon>close</mat-icon>
</button>
}
@case ('completed') {
<button
mat-icon-button
color="primary"
(click)="
$event.stopPropagation();
play(item)
"
[matTooltip]="
'DOWNLOADS.PLAY' | translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
<button
mat-icon-button
(click)="
$event.stopPropagation();
reveal(item)
"
[matTooltip]="
'DOWNLOADS.REVEAL' | translate
"
>
<mat-icon>folder_open</mat-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="
$event.stopPropagation();
remove(item)
"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
"
>
<mat-icon>delete</mat-icon>
</button>
}
@case ('failed') {
<button
mat-icon-button
color="accent"
(click)="
$event.stopPropagation();
retry(item)
"
[matTooltip]="
'DOWNLOADS.RETRY' | translate
"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="
$event.stopPropagation();
remove(item)
"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
"
>
<mat-icon>delete</mat-icon>
</button>
}
@case ('canceled') {
<button
mat-icon-button
color="accent"
(click)="
$event.stopPropagation();
retry(item)
"
[matTooltip]="
'DOWNLOADS.RETRY' | translate
"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="
$event.stopPropagation();
remove(item)
"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
"
>
<mat-icon>delete</mat-icon>
</button>
}
}
</div>
</div>
</div>
}
</div>
</div>
}
</div>
</div>
@@ -0,0 +1,418 @@
:host {
width: 100%;
display: flex;
min-width: 0;
min-height: 0;
}
.downloads-layout {
display: flex;
flex: 1;
width: 100%;
min-width: 0;
min-height: 0;
}
.downloads {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding: 24px 16px 12px;
border-bottom: 1px solid var(--mat-sys-outline-variant);
&-left {
display: flex;
align-items: center;
gap: 16px;
.settings-group__header-text {
display: flex;
align-items: center;
gap: 0.75rem;
h1 {
margin: 0;
font-size: 1.2rem;
font-weight: 650;
letter-spacing: -0.02em;
}
}
.back-btn {
margin-right: 0.5rem;
}
}
&-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
}
}
&__badge {
background: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
&__folder-inline {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: min(42vw, 460px);
padding: 7px 10px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 86%,
transparent
);
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
box-shadow: inset 0 1px 0
color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent);
mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
color: var(--mat-sys-primary);
flex-shrink: 0;
}
}
&__folder-inline-path {
font-family:
'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas,
monospace;
font-size: 0.78rem;
color: var(--mat-sys-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
text-align: center;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 4rem;
width: 4rem;
height: 4rem;
margin-bottom: 1rem;
opacity: 0.4;
}
p {
margin: 0 0 0.5rem;
font-size: 1.05rem;
font-weight: 520;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
span {
font-size: 0.875rem;
}
}
&__list-wrapper {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0 16px;
container-type: inline-size;
}
&__list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr));
gap: 12px;
padding-bottom: 24px;
align-content: start;
}
&__item {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
align-items: stretch;
gap: 1rem;
padding: 1rem;
min-width: 0;
background: var(
--app-widget-bg,
var(--mat-sys-surface-container-high)
);
border-radius: 14px;
border: 1px solid var(--app-separator, var(--mat-sys-outline-variant));
box-shadow: var(--app-widget-shadow, none);
transition:
background 0.18s ease,
border-color 0.18s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
&--clickable {
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: 2px;
}
}
&:hover {
background: var(--app-selection-surface);
border-color: color-mix(
in srgb,
var(--app-selection-border) 72%,
transparent
);
}
&--unavailable {
cursor: default;
}
&-poster {
width: 64px;
height: 90px;
border-radius: 10px;
overflow: hidden;
flex-shrink: 0;
background: var(
--app-widget-header-bg,
var(--mat-sys-surface-container-high)
);
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--app-separator, var(--mat-sys-outline-variant));
img {
width: 100%;
height: 100%;
object-fit: cover;
}
mat-icon {
font-size: 2rem;
width: 2rem;
height: 2rem;
color: var(--mat-sys-on-surface-variant);
opacity: 0.5;
}
}
&-info {
flex: 1;
min-width: 0;
min-height: 90px;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&-title {
font-size: 1.05rem;
font-weight: 600;
color: var(--mat-sys-on-surface);
display: flex;
align-items: flex-start;
gap: 0.5rem;
min-width: 0;
}
&-title-text {
min-width: 0;
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.3;
}
&-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.85rem;
color: var(--mat-sys-on-surface-variant);
}
&-status {
display: flex;
align-items: center;
gap: 0.35rem;
font-weight: 500;
padding: 2px 8px;
border-radius: 12px;
background: var(
--app-widget-header-bg,
var(--mat-sys-surface-container-high)
);
mat-icon {
font-size: 1.1rem;
width: 1.1rem;
height: 1.1rem;
}
&.status-downloading {
color: #2196f3;
background: color-mix(in srgb, #2196f3 12%, transparent);
}
&.status-completed {
color: #4caf50;
background: color-mix(in srgb, #4caf50 12%, transparent);
}
&.status-failed {
color: var(--mat-sys-error);
background: color-mix(
in srgb,
var(--mat-sys-error) 12%,
transparent
);
}
&.status-queued {
color: var(--mat-sys-on-surface-variant);
}
}
&-size {
font-family:
'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas,
monospace;
background: transparent;
padding: 2px 8px;
border-radius: 12px;
}
&-error {
color: var(--mat-sys-error);
font-size: 0.8rem;
background: color-mix(
in srgb,
var(--mat-sys-error) 12%,
transparent
);
padding: 2px 8px;
border-radius: 12px;
}
&-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.2rem;
margin-top: auto;
padding-top: 0.65rem;
border-top: 1px solid
color-mix(
in srgb,
var(--app-separator, var(--mat-sys-outline-variant)) 72%,
transparent
);
button {
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
padding: 0;
border-radius: 9px;
--mdc-icon-button-state-layer-size: 32px;
transition:
background-color 0.15s ease,
transform 0.15s ease,
opacity 0.15s ease;
opacity: 0.86;
&:hover {
background: var(--app-selection-surface);
transform: translateY(-1px);
opacity: 1;
}
}
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
}
}
mat-progress-bar {
margin-top: 0.15rem;
border-radius: 4px;
}
}
&__episode-badge {
background: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
padding: 0.125rem 0.5rem;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
.sidebar-header h2 {
margin: 0;
}
@media (max-width: 980px) {
.downloads {
&__header {
align-items: flex-start;
gap: 12px;
}
&__header-actions {
width: 100%;
justify-content: flex-start;
}
&__folder-inline {
max-width: 100%;
}
&__list {
grid-template-columns: 1fr;
}
}
}
@container (max-width: 760px) {
.downloads__item {
align-items: start;
}
}
@@ -0,0 +1,559 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { DialogService } from 'components';
import { firstValueFrom, map } from 'rxjs';
import {
DatabaseService,
type DownloadItem,
DownloadsService,
PlaylistsService,
} from 'services';
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
import { queryParamSignal } from '@iptvnator/portal/shared/util';
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '@iptvnator/portal/shared/util';
import { PortalCollectionContextService } from '@iptvnator/portal/shared/util';
import { Playlist } from 'shared-interfaces';
type PortalSource = 'xtream' | 'stalker';
const DOWNLOAD_COLLECTION_LABELS = {
all: 'All',
movie: 'Movies',
live: 'Live TV',
series: 'Series',
};
@Component({
selector: 'app-downloads',
templateUrl: './downloads.component.html',
styleUrls: [
'./downloads.component.scss',
'../../../../shared/ui/src/lib/styles/portal-sidebar.scss',
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
EmptyStateComponent,
MatButtonModule,
MatIcon,
MatProgressBarModule,
MatTooltip,
TranslatePipe,
],
})
export class DownloadsComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly collectionCtx = inject(PortalCollectionContextService);
private readonly dialogService = inject(DialogService);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
readonly downloadsService = inject(DownloadsService);
readonly downloads = this.downloadsService.downloads;
readonly downloadFolder = this.downloadsService.downloadFolder;
readonly isAvailable = this.downloadsService.isAvailable;
readonly hasDownloads = this.downloadsService.hasDownloads;
readonly activeCount = this.downloadsService.activeCount;
readonly playlistId = toSignal(
this.route.paramMap.pipe(map((params) => params.get('id') ?? '')),
{ initialValue: this.route.snapshot.params['id'] ?? '' }
);
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
);
readonly playlists = toSignal(this.playlistsService.getAllPlaylists(), {
initialValue: [] as Playlist[],
});
readonly hasNoPlaylists = computed(() => this.playlists().length === 0);
addPlaylist(): void {
this.shellActions.openAddPlaylistDialog();
}
goToSources(): void {
void this.router.navigate(['/workspace', 'sources']);
}
goToDashboard(): void {
void this.router.navigate(['/workspace', 'dashboard']);
}
readonly scopedDownloads = computed(() => {
const playlistId = this.playlistId();
const downloads = this.downloads();
return playlistId
? downloads.filter((item) => item.playlistId === playlistId)
: downloads;
});
readonly categories = computed(() => {
const downloads = this.scopedDownloads();
const moviesCount = downloads.filter(
(item) => item.contentType === 'vod'
).length;
const seriesCount = downloads.filter(
(item) => item.contentType === 'episode'
).length;
return buildStandardCollectionCategories({
labels: DOWNLOAD_COLLECTION_LABELS,
counts: {
all: downloads.length,
movie: moviesCount,
series: seriesCount,
},
});
});
readonly collectionContext = createPortalCollectionContext({
ctx: this.collectionCtx,
categories: this.categories,
});
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
readonly failedPosterKeys = signal<Record<string, true>>({});
/** Filter downloads for current playlist and sort by newest first */
readonly filteredDownloads = computed(() => {
const term = this.searchTerm();
const downloads = this.scopedDownloads();
const filteredByTerm = filterCollectionBucket({
selectedCategoryId: this.selectedCategoryId(),
allItems: downloads,
buckets: {
movie: downloads.filter((item) => item.contentType === 'vod'),
series: downloads.filter(
(item) => item.contentType === 'episode'
),
},
searchTerm: term,
textOf: (item) => `${item.title ?? ''} ${item.errorMessage ?? ''}`,
});
// Sort by createdAt descending (newest first)
return [...filteredByTerm].sort((a, b) => {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA;
});
});
readonly hasClearableDownloads = computed(() =>
this.scopedDownloads().some(
(item) =>
item.status === 'completed' ||
item.status === 'failed' ||
item.status === 'canceled'
)
);
readonly availablePlaylistIds = computed(
() => new Set(this.playlists().map((playlist) => playlist._id))
);
constructor() {
effect(() => {
const playlistId = this.playlistId();
this.collectionContext.setCategoryId('all');
void this.downloadsService.loadDownloads(playlistId || undefined);
});
}
getProgress(item: DownloadItem): number {
return this.downloadsService.getProgressPercent(item);
}
formatBytes(bytes: number | undefined): string {
if (!bytes) return '0 B';
return this.downloadsService.formatBytes(bytes);
}
getStatusIcon(status: string): string {
switch (status) {
case 'queued':
return 'schedule';
case 'downloading':
return 'downloading';
case 'completed':
return 'check_circle';
case 'failed':
return 'error';
case 'canceled':
return 'cancel';
default:
return 'help';
}
}
getStatusColor(status: string): string {
switch (status) {
case 'queued':
return 'status-queued';
case 'downloading':
return 'status-downloading';
case 'completed':
return 'status-completed';
case 'failed':
case 'canceled':
return 'status-failed';
default:
return '';
}
}
async copyUrl(item: DownloadItem): Promise<void> {
try {
await navigator.clipboard.writeText(item.url);
this.snackBar.open(
this.translate.instant('DOWNLOADS.URL_COPIED'),
undefined,
{ duration: 2000, horizontalPosition: 'start' }
);
} catch {
this.snackBar.open(
this.translate.instant('DOWNLOADS.URL_COPY_FAILED'),
undefined,
{ duration: 3000, horizontalPosition: 'start' }
);
}
}
async cancel(item: DownloadItem) {
await this.downloadsService.cancelDownload(item.id);
}
async retry(item: DownloadItem) {
await this.downloadsService.retryDownload(item.id);
}
async remove(item: DownloadItem) {
await this.downloadsService.removeDownload(item.id);
}
async play(item: DownloadItem) {
if (item.filePath) {
const result = await this.downloadsService.playDownload(
item.filePath
);
if (!result.success) {
this.handleFileActionError(result.error);
}
}
}
async reveal(item: DownloadItem) {
if (item.filePath) {
const result = await this.downloadsService.revealFile(item.filePath);
if (!result.success) {
this.handleFileActionError(result.error);
}
}
}
async changeFolder() {
await this.downloadsService.selectFolder();
}
async clearCompleted() {
this.dialogService.openConfirmDialog({
title: this.translate.instant(
'DOWNLOADS.CLEAR_COMPLETED_DIALOG.TITLE'
),
message: this.translate.instant(
'DOWNLOADS.CLEAR_COMPLETED_DIALOG.MESSAGE'
),
confirmLabel: this.translate.instant('DOWNLOADS.CLEAR_COMPLETED'),
onConfirm: async (): Promise<void> => {
await this.downloadsService.clearCompleted(this.playlistId());
},
});
}
formatEpisodeLabel(item: DownloadItem): string {
if (item.contentType !== 'episode') return '';
const s = item.seasonNumber?.toString().padStart(2, '0') || '00';
const e = item.episodeNumber?.toString().padStart(2, '0') || '00';
return `S${s}E${e}`;
}
hasPoster(item: DownloadItem): boolean {
return !!item.posterUrl && !this.failedPosterKeys()[this.getPosterKey(item)];
}
markPosterFailed(item: DownloadItem): void {
const key = this.getPosterKey(item);
this.failedPosterKeys.update((state) => {
if (state[key]) {
return state;
}
return {
...state,
[key]: true,
};
});
}
getPosterPlaceholderIcon(item: DownloadItem): string {
return item.contentType === 'episode' ? 'video_library' : 'movie';
}
hasSourcePlaylist(item: DownloadItem): boolean {
return this.availablePlaylistIds().has(item.playlistId);
}
isItemNavigable(item: DownloadItem): boolean {
return (
!!item.playlistId &&
this.hasSourcePlaylist(item) &&
this.getTargetContentId(item) !== null
);
}
onItemCardKeydown(event: KeyboardEvent, item: DownloadItem): void {
if (event.target !== event.currentTarget) {
return;
}
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
void this.openInLibrary(item);
}
async openInLibrary(item: DownloadItem): Promise<void> {
if (!this.hasSourcePlaylist(item)) {
this.snackBar.open(
this.translate.instant('DOWNLOADS.SOURCE_PLAYLIST_MISSING'),
undefined,
{ duration: 3000, horizontalPosition: 'start' }
);
return;
}
if (!this.isItemNavigable(item)) {
return;
}
const source = await this.resolveSourceType(item.playlistId);
if (!source) {
return;
}
if (source === 'xtream') {
await this.openXtreamItem(item);
return;
}
await this.openStalkerItem(item);
}
private getTargetContentId(item: DownloadItem): number | null {
const id =
item.contentType === 'episode'
? (item.seriesXtreamId ?? item.xtreamId)
: item.xtreamId;
const numeric = Number(id);
return Number.isFinite(numeric) ? numeric : null;
}
private buildPlaylistRoute(
source: PortalSource,
playlistId: string,
segments: Array<string | number>
): Array<string | number> {
const sourceSegment = source === 'stalker' ? 'stalker' : 'xtreams';
return ['/workspace', sourceSegment, playlistId, ...segments];
}
private async resolveSourceType(
playlistId: string
): Promise<PortalSource | null> {
try {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
);
if (!playlist) {
return null;
}
if (playlist.portalUrl && playlist.macAddress) {
return 'stalker';
}
return 'xtream';
} catch {
return null;
}
}
private async openXtreamItem(item: DownloadItem): Promise<void> {
const targetId = this.getTargetContentId(item);
if (targetId === null) return;
const contentType = item.contentType === 'episode' ? 'series' : 'vod';
const content = await this.dbService.getContentByXtreamId(
targetId,
item.playlistId
);
const categoryId = content?.category_id;
if (categoryId === null || categoryId === undefined) {
await this.router.navigate(
this.buildPlaylistRoute('xtream', item.playlistId, [
contentType,
])
);
return;
}
await this.router.navigate(
this.buildPlaylistRoute('xtream', item.playlistId, [
contentType,
String(categoryId),
String(targetId),
])
);
}
private normalizePortalItemId(value: unknown): string {
const raw = String(value ?? '').trim();
if (!raw) return '';
return raw.includes(':') ? raw.split(':')[0] : raw;
}
private normalizeStalkerCategoryId(
value: unknown,
fallback: 'vod' | 'series'
): 'vod' | 'series' | 'itv' {
const normalized = String(value ?? '').toLowerCase();
if (normalized === 'movie') return 'vod';
if (
normalized === 'vod' ||
normalized === 'series' ||
normalized === 'itv'
) {
return normalized;
}
return fallback;
}
private findMatchingStalkerRecentItem(
items: Array<Record<string, unknown>>,
targetId: number
): Record<string, unknown> | undefined {
const expectedId = String(targetId);
return items.find((recentItem) => {
const candidates = [
recentItem['id'],
recentItem['movie_id'],
recentItem['series_id'],
recentItem['stream_id'],
];
return candidates.some(
(candidate) =>
this.normalizePortalItemId(candidate) === expectedId
);
});
}
private async buildStalkerOpenState(
item: DownloadItem,
targetId: number,
fallbackCategory: 'vod' | 'series'
): Promise<Record<string, unknown>> {
try {
const items = (await firstValueFrom(
this.playlistsService.getPortalRecentlyViewed(item.playlistId)
)) as Array<Record<string, unknown>>;
const matched = this.findMatchingStalkerRecentItem(items, targetId);
if (matched) {
return {
...matched,
id:
matched['id'] ??
matched['series_id'] ??
matched['movie_id'] ??
String(targetId),
category_id: this.normalizeStalkerCategoryId(
matched['category_id'],
fallbackCategory
),
title: matched['title'] ?? item.title,
name: matched['name'] ?? matched['o_name'] ?? item.title,
};
}
} catch {
// Ignore and use fallback state below.
}
return {
id: String(targetId),
category_id: fallbackCategory,
title: item.title,
name: item.title,
o_name: item.title,
cover: item.posterUrl,
logo: item.posterUrl,
};
}
private async openStalkerItem(item: DownloadItem): Promise<void> {
const targetId = this.getTargetContentId(item);
if (targetId === null) return;
const fallbackCategory =
item.contentType === 'episode' ? 'series' : 'vod';
const openRecentItem = await this.buildStalkerOpenState(
item,
targetId,
fallbackCategory
);
await this.router.navigate(
this.buildPlaylistRoute('stalker', item.playlistId, ['recent']),
{
state: { openRecentItem },
}
);
}
private getPosterKey(item: DownloadItem): string {
return `${item.id}:${item.posterUrl ?? ''}`;
}
private handleFileActionError(error?: string): void {
const message =
error === 'File not found'
? this.translate.instant('DOWNLOADS.FILE_NOT_FOUND')
: this.translate.instant('DOWNLOADS.FILE_ACTION_ERROR');
this.snackBar.open(message, undefined, {
duration: 3000,
horizontalPosition: 'start',
});
}
}
@@ -0,0 +1,28 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}