Proyecto App Linux
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import baseConfig from '../../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
files: ['**/*.json'],
|
||||
rules: {
|
||||
'@nx/dependency-checks': [
|
||||
'error',
|
||||
{
|
||||
ignoredFiles: [
|
||||
'{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
languageOptions: {
|
||||
parser: await import('jsonc-eslint-parser'),
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
displayName: 'm3u-utils',
|
||||
preset: '../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': [
|
||||
'ts-jest',
|
||||
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
|
||||
],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../coverage/libs/shared/m3u-utils',
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "m3u-utils",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "./src/index.js",
|
||||
"types": "./src/index.d.ts",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "m3u-utils",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/shared/m3u-utils/src",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:playlist", "domain:m3u", "type:util"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/shared/m3u-utils",
|
||||
"main": "libs/shared/m3u-utils/src/index.ts",
|
||||
"tsConfig": "libs/shared/m3u-utils/tsconfig.lib.json",
|
||||
"assets": []
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/shared/m3u-utils/jest.config.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './lib/playlist.utils';
|
||||
export * from './lib/catchup.utils';
|
||||
@@ -0,0 +1,254 @@
|
||||
import { Channel, EpgProgram } from 'shared-interfaces';
|
||||
import {
|
||||
getM3uArchiveDays,
|
||||
isM3uCatchupPlaybackSupported,
|
||||
resolveM3uCatchupUrl,
|
||||
} from './catchup.utils';
|
||||
import { createPlaylistObject } from './playlist.utils';
|
||||
|
||||
describe('catchup.utils', () => {
|
||||
const baseChannel: Channel = {
|
||||
id: 'channel-1',
|
||||
name: 'News One',
|
||||
url: 'https://streams.example.com/live/channel-1.m3u8',
|
||||
group: { title: 'News' },
|
||||
tvg: {
|
||||
id: 'news-1',
|
||||
name: 'News One',
|
||||
url: '',
|
||||
logo: '',
|
||||
rec: '3',
|
||||
},
|
||||
catchup: {
|
||||
type: 'shift',
|
||||
days: '3',
|
||||
},
|
||||
timeshift: '3',
|
||||
http: {
|
||||
referrer: '',
|
||||
'user-agent': '',
|
||||
origin: '',
|
||||
},
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
};
|
||||
|
||||
const archivedProgram: EpgProgram = {
|
||||
start: '2026-04-10T08:00:00.000Z',
|
||||
stop: '2026-04-10T09:00:00.000Z',
|
||||
channel: 'news-1',
|
||||
title: 'Morning News',
|
||||
desc: null,
|
||||
category: null,
|
||||
startTimestamp: 1_775_808_800,
|
||||
stopTimestamp: 1_775_812_400,
|
||||
};
|
||||
|
||||
it('reports archive days from catchup metadata before legacy fields', () => {
|
||||
expect(
|
||||
getM3uArchiveDays({
|
||||
...baseChannel,
|
||||
catchup: { type: 'shift', days: '5' },
|
||||
timeshift: '3',
|
||||
tvg: { ...baseChannel.tvg, rec: '2' },
|
||||
})
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it('falls back to tvg-rec when catchup-days and timeshift are blank strings', () => {
|
||||
expect(
|
||||
getM3uArchiveDays({
|
||||
...baseChannel,
|
||||
catchup: { type: '', days: '' },
|
||||
timeshift: '',
|
||||
tvg: { ...baseChannel.tvg, rec: '7' },
|
||||
})
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it('supports legacy same-stream shift playback when catchup type is shift', () => {
|
||||
expect(isM3uCatchupPlaybackSupported(baseChannel)).toBe(true);
|
||||
expect(resolveM3uCatchupUrl(baseChannel, archivedProgram, 1_775_820_000))
|
||||
.toBe(
|
||||
'https://streams.example.com/live/channel-1.m3u8?utc=1775808800&lutc=1775820000'
|
||||
);
|
||||
});
|
||||
|
||||
it('supports tvg-rec-only legacy same-stream playback when the channel url is reusable', () => {
|
||||
const channel: Channel = {
|
||||
...baseChannel,
|
||||
tvg: {
|
||||
...baseChannel.tvg,
|
||||
rec: '7',
|
||||
},
|
||||
catchup: undefined,
|
||||
timeshift: undefined,
|
||||
};
|
||||
|
||||
expect(isM3uCatchupPlaybackSupported(channel)).toBe(true);
|
||||
expect(resolveM3uCatchupUrl(channel, archivedProgram, 1_775_820_000))
|
||||
.toBe(
|
||||
'https://streams.example.com/live/channel-1.m3u8?utc=1775808800&lutc=1775820000'
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites utc and lutc query params on catchup-source urls', () => {
|
||||
const playbackUrl = resolveM3uCatchupUrl(
|
||||
{
|
||||
...baseChannel,
|
||||
catchup: {
|
||||
type: 'append',
|
||||
days: '3',
|
||||
source:
|
||||
'https://archive.example.com/catchup.m3u8?utc=1&lutc=2&token=abc',
|
||||
},
|
||||
},
|
||||
archivedProgram,
|
||||
1_775_820_000
|
||||
);
|
||||
|
||||
expect(playbackUrl).toBe(
|
||||
'https://archive.example.com/catchup.m3u8?utc=1775808800&lutc=1775820000&token=abc'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when the channel metadata declares an unsupported replay scheme', () => {
|
||||
expect(
|
||||
isM3uCatchupPlaybackSupported({
|
||||
...baseChannel,
|
||||
catchup: {
|
||||
type: 'append',
|
||||
days: '3',
|
||||
source: '',
|
||||
},
|
||||
timeshift: '3',
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveM3uCatchupUrl(
|
||||
{
|
||||
...baseChannel,
|
||||
catchup: {
|
||||
type: 'append',
|
||||
days: '3',
|
||||
source: '',
|
||||
},
|
||||
timeshift: '3',
|
||||
},
|
||||
archivedProgram
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for tvg-rec-only channels when the stream url is not http', () => {
|
||||
expect(
|
||||
isM3uCatchupPlaybackSupported({
|
||||
...baseChannel,
|
||||
url: 'udp://239.0.0.1:1234',
|
||||
tvg: {
|
||||
...baseChannel.tvg,
|
||||
rec: '7',
|
||||
},
|
||||
catchup: undefined,
|
||||
timeshift: undefined,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveM3uCatchupUrl(
|
||||
{
|
||||
...baseChannel,
|
||||
url: 'udp://239.0.0.1:1234',
|
||||
tvg: {
|
||||
...baseChannel.tvg,
|
||||
rec: '7',
|
||||
},
|
||||
catchup: undefined,
|
||||
timeshift: undefined,
|
||||
},
|
||||
archivedProgram
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when archive days are missing', () => {
|
||||
expect(
|
||||
resolveM3uCatchupUrl(
|
||||
{
|
||||
...baseChannel,
|
||||
tvg: { ...baseChannel.tvg, rec: '' },
|
||||
timeshift: '',
|
||||
catchup: {
|
||||
type: 'shift',
|
||||
days: '',
|
||||
},
|
||||
},
|
||||
archivedProgram
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to parsing the program start string when the unix timestamp is unavailable', () => {
|
||||
expect(
|
||||
resolveM3uCatchupUrl(
|
||||
baseChannel,
|
||||
{
|
||||
...archivedProgram,
|
||||
start: '202604100800 +0000',
|
||||
startTimestamp: null,
|
||||
},
|
||||
1_775_820_000
|
||||
)
|
||||
).toBe(
|
||||
'https://streams.example.com/live/channel-1.m3u8?utc=1775808000&lutc=1775820000'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves catchup metadata when storing parsed playlist items', () => {
|
||||
const playlist = createPlaylistObject('Catchup playlist', {
|
||||
header: {
|
||||
attrs: {
|
||||
'x-tvg-url': 'https://example.com/guide.xml',
|
||||
},
|
||||
raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml"',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
name: 'News One',
|
||||
tvg: {
|
||||
id: 'news-1',
|
||||
name: 'News One',
|
||||
url: '',
|
||||
logo: '',
|
||||
rec: '3',
|
||||
},
|
||||
group: { title: 'News' },
|
||||
http: {
|
||||
referrer: '',
|
||||
'user-agent': '',
|
||||
},
|
||||
url: 'https://streams.example.com/live/channel-1.m3u8',
|
||||
raw: '#EXTINF:-1,News One',
|
||||
catchup: {
|
||||
type: 'shift',
|
||||
source: 'https://archive.example.com/catchup.m3u8',
|
||||
days: '3',
|
||||
},
|
||||
timeshift: '3',
|
||||
radio: 'false',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(playlist.playlist?.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
catchup: {
|
||||
type: 'shift',
|
||||
source: 'https://archive.example.com/catchup.m3u8',
|
||||
days: '3',
|
||||
},
|
||||
timeshift: '3',
|
||||
radio: 'false',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Channel, EpgProgram } from 'shared-interfaces';
|
||||
|
||||
const XMLTV_TIMESTAMP_PATTERN =
|
||||
/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{2})(\d{2})$/;
|
||||
|
||||
type CatchupSupportMode = 'none' | 'source' | 'shift';
|
||||
|
||||
export function getM3uArchiveDays(
|
||||
channel: Pick<Channel, 'tvg' | 'timeshift' | 'catchup'> | null | undefined
|
||||
): number {
|
||||
const value = getFirstNonBlankValue(
|
||||
channel?.catchup?.days,
|
||||
channel?.timeshift,
|
||||
channel?.tvg?.rec
|
||||
);
|
||||
return Math.max(0, Number(value ?? 0) || 0);
|
||||
}
|
||||
|
||||
export function isM3uCatchupPlaybackSupported(
|
||||
channel: Channel | null | undefined
|
||||
): boolean {
|
||||
return getM3uCatchupSupportMode(channel) !== 'none';
|
||||
}
|
||||
|
||||
export function resolveM3uCatchupUrl(
|
||||
channel: Channel | null | undefined,
|
||||
program: Pick<EpgProgram, 'start' | 'startTimestamp'>,
|
||||
nowTimestampSeconds = Math.floor(Date.now() / 1000)
|
||||
): string | null {
|
||||
const supportMode = getM3uCatchupSupportMode(channel);
|
||||
if (supportMode === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startTimestamp = getEpgProgramTimestampSeconds(
|
||||
program.start,
|
||||
program.startTimestamp
|
||||
);
|
||||
if (startTimestamp === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const playbackBaseUrl =
|
||||
supportMode === 'source'
|
||||
? channel?.catchup?.source
|
||||
: channel?.url;
|
||||
if (!playbackBaseUrl?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedNow =
|
||||
Number.isFinite(nowTimestampSeconds) && nowTimestampSeconds > 0
|
||||
? Math.floor(nowTimestampSeconds)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
return setCatchupQueryParams(playbackBaseUrl, {
|
||||
utc: startTimestamp,
|
||||
lutc: normalizedNow,
|
||||
});
|
||||
}
|
||||
|
||||
function getM3uCatchupSupportMode(
|
||||
channel: Channel | null | undefined
|
||||
): CatchupSupportMode {
|
||||
if (getM3uArchiveDays(channel) <= 0) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
const catchupSource = channel?.catchup?.source?.trim() ?? '';
|
||||
if (isHttpUrl(catchupSource)) {
|
||||
return 'source';
|
||||
}
|
||||
|
||||
const streamUrl = channel?.url?.trim() ?? '';
|
||||
const catchupType = channel?.catchup?.type?.trim().toLowerCase() ?? '';
|
||||
if (catchupType === 'shift' && isHttpUrl(streamUrl)) {
|
||||
return 'shift';
|
||||
}
|
||||
|
||||
if (!catchupType && isHttpUrl(streamUrl)) {
|
||||
return 'shift';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function getEpgProgramTimestampSeconds(
|
||||
dateValue: string,
|
||||
unixTimestampValue?: number | string | null
|
||||
): number | null {
|
||||
const unixTimestamp = Number.parseInt(String(unixTimestampValue ?? ''), 10);
|
||||
if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) {
|
||||
return unixTimestamp;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(dateValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.floor(parsed / 1000);
|
||||
}
|
||||
|
||||
const match = dateValue.match(XMLTV_TIMESTAMP_PATTERN);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, year, month, day, hour, minute, offsetHours, offsetMinutes] =
|
||||
match;
|
||||
const utcMillis = Date.UTC(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute)
|
||||
);
|
||||
const offsetTotalMinutes =
|
||||
Number(offsetHours) * 60 +
|
||||
Math.sign(Number(offsetHours)) * Number(offsetMinutes);
|
||||
|
||||
return Math.floor((utcMillis - offsetTotalMinutes * 60_000) / 1000);
|
||||
}
|
||||
|
||||
function setCatchupQueryParams(
|
||||
rawUrl: string,
|
||||
params: Record<'utc' | 'lutc', number>
|
||||
): string | null {
|
||||
try {
|
||||
const url = new URL(rawUrl.trim());
|
||||
url.searchParams.set('utc', String(params.utc));
|
||||
url.searchParams.set('lutc', String(params.lutc));
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getFirstNonBlankValue(
|
||||
...values: Array<string | null | undefined>
|
||||
): string | undefined {
|
||||
return values.find(
|
||||
(value): value is string => value != null && value.trim() !== ''
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
Channel,
|
||||
ParsedPlaylist,
|
||||
ParsedPlaylistItem,
|
||||
Playlist,
|
||||
} from 'shared-interfaces';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Aggregates favorite channels as objects from all available playlists
|
||||
* @param playlists all available playlists
|
||||
* @returns an array with favorite channels from all playlists
|
||||
*/
|
||||
export function aggregateFavoriteChannels(playlists: Playlist[]): Channel[] {
|
||||
const favorites: Channel[] = [];
|
||||
playlists.forEach((playlist) => {
|
||||
if (playlist.favorites?.length && playlist.favorites.length > 0) {
|
||||
playlist?.playlist?.items.forEach((channel: Channel) => {
|
||||
if (
|
||||
playlist.favorites &&
|
||||
(playlist.favorites.includes(channel.id) ||
|
||||
playlist.favorites.includes(channel.url))
|
||||
) {
|
||||
favorites.push(channel);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return favorites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a simplified playlist object which is used for global favorites
|
||||
* @param channels channels list
|
||||
* @returns simplified playlist object
|
||||
*/
|
||||
export function createFavoritesPlaylist(
|
||||
channels: Channel[]
|
||||
): Partial<Playlist> {
|
||||
return {
|
||||
_id: 'global-favorites',
|
||||
count: channels.length,
|
||||
playlist: {
|
||||
items: channels,
|
||||
},
|
||||
favorites: channels.map((channel) => channel.url),
|
||||
filename: 'Global favorites',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns last segment (part after last slash "/") of the given URL
|
||||
* @param value URL as string
|
||||
*/
|
||||
export const getFilenameFromUrl = (value: string): string => {
|
||||
if (value && value.length > 1) {
|
||||
return value.substring(value.lastIndexOf('/') + 1);
|
||||
}
|
||||
return 'Untitled playlist';
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a playlist object
|
||||
* @param name name of the playlist
|
||||
* @param playlist playlist to save
|
||||
* @param urlOrPath absolute fs path or url of the playlist
|
||||
* @param uploadType upload type - by file or via an url
|
||||
*/
|
||||
export const createPlaylistObject = (
|
||||
name: string,
|
||||
playlist: ParsedPlaylist,
|
||||
urlOrPath?: string,
|
||||
uploadType?: 'URL' | 'FILE' | 'TEXT'
|
||||
): Playlist => {
|
||||
return {
|
||||
_id: uuidv4(),
|
||||
filename: name,
|
||||
title: name,
|
||||
count: playlist.items.length,
|
||||
playlist: {
|
||||
...playlist,
|
||||
items: playlist.items.map((item: ParsedPlaylistItem) => ({
|
||||
...item,
|
||||
id: uuidv4(),
|
||||
})),
|
||||
},
|
||||
importDate: new Date().toISOString(),
|
||||
lastUsage: new Date().toISOString(),
|
||||
favorites: [],
|
||||
autoRefresh: false,
|
||||
...(uploadType === 'URL' ? { url: urlOrPath } : {}),
|
||||
...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const getExtensionFromUrl = (url: string) => {
|
||||
return url.split(/[#?]/)[0].split('.').pop()?.trim();
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user