diff --git a/package-lock.json b/package-lock.json index 2384d4b7..d80c4063 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,7 @@ "tsx": "^4.20.3", "typescript": "^5.8.3", "unzipit": "^1.4.3", + "uuid": "^11.1.0", "wildcard-match": "^5.1.4" } }, @@ -11418,6 +11419,19 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/uzip-module": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz", @@ -19318,6 +19332,11 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==" + }, "uzip-module": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz", diff --git a/package.json b/package.json index 4315f72c..adfadf14 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "tsx": "^4.20.3", "typescript": "^5.8.3", "unzipit": "^1.4.3", + "uuid": "^11.1.0", "wildcard-match": "^5.1.4" }, "packageManager": "yarn@4.9.2" diff --git a/scripts/core/configLoader.ts b/scripts/core/configLoader.ts index ae9971bf..c5f05ad2 100644 --- a/scripts/core/configLoader.ts +++ b/scripts/core/configLoader.ts @@ -1,5 +1,4 @@ import { SiteConfig } from 'epg-grabber' -import { deepMerge } from '../functions' import { pathToFileURL } from 'url' export class ConfigLoader { @@ -28,6 +27,6 @@ export class ConfigLoader { channels: undefined } - return deepMerge(defaultConfig, config) as SiteConfig + return { ...defaultConfig, ...config } as SiteConfig } } diff --git a/scripts/functions/functions.ts b/scripts/functions/functions.ts index fcf498a3..346c5c94 100644 --- a/scripts/functions/functions.ts +++ b/scripts/functions/functions.ts @@ -1,42 +1,65 @@ -// Made to replace lodash functions with their native alternatives. Typed for better TypeScript support. - /** - * Creates a new array of unique items based on an specific identifier. - * This function uses a Map to ensure that each item is unique based on the result of the provided function. - * @param {Array} arr - The array to filter for unique items - * @param {Function} fn - A function that takes an item and returns a unique identifier - * @returns {Array} A new array containing only unique items based on the identifier - * @example - * const items = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }, { id: 1, name: 'C' }]; - * const uniqueItems = uniqBy(items, item => item.id); - * // uniqueItems will be [{ id: 1, name: 'A' }, { id: 2, name: 'B' }] - */ -export const uniqBy = (arr: T[], fn: (item: T) => K): T[] => [...new Map(arr.map(x => [fn(x), x])).values()] - -/** - * Recursively merges multiple objects into a single object. - * If the same key exists in multiple objects and the values are both objects, - * they will be deep merged. Otherwise, the latter value will override the former. + * Sorts an array by the result of running each element through an iteratee function. + * Creates a shallow copy of the array before sorting to avoid mutating the original. * - * @param {...object[]} a - An array of objects to be merged - * @returns {Record} A new object containing all merged properties + * @param {Array} arr - The array to sort + * @param {Function} fn - The iteratee function to compute sort values + * @returns {Array} A new sorted array * * @example - * const obj1 = { a: { b: 2 }, c: 3 }; - * const obj2 = { a: { d: 4 }, e: 5 }; - * deepMerge(obj1, obj2); // { a: { b: 2, d: 4 }, c: 3, e: 5 } + * const users = [{name: 'john', age: 30}, {name: 'jane', age: 25}]; + * sortBy(users, x => x.age); // [{name: 'jane', age: 25}, {name: 'john', age: 30}] */ -export const deepMerge = (...a: (object)[]): Record => - a.reduce((r: { [key: string]: unknown }, o) => - (Object.entries(o).forEach(([k, v]) => { r[k] = r[k] && typeof r[k] === 'object' && typeof v === 'object' ? - deepMerge(r[k], v) : v }), r), {} as Record) +export const sortBy = (arr: T[], fn: (item: T) => number | string): T[] => [...arr].sort((a, b) => fn(a) > fn(b) ? 1 : -1) /** - * Sort an array of objects by a specific key. + * Sorts an array by multiple criteria with customizable sort orders. + * Supports ascending (default) and descending order for each criterion. * - * @param {string} key - The key to sort by - * @returns {function} A comparison function for sorting + * @param {Array} arr - The array to sort + * @param {Array} fns - Array of iteratee functions to compute sort values + * @param {Array} orders - Array of sort orders ('asc' or 'desc'), defaults to all 'asc' + * @returns {Array} A new sorted array + * + * @example + * const users = [{name: 'john', age: 30}, {name: 'jane', age: 25}, {name: 'bob', age: 30}]; + * orderBy(users, [x => x.age, x => x.name], ['desc', 'asc']); + * // [{name: 'bob', age: 30}, {name: 'john', age: 30}, {name: 'jane', age: 25}] */ -export const sortBy = (key: keyof T): ((a: T, b: T) => number) => { - return (a: T, b: T) => (a[key] > b[key]) ? 1 : ((b[key] > a[key]) ? -1 : 0) -} \ No newline at end of file +export const orderBy = (arr: Array, fns: Array<(item: unknown) => string | number>, orders: Array = []): Array => [...arr].sort((a, b) => + fns.reduce((acc, fn, i) => + acc || ((orders[i] === 'desc' ? fn(b) > fn(a) : fn(a) > fn(b)) ? 1 : fn(a) === fn(b) ? 0 : -1), 0) +) + +/** + * Creates a duplicate-free version of an array using an iteratee function to generate + * the criterion by which uniqueness is computed. Only the first occurrence of each + * element is kept. + * + * @param {Array} arr - The array to inspect + * @param {Function} fn - The iteratee function to compute uniqueness criterion + * @returns {Array} A new duplicate-free array + * + * @example + * const users = [{id: 1, name: 'john'}, {id: 2, name: 'jane'}, {id: 1, name: 'john'}]; + * uniqBy(users, x => x.id); // [{id: 1, name: 'john'}, {id: 2, name: 'jane'}] + */ +export const uniqBy = (arr: T[], fn: (item: T) => unknown): T[] => arr.filter((item, index) => arr.findIndex(x => fn(x) === fn(item)) === index) + +/** + * Converts a string to start case (capitalizes the first letter of each word). + * Handles camelCase, snake_case, kebab-case, and regular spaces. + * + * @param {string} str - The string to convert + * @returns {string} The start case string + * + * @example + * startCase('hello_world'); // "Hello World" + * startCase('helloWorld'); // "Hello World" + * startCase('hello-world'); // "Hello World" + * startCase('hello world'); // "Hello World" + */ +export const startCase = (str: string): string => str + .replace(/([a-z])([A-Z])/g, '$1 $2') // Split camelCase + .replace(/[_-]/g, ' ') // Replace underscores and hyphens with spaces + .replace(/\b\w/g, c => c.toUpperCase()) // Capitalize first letter of each word \ No newline at end of file diff --git a/scripts/models/guide.ts b/scripts/models/guide.ts index 349d6d5c..a7a4359f 100644 --- a/scripts/models/guide.ts +++ b/scripts/models/guide.ts @@ -1,5 +1,5 @@ import type { GuideData } from '../types/guide' -import { uniqueId } from 'lodash' +import { v4 as uuidv4 } from 'uuid' export class Guide { channelId?: string @@ -21,7 +21,7 @@ export class Guide { } getUUID(): string { - if (!this.getStreamId() || !this.siteId) return uniqueId() + if (!this.getStreamId() || !this.siteId) return uuidv4() return this.getStreamId() + this.siteId } diff --git a/sites/derana.lk/derana.lk.config.js b/sites/derana.lk/derana.lk.config.js index e8adcea2..24ec4ae0 100644 --- a/sites/derana.lk/derana.lk.config.js +++ b/sites/derana.lk/derana.lk.config.js @@ -28,7 +28,7 @@ module.exports = { } }) - return programs.concat().sort(sortBy('start')) + return sortBy(programs, p => p.start.valueOf()) } } diff --git a/sites/mojmaxtv.hrvatskitelekom.hr/mojmaxtv.hrvatskitelekom.hr.config.js b/sites/mojmaxtv.hrvatskitelekom.hr/mojmaxtv.hrvatskitelekom.hr.config.js index db783843..8aff9e1f 100644 --- a/sites/mojmaxtv.hrvatskitelekom.hr/mojmaxtv.hrvatskitelekom.hr.config.js +++ b/sites/mojmaxtv.hrvatskitelekom.hr/mojmaxtv.hrvatskitelekom.hr.config.js @@ -1,8 +1,8 @@ const doFetch = require('@ntlab/sfetch') const axios = require('axios') const dayjs = require('dayjs') -const _ = require('lodash') const crypto = require('crypto') +const { sortBy } = require('../../scripts/functions') // API Configuration Constants const NATCO_CODE = 'hr' @@ -86,7 +86,7 @@ module.exports = { } }) - items = _.sortBy(items, i => dayjs(i.start_time).valueOf()) + items = sortBy(items, i => dayjs(i.start_time).valueOf()) // Fetch program details for each item const programs = [] diff --git a/sites/mtel.ba/mtel.ba.config.js b/sites/mtel.ba/mtel.ba.config.js index d5f6f225..6f84f5cf 100644 --- a/sites/mtel.ba/mtel.ba.config.js +++ b/sites/mtel.ba/mtel.ba.config.js @@ -1,9 +1,9 @@ -const _ = require('lodash') const doFetch = require('@ntlab/sfetch') const dayjs = require('dayjs') const utc = require('dayjs/plugin/utc') const timezone = require('dayjs/plugin/timezone') const customParseFormat = require('dayjs/plugin/customParseFormat') +const { sortBy } = require('../../scripts/functions') dayjs.extend(utc) dayjs.extend(timezone) @@ -15,7 +15,7 @@ module.exports = { url({ channel, date }) { const [platform] = channel.site_id.split('#') - return `https://mtel.ba/hybris/ecommerce/b2c/v1/products/channels/epg?platform=tv-${platform}¤tPage=0&pageSize=1000&date=${date.format( + return `https://mtel.ba/hybris/ecommerce/b2c/v1/products/channels/epg?platform=tv-${platform}&pageSize=999&date=${date.format( 'YYYY-MM-DD' )}` }, @@ -31,7 +31,6 @@ module.exports = { let programs = [] const items = parseItems(content, channel) items.forEach(item => { - if (item.title === 'Nema informacija o programu') return programs.push({ title: item.title, description: item.description, @@ -46,14 +45,14 @@ module.exports = { }, async channels({ platform = 'msat' }) { const platforms = { - msat: 'https://mtel.ba/hybris/ecommerce/b2c/v1/products/channels/search?pageSize=100¤tPage=&query=:relevantno:tv-kategorija:tv-msat', - iptv: 'https://mtel.ba/hybris/ecommerce/b2c/v1/products/channels/search?pageSize=100¤tPage=&query=:relevantno:tv-kategorija:tv-iptv:tv-iptv-paket:Svi+kanali' + msat: 'https://mtel.ba/hybris/ecommerce/b2c/v1/products/channels/search?pageSize=999&query=:relevantno:tv-kategorija:tv-msat', + iptv: 'https://mtel.ba/hybris/ecommerce/b2c/v1/products/channels/search?pageSize=999&query=:relevantno:tv-kategorija:tv-iptv' } const queue = [ { platform, - url: platforms[platform].replace('', 0) + url: platforms[platform] } ] @@ -62,7 +61,7 @@ module.exports = { if (data && data.pagination.currentPage < data.pagination.totalPages) { queue.push({ platform: req.platform, - url: platforms[req.platform].replace('', ++data.pagination.currentPage) + url: platforms[req.platform] }) } @@ -102,8 +101,9 @@ function parseItems(content, channel) { const [, channelId] = channel.site_id.split('#') const channelData = data.products.find(channel => channel.code === channelId) if (!channelData || !Array.isArray(channelData.programs)) return [] - - return _.sortBy(channelData.programs, p => parseStart(p).valueOf()) + // filter out programs that have the sentence "no program information available" + channelData.programs = channelData.programs.filter(p => !p.title.includes('Nema informacija o programu')) + return sortBy(channelData.programs, p => parseStart(p).valueOf()) } catch { return [] } diff --git a/sites/mtel.ba/mtel.ba.test.js b/sites/mtel.ba/mtel.ba.test.js index 2664d66b..393da507 100644 --- a/sites/mtel.ba/mtel.ba.test.js +++ b/sites/mtel.ba/mtel.ba.test.js @@ -12,7 +12,7 @@ const channel = { site_id: 'msat#ch-11-rtrs' } it('can generate valid url', () => { expect(url({ date, channel })).toBe( - 'https://mtel.ba/hybris/ecommerce/b2c/v1/products/channels/epg?platform=tv-msat¤tPage=0&pageSize=1000&date=2025-02-04' + 'https://mtel.ba/hybris/ecommerce/b2c/v1/products/channels/epg?platform=tv-msat&pageSize=999&date=2025-02-04' ) }) diff --git a/sites/reportv.com.ar/reportv.com.ar.config.js b/sites/reportv.com.ar/reportv.com.ar.config.js index 51c89d23..29fc8a7b 100644 --- a/sites/reportv.com.ar/reportv.com.ar.config.js +++ b/sites/reportv.com.ar/reportv.com.ar.config.js @@ -5,7 +5,7 @@ const cheerio = require('cheerio') const utc = require('dayjs/plugin/utc') const timezone = require('dayjs/plugin/timezone') const customParseFormat = require('dayjs/plugin/customParseFormat') -const _ = require('lodash') +const { startCase } = require('../../scripts/functions') dayjs.extend(utc) dayjs.extend(timezone) @@ -164,7 +164,7 @@ function parseDuration($item) { function parseItems(content, date) { if (!content) return [] const $ = cheerio.load(content) - const d = _.startCase(date.locale('es').format('DD MMMM YYYY')) + const d = startCase(date.locale('es').format('DD MMMM YYYY')) return $(`.trProg[title*="${d}"]`).toArray() } diff --git a/sites/streamingtvguides.com/streamingtvguides.com.config.js b/sites/streamingtvguides.com/streamingtvguides.com.config.js index 811a8b40..e0b1d5a6 100644 --- a/sites/streamingtvguides.com/streamingtvguides.com.config.js +++ b/sites/streamingtvguides.com/streamingtvguides.com.config.js @@ -29,7 +29,7 @@ module.exports = { }) }) - programs = sortBy(uniqBy(programs, p => p.start), 'start') + programs = sortBy(uniqBy(programs, p => p.start), p => p.start.valueOf()) return programs },