Merge remote-tracking branch 'origin/master' into agent/download-season-queue

# Conflicts:
#	apps/electron-backend/src/app/events/database/download-requests.ts
#	libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts
#	libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts
#	libs/ui/components/src/lib/season-container/episode-download.util.ts
#	libs/ui/components/src/lib/season-container/episode-utils.spec.ts
#	libs/ui/components/src/lib/season-container/season-container.component.spec.ts
#	libs/ui/components/src/lib/season-container/season-container.component.ts
This commit is contained in:
4gray committed 2026-08-02 18:06:37 +02:00
commit 31ac306bb0
243 files changed
+12935 -1106

No files matched your search

@@ -188,6 +188,55 @@ describe('PortalStatusService', () => {
);
});
it('returns expiry details from the same round-trip and serves repeats from the cache', async () => {
const expiresAtSeconds = Math.floor(Date.now() / 1000) + 3 * 86_400;
dataService.sendIpcEvent.mockResolvedValue({
payload: {
user_info: {
auth: 1,
status: 'Active',
exp_date: String(expiresAtSeconds),
},
},
});
const details = await service.checkPortalStatusDetails(
'http://example.com',
'user',
'pass'
);
expect(details).toEqual({ status: 'active', expiresAtSeconds });
const cached = await service.checkPortalStatusDetails(
'http://example.com',
'user',
'pass'
);
expect(cached).toEqual(details);
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(1);
});
it('reports a null expiry for unlimited accounts', async () => {
dataService.sendIpcEvent.mockResolvedValue({
payload: {
user_info: {
auth: 1,
status: 'Active',
exp_date: '0',
},
},
});
await expect(
service.checkPortalStatusDetails(
'http://example.com',
'user',
'pass'
)
).resolves.toEqual({ status: 'active', expiresAtSeconds: null });
});
it('treats lowercase active status and exp_date 0 as active', async () => {
dataService.sendIpcEvent.mockResolvedValue({
payload: {
+48 -11
View File
@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core';
import {
normalizeXtreamServerUrl,
resolveXtreamPortalExpiration,
resolveXtreamPortalStatus,
XtreamPortalStatusResponseLike,
} from '@iptvnator/shared/interfaces';
@@ -13,12 +14,24 @@ export type PortalStatus =
| 'unavailable'
| 'checking';
/**
* Status plus the parsed account expiration from the same round-trip the
* status check already makes — consumers that care about "expires soon"
* (dashboard source cards) share the cache instead of issuing their own
* `get_account_info` calls.
*/
export interface PortalStatusDetails {
status: PortalStatus;
/** Unix seconds; null when the portal doesn't report an expiration. */
expiresAtSeconds: number | null;
}
interface XtreamPortalStatusResponse {
payload?: XtreamPortalStatusResponseLike;
}
interface PortalStatusCacheEntry {
status: PortalStatus;
details: PortalStatusDetails;
timestamp: number;
}
@@ -57,7 +70,7 @@ export class PortalStatusService {
* playlist-item + switcher menu open) share a single network round-trip
* instead of racing each other.
*/
private readonly inFlight = new Map<string, Promise<PortalStatus>>();
private readonly inFlight = new Map<string, Promise<PortalStatusDetails>>();
/**
* Checks the status of an Xtream Code portal
@@ -75,13 +88,32 @@ export class PortalStatusService {
password: string,
options?: CheckPortalStatusOptions
): Promise<PortalStatus> {
const details = await this.checkPortalStatusDetails(
serverUrl,
username,
password,
options
);
return details.status;
}
/**
* Same check as {@link checkPortalStatus} (shared cache, shared in-flight
* dedup) but returns the account expiration alongside the status.
*/
async checkPortalStatusDetails(
serverUrl: string,
username: string,
password: string,
options?: CheckPortalStatusOptions
): Promise<PortalStatusDetails> {
const connection = this.normalizeConnection(
serverUrl,
username,
password
);
if (!connection) {
return 'unavailable';
return { status: 'unavailable', expiresAtSeconds: null };
}
const cacheKey = this.buildCacheKey(
@@ -96,7 +128,7 @@ export class PortalStatusService {
cached &&
Date.now() - cached.timestamp < PORTAL_STATUS_CACHE_TTL_MS
) {
return cached.status;
return cached.details;
}
const pending = this.inFlight.get(cacheKey);
@@ -110,12 +142,12 @@ export class PortalStatusService {
connection.username,
connection.password
)
.then((status) => {
.then((details) => {
this.cache.set(cacheKey, {
status,
details,
timestamp: Date.now(),
});
return status;
return details;
})
.finally(() => {
this.inFlight.delete(cacheKey);
@@ -159,7 +191,7 @@ export class PortalStatusService {
if (Date.now() - cached.timestamp >= PORTAL_STATUS_CACHE_TTL_MS) {
return null;
}
return cached.status;
return cached.details.status;
}
/** Clear the entire cache. Useful for log-out or debug flows. */
@@ -205,7 +237,7 @@ export class PortalStatusService {
serverUrl: string,
username: string,
password: string
): Promise<PortalStatus> {
): Promise<PortalStatusDetails> {
for (const action of XTREAM_STATUS_ACTIONS) {
try {
const response =
@@ -223,14 +255,19 @@ export class PortalStatusService {
);
const status = resolveXtreamPortalStatus(response?.payload);
if (status !== 'unavailable') {
return status;
return {
status,
expiresAtSeconds: resolveXtreamPortalExpiration(
response?.payload
),
};
}
} catch {
// Try the next Xtream account-info action variant.
}
}
return 'unavailable';
return { status: 'unavailable', expiresAtSeconds: null };
}
/**