Update scripts

This commit is contained in:
freearhey
2025-07-10 21:13:43 +03:00
parent 9de968a18d
commit acb19e72ee
36 changed files with 342 additions and 85 deletions

View File

@@ -13,13 +13,15 @@ async function main() {
const dataStorage = new Storage(DATA_DIR) const dataStorage = new Storage(DATA_DIR)
const dataLoader = new DataLoader({ storage: dataStorage }) const dataLoader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await dataLoader.load() const data: DataLoaderData = await dataLoader.load()
const { channelsKeyById, feedsGroupedByChannelId }: DataProcessorData = processor.process(data) const { channelsKeyById, feedsGroupedByChannelId, logosGroupedByStreamId }: DataProcessorData =
processor.process(data)
logger.info('loading streams...') logger.info('loading streams...')
const streamsStorage = new Storage(STREAMS_DIR) const streamsStorage = new Storage(STREAMS_DIR)
const parser = new PlaylistParser({ const parser = new PlaylistParser({
storage: streamsStorage, storage: streamsStorage,
channelsKeyById, channelsKeyById,
logosGroupedByStreamId,
feedsGroupedByChannelId feedsGroupedByChannelId
}) })
const files = await streamsStorage.list('**/*.m3u') const files = await streamsStorage.list('**/*.m3u')

View File

@@ -15,6 +15,7 @@ async function main() {
loader.download('regions.json'), loader.download('regions.json'),
loader.download('subdivisions.json'), loader.download('subdivisions.json'),
loader.download('feeds.json'), loader.download('feeds.json'),
loader.download('logos.json'),
loader.download('timezones.json'), loader.download('timezones.json'),
loader.download('guides.json'), loader.download('guides.json'),
loader.download('streams.json') loader.download('streams.json')

View File

@@ -49,11 +49,20 @@ export default async function main(filepath: string) {
const dataStorage = new Storage(DATA_DIR) const dataStorage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage: dataStorage }) const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load() const data: DataLoaderData = await loader.load()
const { channels, channelsKeyById, feedsGroupedByChannelId }: DataProcessorData = const {
processor.process(data) channels,
channelsKeyById,
feedsGroupedByChannelId,
logosGroupedByStreamId
}: DataProcessorData = processor.process(data)
logger.info('loading streams...') logger.info('loading streams...')
const parser = new PlaylistParser({ storage, feedsGroupedByChannelId, channelsKeyById }) const parser = new PlaylistParser({
storage,
feedsGroupedByChannelId,
logosGroupedByStreamId,
channelsKeyById
})
parsedStreams = await parser.parseFile(filepath) parsedStreams = await parser.parseFile(filepath)
const streamsWithoutId = parsedStreams.filter((stream: Stream) => !stream.id) const streamsWithoutId = parsedStreams.filter((stream: Stream) => !stream.id)

View File

@@ -16,14 +16,16 @@ async function main() {
const dataStorage = new Storage(DATA_DIR) const dataStorage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage: dataStorage }) const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load() const data: DataLoaderData = await loader.load()
const { channelsKeyById, feedsGroupedByChannelId }: DataProcessorData = processor.process(data) const { channelsKeyById, feedsGroupedByChannelId, logosGroupedByStreamId }: DataProcessorData =
processor.process(data)
logger.info('loading streams...') logger.info('loading streams...')
const streamsStorage = new Storage(STREAMS_DIR) const streamsStorage = new Storage(STREAMS_DIR)
const parser = new PlaylistParser({ const parser = new PlaylistParser({
storage: streamsStorage, storage: streamsStorage,
channelsKeyById, channelsKeyById,
feedsGroupedByChannelId feedsGroupedByChannelId,
logosGroupedByStreamId
}) })
const files = program.args.length ? program.args : await streamsStorage.list('**/*.m3u') const files = program.args.length ? program.args : await streamsStorage.list('**/*.m3u')
let streams = await parser.parse(files) let streams = await parser.parse(files)

View File

@@ -14,7 +14,8 @@ import {
CountriesGenerator, CountriesGenerator,
LanguagesGenerator, LanguagesGenerator,
RegionsGenerator, RegionsGenerator,
IndexGenerator IndexGenerator,
SourcesGenerator
} from '../../generators' } from '../../generators'
async function main() { async function main() {
@@ -28,6 +29,7 @@ async function main() {
const data: DataLoaderData = await loader.load() const data: DataLoaderData = await loader.load()
const { const {
feedsGroupedByChannelId, feedsGroupedByChannelId,
logosGroupedByStreamId,
channelsKeyById, channelsKeyById,
categories, categories,
countries, countries,
@@ -39,15 +41,18 @@ async function main() {
const parser = new PlaylistParser({ const parser = new PlaylistParser({
storage: streamsStorage, storage: streamsStorage,
feedsGroupedByChannelId, feedsGroupedByChannelId,
logosGroupedByStreamId,
channelsKeyById channelsKeyById
}) })
const files = await streamsStorage.list('**/*.m3u') const files = await streamsStorage.list('**/*.m3u')
let streams = await parser.parse(files) let streams = await parser.parse(files)
const totalStreams = streams.count() const totalStreams = streams.count()
logger.info(`found ${totalStreams} streams`)
logger.info('filtering streams...')
streams = streams.uniqBy((stream: Stream) => streams = streams.uniqBy((stream: Stream) =>
stream.hasId() ? stream.getChannelId() + stream.getFeedId() : uniqueId() stream.hasId() ? stream.getChannelId() + stream.getFeedId() : uniqueId()
) )
logger.info(`found ${totalStreams} streams (including ${streams.count()} unique)`)
logger.info('sorting streams...') logger.info('sorting streams...')
streams = streams.orderBy( streams = streams.orderBy(
@@ -79,6 +84,9 @@ async function main() {
logFile logFile
}).generate() }).generate()
logger.info('generating sources/...')
await new SourcesGenerator({ streams, logFile }).generate()
logger.info('generating index.m3u...') logger.info('generating index.m3u...')
await new IndexGenerator({ streams, logFile }).generate() await new IndexGenerator({ streams, logFile }).generate()

View File

@@ -61,14 +61,16 @@ async function main() {
const dataStorage = new Storage(DATA_DIR) const dataStorage = new Storage(DATA_DIR)
const loader = new DataLoader({ storage: dataStorage }) const loader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await loader.load() const data: DataLoaderData = await loader.load()
const { channelsKeyById, feedsGroupedByChannelId }: DataProcessorData = processor.process(data) const { channelsKeyById, feedsGroupedByChannelId, logosGroupedByStreamId }: DataProcessorData =
processor.process(data)
logger.info('loading streams...') logger.info('loading streams...')
const rootStorage = new Storage(ROOT_DIR) const rootStorage = new Storage(ROOT_DIR)
const parser = new PlaylistParser({ const parser = new PlaylistParser({
storage: rootStorage, storage: rootStorage,
channelsKeyById, channelsKeyById,
feedsGroupedByChannelId feedsGroupedByChannelId,
logosGroupedByStreamId
}) })
const files = program.args.length ? program.args : await rootStorage.list(`${STREAMS_DIR}/*.m3u`) const files = program.args.length ? program.args : await rootStorage.list(`${STREAMS_DIR}/*.m3u`)
streams = await parser.parse(files) streams = await parser.parse(files)

View File

@@ -20,13 +20,15 @@ async function main() {
const dataStorage = new Storage(DATA_DIR) const dataStorage = new Storage(DATA_DIR)
const dataLoader = new DataLoader({ storage: dataStorage }) const dataLoader = new DataLoader({ storage: dataStorage })
const data: DataLoaderData = await dataLoader.load() const data: DataLoaderData = await dataLoader.load()
const { channelsKeyById, feedsGroupedByChannelId }: DataProcessorData = processor.process(data) const { channelsKeyById, feedsGroupedByChannelId, logosGroupedByStreamId }: DataProcessorData =
processor.process(data)
logger.info('loading streams...') logger.info('loading streams...')
const streamsStorage = new Storage(STREAMS_DIR) const streamsStorage = new Storage(STREAMS_DIR)
const parser = new PlaylistParser({ const parser = new PlaylistParser({
storage: streamsStorage, storage: streamsStorage,
feedsGroupedByChannelId, feedsGroupedByChannelId,
logosGroupedByStreamId,
channelsKeyById channelsKeyById
}) })
const files = await streamsStorage.list('**/*.m3u') const files = await streamsStorage.list('**/*.m3u')
@@ -168,6 +170,7 @@ async function addStreams({
const quality = data.getString('quality') || null const quality = data.getString('quality') || null
const httpUserAgent = data.getString('httpUserAgent') || null const httpUserAgent = data.getString('httpUserAgent') || null
const httpReferrer = data.getString('httpReferrer') || null const httpReferrer = data.getString('httpReferrer') || null
const directives = data.getArray('directives') || []
const stream = new Stream({ const stream = new Stream({
channel: channelId, channel: channelId,
@@ -176,6 +179,7 @@ async function addStreams({
url: streamUrl, url: streamUrl,
user_agent: httpUserAgent, user_agent: httpUserAgent,
referrer: httpReferrer, referrer: httpReferrer,
directives,
quality, quality,
label label
}) })

View File

@@ -26,6 +26,7 @@ async function main() {
const { const {
channelsKeyById, channelsKeyById,
feedsGroupedByChannelId, feedsGroupedByChannelId,
logosGroupedByStreamId,
blocklistRecordsGroupedByChannelId blocklistRecordsGroupedByChannelId
}: DataProcessorData = processor.process(data) }: DataProcessorData = processor.process(data)
@@ -34,7 +35,8 @@ async function main() {
const parser = new PlaylistParser({ const parser = new PlaylistParser({
storage: rootStorage, storage: rootStorage,
channelsKeyById, channelsKeyById,
feedsGroupedByChannelId feedsGroupedByChannelId,
logosGroupedByStreamId
}) })
const files = program.args.length ? program.args : await rootStorage.list('streams/**/*.m3u') const files = program.args.length ? program.args : await rootStorage.list('streams/**/*.m3u')
const streams = await parser.parse(files) const streams = await parser.parse(files)

View File

@@ -21,6 +21,7 @@ async function main() {
const { const {
channelsKeyById, channelsKeyById,
feedsGroupedByChannelId, feedsGroupedByChannelId,
logosGroupedByStreamId,
blocklistRecordsGroupedByChannelId blocklistRecordsGroupedByChannelId
}: DataProcessorData = processor.process(data) }: DataProcessorData = processor.process(data)
@@ -29,7 +30,8 @@ async function main() {
const parser = new PlaylistParser({ const parser = new PlaylistParser({
storage: streamsStorage, storage: streamsStorage,
channelsKeyById, channelsKeyById,
feedsGroupedByChannelId feedsGroupedByChannelId,
logosGroupedByStreamId
}) })
const files = await streamsStorage.list('**/*.m3u') const files = await streamsStorage.list('**/*.m3u')
const streams = await parser.parse(files) const streams = await parser.parse(files)
@@ -151,7 +153,7 @@ async function main() {
else if (!feedId && streamsGroupedByChannelId.has(channelId)) result.status = 'fulfilled' else if (!feedId && streamsGroupedByChannelId.has(channelId)) result.status = 'fulfilled'
else { else {
const channelData = channelsKeyById.get(channelId) const channelData = channelsKeyById.get(channelId)
if (channelData.length && channelData[0].closed) result.status = 'closed' if (channelData && channelData.isClosed) result.status = 'closed'
} }
channelSearchRequestsBuffer.set(streamId, true) channelSearchRequestsBuffer.set(streamId, true)

View File

@@ -47,6 +47,7 @@ export class DataLoader {
blocklist, blocklist,
channels, channels,
feeds, feeds,
logos,
timezones, timezones,
guides, guides,
streams streams
@@ -59,6 +60,7 @@ export class DataLoader {
this.storage.json('blocklist.json'), this.storage.json('blocklist.json'),
this.storage.json('channels.json'), this.storage.json('channels.json'),
this.storage.json('feeds.json'), this.storage.json('feeds.json'),
this.storage.json('logos.json'),
this.storage.json('timezones.json'), this.storage.json('timezones.json'),
this.storage.json('guides.json'), this.storage.json('guides.json'),
this.storage.json('streams.json') this.storage.json('streams.json')
@@ -73,6 +75,7 @@ export class DataLoader {
blocklist, blocklist,
channels, channels,
feeds, feeds,
logos,
timezones, timezones,
guides, guides,
streams streams

View File

@@ -11,7 +11,8 @@ import {
Region, Region,
Stream, Stream,
Guide, Guide,
Feed Feed,
Logo
} from '../models' } from '../models'
export class DataProcessor { export class DataProcessor {
@@ -21,6 +22,9 @@ export class DataProcessor {
const categories = new Collection(data.categories).map(data => new Category(data)) const categories = new Collection(data.categories).map(data => new Category(data))
const categoriesKeyById = categories.keyBy((category: Category) => category.id) const categoriesKeyById = categories.keyBy((category: Category) => category.id)
const languages = new Collection(data.languages).map(data => new Language(data))
const languagesKeyByCode = languages.keyBy((language: Language) => language.code)
const subdivisions = new Collection(data.subdivisions).map(data => new Subdivision(data)) const subdivisions = new Collection(data.subdivisions).map(data => new Subdivision(data))
const subdivisionsKeyByCode = subdivisions.keyBy((subdivision: Subdivision) => subdivision.code) const subdivisionsKeyByCode = subdivisions.keyBy((subdivision: Subdivision) => subdivision.code)
const subdivisionsGroupedByCountryCode = subdivisions.groupBy( const subdivisionsGroupedByCountryCode = subdivisions.groupBy(
@@ -30,20 +34,6 @@ export class DataProcessor {
let regions = new Collection(data.regions).map(data => new Region(data)) let regions = new Collection(data.regions).map(data => new Region(data))
const regionsKeyByCode = regions.keyBy((region: Region) => region.code) const regionsKeyByCode = regions.keyBy((region: Region) => region.code)
const blocklistRecords = new Collection(data.blocklist).map(data => new BlocklistRecord(data))
const blocklistRecordsGroupedByChannelId = blocklistRecords.groupBy(
(blocklistRecord: BlocklistRecord) => blocklistRecord.channelId
)
const streams = new Collection(data.streams).map(data => new Stream(data))
const streamsGroupedById = streams.groupBy((stream: Stream) => stream.getId())
const guides = new Collection(data.guides).map(data => new Guide(data))
const guidesGroupedByStreamId = guides.groupBy((guide: Guide) => guide.getStreamId())
const languages = new Collection(data.languages).map(data => new Language(data))
const languagesKeyByCode = languages.keyBy((language: Language) => language.code)
const countries = new Collection(data.countries).map(data => const countries = new Collection(data.countries).map(data =>
new Country(data) new Country(data)
.withRegions(regions) .withRegions(regions)
@@ -52,13 +42,16 @@ export class DataProcessor {
) )
const countriesKeyByCode = countries.keyBy((country: Country) => country.code) const countriesKeyByCode = countries.keyBy((country: Country) => country.code)
regions = regions.map((region: Region) => region.withCountries(countriesKeyByCode))
const timezones = new Collection(data.timezones).map(data => const timezones = new Collection(data.timezones).map(data =>
new Timezone(data).withCountries(countriesKeyByCode) new Timezone(data).withCountries(countriesKeyByCode)
) )
const timezonesKeyById = timezones.keyBy((timezone: Timezone) => timezone.id) const timezonesKeyById = timezones.keyBy((timezone: Timezone) => timezone.id)
const blocklistRecords = new Collection(data.blocklist).map(data => new BlocklistRecord(data))
const blocklistRecordsGroupedByChannelId = blocklistRecords.groupBy(
(blocklistRecord: BlocklistRecord) => blocklistRecord.channelId
)
let channels = new Collection(data.channels).map(data => let channels = new Collection(data.channels).map(data =>
new Channel(data) new Channel(data)
.withCategories(categoriesKeyById) .withCategories(categoriesKeyById)
@@ -66,6 +59,7 @@ export class DataProcessor {
.withSubdivision(subdivisionsKeyByCode) .withSubdivision(subdivisionsKeyByCode)
.withCategories(categoriesKeyById) .withCategories(categoriesKeyById)
) )
const channelsKeyById = channels.keyBy((channel: Channel) => channel.id) const channelsKeyById = channels.keyBy((channel: Channel) => channel.id)
const feeds = new Collection(data.feeds).map(data => const feeds = new Collection(data.feeds).map(data =>
@@ -78,14 +72,32 @@ export class DataProcessor {
.withBroadcastSubdivisions(subdivisionsKeyByCode) .withBroadcastSubdivisions(subdivisionsKeyByCode)
) )
const feedsGroupedByChannelId = feeds.groupBy((feed: Feed) => feed.channelId) const feedsGroupedByChannelId = feeds.groupBy((feed: Feed) => feed.channelId)
const feedsGroupedById = feeds.groupBy((feed: Feed) => feed.id)
channels = channels.map((channel: Channel) => channel.withFeeds(feedsGroupedByChannelId)) let logos = new Collection(data.logos).map(data => new Logo(data).withFeed(feedsGroupedById))
const logosGroupedByChannelId = logos.groupBy((logo: Logo) => logo.channelId)
const logosGroupedByStreamId = logos.groupBy((logo: Logo) => logo.getStreamId())
const streams = new Collection(data.streams).map(data =>
new Stream(data).withLogos(logosGroupedByStreamId)
)
const streamsGroupedById = streams.groupBy((stream: Stream) => stream.getId())
const guides = new Collection(data.guides).map(data => new Guide(data))
const guidesGroupedByStreamId = guides.groupBy((guide: Guide) => guide.getStreamId())
regions = regions.map((region: Region) => region.withCountries(countriesKeyByCode))
channels = channels.map((channel: Channel) =>
channel.withFeeds(feedsGroupedByChannelId).withLogos(logosGroupedByChannelId)
)
return { return {
blocklistRecordsGroupedByChannelId, blocklistRecordsGroupedByChannelId,
subdivisionsGroupedByCountryCode, subdivisionsGroupedByCountryCode,
feedsGroupedByChannelId, feedsGroupedByChannelId,
guidesGroupedByStreamId, guidesGroupedByStreamId,
logosGroupedByStreamId,
subdivisionsKeyByCode, subdivisionsKeyByCode,
countriesKeyByCode, countriesKeyByCode,
languagesKeyByCode, languagesKeyByCode,
@@ -104,7 +116,8 @@ export class DataProcessor {
regions, regions,
streams, streams,
guides, guides,
feeds feeds,
logos
} }
} }
} }

View File

@@ -24,9 +24,11 @@ export class IssueData {
return this._data.get(key) === deleteSymbol ? '' : this._data.get(key) return this._data.get(key) === deleteSymbol ? '' : this._data.get(key)
} }
getArray(key: string): string[] { getArray(key: string): string[] | undefined {
const deleteSymbol = '~' const deleteSymbol = '~'
if (this._data.missing(key)) return undefined
return this._data.get(key) === deleteSymbol ? [] : this._data.get(key).split('\r\n') return this._data.get(key) === deleteSymbol ? [] : this._data.get(key).split('\r\n')
} }
} }

View File

@@ -16,7 +16,7 @@ export class IssueLoader {
} }
let issues: object[] = [] let issues: object[] = []
if (TESTING) { if (TESTING) {
issues = (await import('../../tests/__data__/input/playlist_update/issues.js')).default issues = (await import('../../tests/__data__/input/issues.js')).default
} else { } else {
issues = await octokit.paginate(octokit.rest.issues.listForRepo, { issues = await octokit.paginate(octokit.rest.issues.listForRepo, {
owner: OWNER, owner: OWNER,

View File

@@ -16,7 +16,8 @@ const FIELDS = new Dictionary({
'HTTP Referrer': 'httpReferrer', 'HTTP Referrer': 'httpReferrer',
'What happened to the stream?': 'reason', 'What happened to the stream?': 'reason',
Reason: 'reason', Reason: 'reason',
Notes: 'notes' Notes: 'notes',
Directives: 'directives'
}) })
export class IssueParser { export class IssueParser {

View File

@@ -5,17 +5,25 @@ import { Stream } from '../models'
type PlaylistPareserProps = { type PlaylistPareserProps = {
storage: Storage storage: Storage
feedsGroupedByChannelId: Dictionary feedsGroupedByChannelId: Dictionary
logosGroupedByStreamId: Dictionary
channelsKeyById: Dictionary channelsKeyById: Dictionary
} }
export class PlaylistParser { export class PlaylistParser {
storage: Storage storage: Storage
feedsGroupedByChannelId: Dictionary feedsGroupedByChannelId: Dictionary
logosGroupedByStreamId: Dictionary
channelsKeyById: Dictionary channelsKeyById: Dictionary
constructor({ storage, feedsGroupedByChannelId, channelsKeyById }: PlaylistPareserProps) { constructor({
storage,
feedsGroupedByChannelId,
logosGroupedByStreamId,
channelsKeyById
}: PlaylistPareserProps) {
this.storage = storage this.storage = storage
this.feedsGroupedByChannelId = feedsGroupedByChannelId this.feedsGroupedByChannelId = feedsGroupedByChannelId
this.logosGroupedByStreamId = logosGroupedByStreamId
this.channelsKeyById = channelsKeyById this.channelsKeyById = channelsKeyById
} }
@@ -41,6 +49,7 @@ export class PlaylistParser {
.fromPlaylistItem(data) .fromPlaylistItem(data)
.withFeed(this.feedsGroupedByChannelId) .withFeed(this.feedsGroupedByChannelId)
.withChannel(this.channelsKeyById) .withChannel(this.channelsKeyById)
.withLogos(this.logosGroupedByStreamId)
.setFilepath(filepath) .setFilepath(filepath)
return stream return stream

View File

@@ -17,7 +17,7 @@ export class CategoriesGenerator implements Generator {
logFile: File logFile: File
constructor({ streams, categories, logFile }: CategoriesGeneratorProps) { constructor({ streams, categories, logFile }: CategoriesGeneratorProps) {
this.streams = streams this.streams = streams.clone()
this.categories = categories this.categories = categories
this.storage = new Storage(PUBLIC_DIR) this.storage = new Storage(PUBLIC_DIR)
this.logFile = logFile this.logFile = logFile
@@ -30,7 +30,8 @@ export class CategoriesGenerator implements Generator {
const categoryStreams = streams const categoryStreams = streams
.filter((stream: Stream) => stream.hasCategory(category)) .filter((stream: Stream) => stream.hasCategory(category))
.map((stream: Stream) => { .map((stream: Stream) => {
stream.groupTitle = stream.getCategoryNames().join(';') const groupTitle = stream.getCategoryNames().join(';')
if (groupTitle) stream.groupTitle = groupTitle
return stream return stream
}) })

View File

@@ -17,7 +17,7 @@ export class CountriesGenerator implements Generator {
logFile: File logFile: File
constructor({ streams, countries, logFile }: CountriesGeneratorProps) { constructor({ streams, countries, logFile }: CountriesGeneratorProps) {
this.streams = streams this.streams = streams.clone()
this.countries = countries this.countries = countries
this.storage = new Storage(PUBLIC_DIR) this.storage = new Storage(PUBLIC_DIR)
this.logFile = logFile this.logFile = logFile

View File

@@ -1,10 +1,11 @@
export * from './categoriesGenerator' export * from './categoriesGenerator'
export * from './countriesGenerator' export * from './countriesGenerator'
export * from './languagesGenerator'
export * from './regionsGenerator'
export * from './indexGenerator'
export * from './indexNsfwGenerator'
export * from './indexCategoryGenerator' export * from './indexCategoryGenerator'
export * from './indexCountryGenerator' export * from './indexCountryGenerator'
export * from './indexGenerator'
export * from './indexLanguageGenerator' export * from './indexLanguageGenerator'
export * from './indexNsfwGenerator'
export * from './indexRegionGenerator' export * from './indexRegionGenerator'
export * from './languagesGenerator'
export * from './regionsGenerator'
export * from './sourcesGenerator'

View File

@@ -15,7 +15,7 @@ export class IndexCategoryGenerator implements Generator {
logFile: File logFile: File
constructor({ streams, logFile }: IndexCategoryGeneratorProps) { constructor({ streams, logFile }: IndexCategoryGeneratorProps) {
this.streams = streams this.streams = streams.clone()
this.storage = new Storage(PUBLIC_DIR) this.storage = new Storage(PUBLIC_DIR)
this.logFile = logFile this.logFile = logFile
} }

View File

@@ -15,7 +15,7 @@ export class IndexCountryGenerator implements Generator {
logFile: File logFile: File
constructor({ streams, logFile }: IndexCountryGeneratorProps) { constructor({ streams, logFile }: IndexCountryGeneratorProps) {
this.streams = streams this.streams = streams.clone()
this.storage = new Storage(PUBLIC_DIR) this.storage = new Storage(PUBLIC_DIR)
this.logFile = logFile this.logFile = logFile
} }

View File

@@ -15,7 +15,7 @@ export class IndexGenerator implements Generator {
logFile: File logFile: File
constructor({ streams, logFile }: IndexGeneratorProps) { constructor({ streams, logFile }: IndexGeneratorProps) {
this.streams = streams this.streams = streams.clone()
this.storage = new Storage(PUBLIC_DIR) this.storage = new Storage(PUBLIC_DIR)
this.logFile = logFile this.logFile = logFile
} }
@@ -24,6 +24,12 @@ export class IndexGenerator implements Generator {
const sfwStreams = this.streams const sfwStreams = this.streams
.orderBy(stream => stream.getTitle()) .orderBy(stream => stream.getTitle())
.filter((stream: Stream) => stream.isSFW()) .filter((stream: Stream) => stream.isSFW())
.map((stream: Stream) => {
const groupTitle = stream.getCategoryNames().join(';')
if (groupTitle) stream.groupTitle = groupTitle
return stream
})
const playlist = new Playlist(sfwStreams, { public: true }) const playlist = new Playlist(sfwStreams, { public: true })
const filepath = 'index.m3u' const filepath = 'index.m3u'

View File

@@ -15,7 +15,7 @@ export class IndexLanguageGenerator implements Generator {
logFile: File logFile: File
constructor({ streams, logFile }: IndexLanguageGeneratorProps) { constructor({ streams, logFile }: IndexLanguageGeneratorProps) {
this.streams = streams this.streams = streams.clone()
this.storage = new Storage(PUBLIC_DIR) this.storage = new Storage(PUBLIC_DIR)
this.logFile = logFile this.logFile = logFile
} }

View File

@@ -15,7 +15,7 @@ export class IndexNsfwGenerator implements Generator {
logFile: File logFile: File
constructor({ streams, logFile }: IndexNsfwGeneratorProps) { constructor({ streams, logFile }: IndexNsfwGeneratorProps) {
this.streams = streams this.streams = streams.clone()
this.storage = new Storage(PUBLIC_DIR) this.storage = new Storage(PUBLIC_DIR)
this.logFile = logFile this.logFile = logFile
} }

View File

@@ -17,7 +17,7 @@ export class IndexRegionGenerator implements Generator {
logFile: File logFile: File
constructor({ streams, regions, logFile }: IndexRegionGeneratorProps) { constructor({ streams, regions, logFile }: IndexRegionGeneratorProps) {
this.streams = streams this.streams = streams.clone()
this.regions = regions this.regions = regions
this.storage = new Storage(PUBLIC_DIR) this.storage = new Storage(PUBLIC_DIR)
this.logFile = logFile this.logFile = logFile

View File

@@ -12,7 +12,7 @@ export class LanguagesGenerator implements Generator {
logFile: File logFile: File
constructor({ streams, logFile }: LanguagesGeneratorProps) { constructor({ streams, logFile }: LanguagesGeneratorProps) {
this.streams = streams this.streams = streams.clone()
this.storage = new Storage(PUBLIC_DIR) this.storage = new Storage(PUBLIC_DIR)
this.logFile = logFile this.logFile = logFile
} }

View File

@@ -17,7 +17,7 @@ export class RegionsGenerator implements Generator {
logFile: File logFile: File
constructor({ streams, regions, logFile }: RegionsGeneratorProps) { constructor({ streams, regions, logFile }: RegionsGeneratorProps) {
this.streams = streams this.streams = streams.clone()
this.regions = regions this.regions = regions
this.storage = new Storage(PUBLIC_DIR) this.storage = new Storage(PUBLIC_DIR)
this.logFile = logFile this.logFile = logFile

View File

@@ -0,0 +1,44 @@
import { Collection, Storage, File, type Dictionary } from '@freearhey/core'
import { Stream, Playlist } from '../models'
import { PUBLIC_DIR } from '../constants'
import { Generator } from './generator'
import { EOL } from 'node:os'
type SourcesGeneratorProps = {
streams: Collection
logFile: File
}
export class SourcesGenerator implements Generator {
streams: Collection
storage: Storage
logFile: File
constructor({ streams, logFile }: SourcesGeneratorProps) {
this.streams = streams.clone()
this.storage = new Storage(PUBLIC_DIR)
this.logFile = logFile
}
async generate() {
const files: Dictionary = this.streams.groupBy((stream: Stream) => stream.getFilename())
for (let filename of files.keys()) {
if (!filename) continue
let streams = new Collection(files.get(filename))
streams = streams.map((stream: Stream) => {
const groupTitle = stream.getCategoryNames().join(';')
if (groupTitle) stream.groupTitle = groupTitle
return stream
})
const playlist = new Playlist(streams, { public: true })
const filepath = `sources/${filename}`
await this.storage.save(filepath, playlist.toString())
this.logFile.append(
JSON.stringify({ type: 'source', filepath, count: playlist.streams.count() }) + EOL
)
}
}
}

View File

@@ -1,5 +1,5 @@
import { Collection, Dictionary } from '@freearhey/core' import { Collection, Dictionary } from '@freearhey/core'
import { Category, Country, Feed, Guide, Stream, Subdivision } from './index' import { Category, Country, Feed, Guide, Logo, Stream, Subdivision } from './index'
import type { ChannelData, ChannelSearchableData, ChannelSerializedData } from '../types/channel' import type { ChannelData, ChannelSearchableData, ChannelSerializedData } from '../types/channel'
export class Channel { export class Channel {
@@ -19,9 +19,10 @@ export class Channel {
launched?: string launched?: string
closed?: string closed?: string
replacedBy?: string replacedBy?: string
isClosed: boolean
website?: string website?: string
logo: string
feeds?: Collection feeds?: Collection
logos: Collection = new Collection()
constructor(data?: ChannelData) { constructor(data?: ChannelData) {
if (!data) return if (!data) return
@@ -40,7 +41,7 @@ export class Channel {
this.closed = data.closed || undefined this.closed = data.closed || undefined
this.replacedBy = data.replaced_by || undefined this.replacedBy = data.replaced_by || undefined
this.website = data.website || undefined this.website = data.website || undefined
this.logo = data.logo this.isClosed = !!data.closed || !!data.replaced_by
} }
withSubdivision(subdivisionsKeyByCode: Dictionary): this { withSubdivision(subdivisionsKeyByCode: Dictionary): this {
@@ -71,6 +72,12 @@ export class Channel {
return this return this
} }
withLogos(logosGroupedByChannelId: Dictionary): this {
if (this.id) this.logos = new Collection(logosGroupedByChannelId.get(this.id))
return this
}
getCountry(): Country | undefined { getCountry(): Country | undefined {
return this.country return this.country
} }
@@ -142,6 +149,35 @@ export class Channel {
return this.isNSFW === false return this.isNSFW === false
} }
getLogos(): Collection {
function feed(logo: Logo): number {
if (!logo.feed) return 1
if (logo.feed.isMain) return 1
return 0
}
function format(logo: Logo): number {
const levelByFormat = { SVG: 0, PNG: 3, APNG: 1, WebP: 1, AVIF: 1, JPEG: 2, GIF: 1 }
return logo.format ? levelByFormat[logo.format] : 0
}
function size(logo: Logo): number {
return Math.abs(512 - logo.width) + Math.abs(512 - logo.height)
}
return this.logos.orderBy([feed, format, size], ['desc', 'desc', 'asc'], false)
}
getLogo(): Logo | undefined {
return this.getLogos().first()
}
hasLogo(): boolean {
return this.getLogos().notEmpty()
}
getSearchable(): ChannelSearchableData { getSearchable(): ChannelSearchableData {
return { return {
id: this.id, id: this.id,
@@ -171,8 +207,7 @@ export class Channel {
launched: this.launched, launched: this.launched,
closed: this.closed, closed: this.closed,
replacedBy: this.replacedBy, replacedBy: this.replacedBy,
website: this.website, website: this.website
logo: this.logo
} }
} }
@@ -192,7 +227,6 @@ export class Channel {
this.closed = data.closed this.closed = data.closed
this.replacedBy = data.replacedBy this.replacedBy = data.replacedBy
this.website = data.website this.website = data.website
this.logo = data.logo
return this return this
} }

View File

@@ -7,6 +7,7 @@ export * from './feed'
export * from './guide' export * from './guide'
export * from './issue' export * from './issue'
export * from './language' export * from './language'
export * from './logo'
export * from './playlist' export * from './playlist'
export * from './region' export * from './region'
export * from './stream' export * from './stream'

40
scripts/models/logo.ts Normal file
View File

@@ -0,0 +1,40 @@
import { Collection, type Dictionary } from '@freearhey/core'
import type { LogoData } from '../types/logo'
import { type Feed } from './feed'
export class Logo {
channelId: string
feedId?: string
feed: Feed
tags: Collection
width: number
height: number
format?: string
url: string
constructor(data?: LogoData) {
if (!data) return
this.channelId = data.channel
this.feedId = data.feed || undefined
this.tags = new Collection(data.tags)
this.width = data.width
this.height = data.height
this.format = data.format || undefined
this.url = data.url
}
withFeed(feedsKeyById: Dictionary): this {
if (!this.feedId) return this
this.feed = feedsKeyById.get(this.feedId)
return this
}
getStreamId(): string {
if (!this.feedId) return this.channelId
return `${this.channelId}@${this.feedId}`
}
}

View File

@@ -1,8 +1,9 @@
import { Feed, Channel, Category, Region, Subdivision, Country, Language } from './index' import { Feed, Channel, Category, Region, Subdivision, Country, Language, Logo } from './index'
import { URL, Collection, Dictionary } from '@freearhey/core' import { URL, Collection, Dictionary } from '@freearhey/core'
import type { StreamData } from '../types/stream' import type { StreamData } from '../types/stream'
import parser from 'iptv-playlist-parser' import parser from 'iptv-playlist-parser'
import { IssueData } from '../core' import { IssueData } from '../core'
import path from 'node:path'
export class Stream { export class Stream {
name?: string name?: string
@@ -12,6 +13,7 @@ export class Stream {
channel?: Channel channel?: Channel
feedId?: string feedId?: string
feed?: Feed feed?: Feed
logos: Collection = new Collection()
filepath?: string filepath?: string
line?: number line?: number
label?: string label?: string
@@ -21,6 +23,7 @@ export class Stream {
userAgent?: string userAgent?: string
groupTitle: string = 'Undefined' groupTitle: string = 'Undefined'
removed: boolean = false removed: boolean = false
directives: Collection = new Collection()
constructor(data?: StreamData) { constructor(data?: StreamData) {
if (!data) return if (!data) return
@@ -38,6 +41,7 @@ export class Stream {
this.verticalResolution = verticalResolution || undefined this.verticalResolution = verticalResolution || undefined
this.isInterlaced = isInterlaced || undefined this.isInterlaced = isInterlaced || undefined
this.label = data.label || undefined this.label = data.label || undefined
this.directives = new Collection(data.directives)
} }
update(issueData: IssueData): this { update(issueData: IssueData): this {
@@ -46,7 +50,8 @@ export class Stream {
quality: issueData.getString('quality'), quality: issueData.getString('quality'),
httpUserAgent: issueData.getString('httpUserAgent'), httpUserAgent: issueData.getString('httpUserAgent'),
httpReferrer: issueData.getString('httpReferrer'), httpReferrer: issueData.getString('httpReferrer'),
newStreamUrl: issueData.getString('newStreamUrl') newStreamUrl: issueData.getString('newStreamUrl'),
directives: issueData.getArray('directives')
} }
if (data.label !== undefined) this.label = data.label if (data.label !== undefined) this.label = data.label
@@ -54,11 +59,43 @@ export class Stream {
if (data.httpUserAgent !== undefined) this.userAgent = data.httpUserAgent if (data.httpUserAgent !== undefined) this.userAgent = data.httpUserAgent
if (data.httpReferrer !== undefined) this.referrer = data.httpReferrer if (data.httpReferrer !== undefined) this.referrer = data.httpReferrer
if (data.newStreamUrl !== undefined) this.url = data.newStreamUrl if (data.newStreamUrl !== undefined) this.url = data.newStreamUrl
if (data.directives !== undefined) this.directives = new Collection(data.directives)
return this return this
} }
fromPlaylistItem(data: parser.PlaylistItem): this { fromPlaylistItem(data: parser.PlaylistItem): this {
function parseTitle(title: string): {
name: string
label: string
quality: string
} {
const [, label] = title.match(/ \[(.*)\]$/) || [null, '']
title = title.replace(new RegExp(` \\[${escapeRegExp(label)}\\]$`), '')
const [, quality] = title.match(/ \(([0-9]+p)\)$/) || [null, '']
title = title.replace(new RegExp(` \\(${quality}\\)$`), '')
return { name: title, label, quality }
}
function parseDirectives(string: string) {
let directives = new Collection()
if (!string) return directives
const supportedDirectives = ['#EXTVLCOPT', '#KODIPROP']
const lines = string.split('\r\n')
const regex = new RegExp(`^${supportedDirectives.join('|')}`, 'i')
lines.forEach((line: string) => {
if (regex.test(line)) {
directives.add(line.trim())
}
})
return directives
}
if (!data.name) throw new Error('"name" property is required') if (!data.name) throw new Error('"name" property is required')
if (!data.url) throw new Error('"url" property is required') if (!data.url) throw new Error('"url" property is required')
@@ -77,6 +114,7 @@ export class Stream {
this.url = data.url this.url = data.url
this.referrer = data.http.referrer || undefined this.referrer = data.http.referrer || undefined
this.userAgent = data.http['user-agent'] || undefined this.userAgent = data.http['user-agent'] || undefined
this.directives = parseDirectives(data.raw)
return this return this
} }
@@ -99,6 +137,12 @@ export class Stream {
return this return this
} }
withLogos(logosGroupedByStreamId: Dictionary): this {
if (this.id) this.logos = new Collection(logosGroupedByStreamId.get(this.id))
return this
}
setId(id: string): this { setId(id: string): this {
this.id = id this.id = id
@@ -130,6 +174,12 @@ export class Stream {
return this.line || -1 return this.line || -1
} }
getFilename(): string {
if (!this.filepath) return ''
return path.basename(this.filepath)
}
setFilepath(filepath: string): this { setFilepath(filepath: string): this {
this.filepath = filepath this.filepath = filepath
@@ -294,8 +344,35 @@ export class Stream {
return this.feed ? this.feed.isInternational() : false return this.feed ? this.feed.isInternational() : false
} }
getLogo(): string { getLogos(): Collection {
return this?.channel?.logo || '' function format(logo: Logo): number {
const levelByFormat = { SVG: 0, PNG: 3, APNG: 1, WebP: 1, AVIF: 1, JPEG: 2, GIF: 1 }
return logo.format ? levelByFormat[logo.format] : 0
}
function size(logo: Logo): number {
return Math.abs(512 - logo.width) + Math.abs(512 - logo.height)
}
return this.logos.orderBy([format, size], ['desc', 'asc'], false)
}
getLogo(): Logo | undefined {
return this.getLogos().first()
}
hasLogo(): boolean {
return this.getLogos().notEmpty()
}
getLogoUrl(): string {
let logo: Logo | undefined
if (this.hasLogo()) logo = this.getLogo()
else logo = this?.channel?.getLogo()
return logo ? logo.url : ''
} }
getName(): string { getName(): string {
@@ -339,7 +416,7 @@ export class Stream {
let output = `#EXTINF:-1 tvg-id="${this.getId()}"` let output = `#EXTINF:-1 tvg-id="${this.getId()}"`
if (options.public) { if (options.public) {
output += ` tvg-logo="${this.getLogo()}" group-title="${this.groupTitle}"` output += ` tvg-logo="${this.getLogoUrl()}" group-title="${this.groupTitle}"`
} }
if (this.referrer) { if (this.referrer) {
@@ -352,13 +429,9 @@ export class Stream {
output += `,${this.getTitle()}` output += `,${this.getTitle()}`
if (this.referrer) { this.directives.forEach((prop: string) => {
output += `\r\n#EXTVLCOPT:http-referrer=${this.referrer}` output += `\r\n${prop}`
} })
if (this.userAgent) {
output += `\r\n#EXTVLCOPT:http-user-agent=${this.userAgent}`
}
output += `\r\n${this.url}` output += `\r\n${this.url}`
@@ -366,19 +439,6 @@ export class Stream {
} }
} }
function parseTitle(title: string): {
name: string
label: string
quality: string
} {
const [, label] = title.match(/ \[(.*)\]$/) || [null, '']
title = title.replace(new RegExp(` \\[${escapeRegExp(label)}\\]$`), '')
const [, quality] = title.match(/ \(([0-9]+p)\)$/) || [null, '']
title = title.replace(new RegExp(` \\(${quality}\\)$`), '')
return { name: title, label, quality }
}
function escapeRegExp(text) { function escapeRegExp(text) {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
} }

View File

@@ -21,7 +21,6 @@ export type ChannelSerializedData = {
closed?: string closed?: string
replacedBy?: string replacedBy?: string
website?: string website?: string
logo: string
} }
export type ChannelData = { export type ChannelData = {
@@ -39,7 +38,6 @@ export type ChannelData = {
closed: string closed: string
replaced_by: string replaced_by: string
website: string website: string
logo: string
} }
export type ChannelSearchableData = { export type ChannelSearchableData = {

View File

@@ -13,6 +13,7 @@ export type DataLoaderData = {
blocklist: object | object[] blocklist: object | object[]
channels: object | object[] channels: object | object[]
feeds: object | object[] feeds: object | object[]
logos: object | object[]
timezones: object | object[] timezones: object | object[]
guides: object | object[] guides: object | object[]
streams: object | object[] streams: object | object[]

View File

@@ -5,6 +5,7 @@ export type DataProcessorData = {
subdivisionsGroupedByCountryCode: Dictionary subdivisionsGroupedByCountryCode: Dictionary
feedsGroupedByChannelId: Dictionary feedsGroupedByChannelId: Dictionary
guidesGroupedByStreamId: Dictionary guidesGroupedByStreamId: Dictionary
logosGroupedByStreamId: Dictionary
subdivisionsKeyByCode: Dictionary subdivisionsKeyByCode: Dictionary
countriesKeyByCode: Dictionary countriesKeyByCode: Dictionary
languagesKeyByCode: Dictionary languagesKeyByCode: Dictionary

9
scripts/types/logo.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
export type LogoData = {
channel: string
feed: string | null
tags: string[]
width: number
height: number
format: string | null
url: string
}

View File

@@ -7,4 +7,5 @@ export type StreamData = {
user_agent: string | null user_agent: string | null
quality: string | null quality: string | null
label: string | null label: string | null
directives: string[]
} }