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
+21
View File
@@ -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'),
},
},
];
+13
View File
@@ -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',
};
+11
View File
@@ -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"
}
}
+26
View File
@@ -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"
}
}
}
}
+2
View File
@@ -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();
};
+23
View File
@@ -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"
}
]
}
+10
View File
@@ -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"]
}
+15
View File
@@ -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"
]
}