diff --git a/plugins/index.ts b/plugins/index.ts index 2b1651e1..0e18c1cf 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -18,15 +18,16 @@ import p_15 from '@plugins/vietnamese/MotTruyen'; import p_16 from '@plugins/vietnamese/NguonC'; import p_17 from '@plugins/vietnamese/OPhim'; import p_18 from '@plugins/vietnamese/PhimFun'; -import p_19 from '@plugins/vietnamese/SangTacViet'; -import p_20 from '@plugins/vietnamese/TieuThuyetMang'; -import p_21 from '@plugins/vietnamese/TomatoMTL'; -import p_22 from '@plugins/vietnamese/TruyenCV'; -import p_23 from '@plugins/vietnamese/TruyenFull'; -import p_24 from '@plugins/vietnamese/Valvrareteam'; -import p_25 from '@plugins/vietnamese/WanwanSekai'; -import p_26 from '@plugins/vietnamese/YeuAnime'; -import p_27 from '@plugins/vietnamese/ZumiNovel'; +import p_19 from '@plugins/vietnamese/Playm3u'; +import p_20 from '@plugins/vietnamese/SangTacViet'; +import p_21 from '@plugins/vietnamese/TieuThuyetMang'; +import p_22 from '@plugins/vietnamese/TomatoMTL'; +import p_23 from '@plugins/vietnamese/TruyenCV'; +import p_24 from '@plugins/vietnamese/TruyenFull'; +import p_25 from '@plugins/vietnamese/Valvrareteam'; +import p_26 from '@plugins/vietnamese/WanwanSekai'; +import p_27 from '@plugins/vietnamese/YeuAnime'; +import p_28 from '@plugins/vietnamese/ZumiNovel'; const PLUGINS: Plugin.PluginBase[] = [ p_0, @@ -57,5 +58,6 @@ const PLUGINS: Plugin.PluginBase[] = [ p_25, p_26, p_27, + p_28, ]; export default PLUGINS; diff --git a/plugins/vietnamese/Playm3u/index.ts b/plugins/vietnamese/Playm3u/index.ts new file mode 100644 index 00000000..e18bd3a8 --- /dev/null +++ b/plugins/vietnamese/Playm3u/index.ts @@ -0,0 +1,208 @@ +import { fetchText } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; +import { storage } from '@libs/storage'; + +type PlaylistItem = { + name: string; + tvg: { + id: string; + name: string; + logo: string; + url: string; + rec: string; + shift: string; + }; + group: { + title: string; + }; + http: { + referrer: string; + 'user-agent': string; + }; + url: string; + catchup: { + type: string; + days: string; + source: string; + }; + timeshift: string; + lang: string; +} + +const iptvPlaylistParser = { + parse: (content: string) => { + const getAttribute = (str: string, name: string) => { + const regex = new RegExp(`${name}="(.*?)"`, 'gi'); + const match = regex.exec(str); + return match?.[1] ?? ''; + }; + const getName = (str: string) => { + const info = str.replace(/="(.*?)"/g, ''); + const parts = info.split(/,(.*)/); + return parts[1] || ''; + }; + const getOption = (str: string, name: string) => { + const regex = new RegExp(`:${name}=(.*)`, 'gi'); + const match = regex.exec(str); + return typeof match?.[1] === 'string' ? match[1].replace(/"/g, '') : ''; + }; + const getValue = (str: string) => { + const regex = new RegExp(':(.*)', 'gi'); + const match = regex.exec(str); + return typeof match?.[1] === 'string' ? match[1].replace(/"/g, '') : ''; + }; + const getParameter = (str: string, name: string) => { + const params = str.replace(/^(.*)\|/, ''); + const regex = new RegExp(`${name}=(\\w[^&]*)`, 'gi'); + const match = regex.exec(params); + return match?.[1] ?? ''; + }; + + const lines = content.split(/\r?\n/); + if (!lines[0] || !/^#EXTM3U/.test(lines[0])) throw new Error('Playlist is not valid'); + + const items: PlaylistItem[] = []; + let currentItem: Partial | null = null; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + if (line.startsWith('#EXTINF:')) { + currentItem = { + name: getName(line), + tvg: { + id: getAttribute(line, 'tvg-id'), + name: getAttribute(line, 'tvg-name'), + logo: getAttribute(line, 'tvg-logo'), + url: getAttribute(line, 'tvg-url'), + rec: getAttribute(line, 'tvg-rec'), + shift: getAttribute(line, 'tvg-shift') + }, + group: { + title: getAttribute(line, 'group-title') + }, + http: { + referrer: getAttribute(line, 'referrer'), + 'user-agent': getAttribute(line, 'user-agent') + }, + url: '', + catchup: { + type: getAttribute(line, 'catchup'), + days: getAttribute(line, 'catchup-days'), + source: getAttribute(line, 'catchup-source') + }, + timeshift: getAttribute(line, 'timeshift'), + lang: getAttribute(line, 'lang') + }; + } else if (line.startsWith('#EXTVLCOPT:')) { + if (!currentItem?.http) continue; + currentItem.http.referrer = getOption(line, 'http-referrer') || currentItem.http.referrer; + currentItem.http['user-agent'] = getOption(line, 'http-user-agent') || currentItem.http['user-agent']; + } else if (line.startsWith('#EXTGRP:')) { + if (!currentItem?.group) continue; + currentItem.group.title = getValue(line) || currentItem.group.title; + } else if (line.startsWith('#')) { + continue; + } else { + if (!currentItem?.http) continue; + currentItem.url = line; + currentItem.http['user-agent'] = getParameter(line, 'user-agent') || currentItem.http['user-agent']; + currentItem.http.referrer = getParameter(line, 'referer') || currentItem.http.referrer; + items.push(currentItem as PlaylistItem); + currentItem = null; + } + } + + return { items }; + } +}; + +class Playm3uPlugin implements Plugin.PluginBase { + id = 'playm3u'; + name = '🎞 Play m3u'; + icon = 'src/vi/playm3u/icon.png'; + site = 'https://vnepg.site'; + version = '1.0.0'; + + pluginSettings: Plugin.PluginSettings = { + m3uUrl: { + value: '', + label: 'M3U URL', + type: 'Text', + }, + }; + + get m3uUrl(): string { + return (storage.get('m3uUrl') as string) || this.pluginSettings.m3uUrl.value; + } + + async popularNovels(pageNo: number): Promise { + if (pageNo > 1 || !this.m3uUrl) return []; + const text = await fetchText(this.m3uUrl); + + const playlist = iptvPlaylistParser.parse(text); + + return playlist.items.map((item) => { + const params = new URLSearchParams(); + params.set('url', item.url); + params.set('name', item.name); + if (item.tvg.logo) params.set('logo', item.tvg.logo); + if (item.http['user-agent']) params.set('ua', item.http['user-agent']); + + return { + name: item.name, + path: `/playm3u?${params.toString()}`, + cover: item.tvg.logo || defaultCover, + }; + }); + } + + async searchNovels(searchTerm: string, pageNo: number): Promise { + if (pageNo > 1) return []; + const novels = await this.popularNovels(1); + return novels.filter(n => n.name.toLowerCase().includes(searchTerm.toLowerCase())); + } + + async parseNovel(novelPath: string): Promise { + const params = new URLSearchParams(novelPath.split('?')[1]); + const name = params.get('name') || 'Unknown'; + const cover = params.get('logo') || defaultCover; + + return { + path: novelPath, + name, + cover, + status: NovelStatus.Ongoing, + chapters: [{ + name, + path: novelPath, + chapterNumber: 1, + }] + }; + } + + async parseChapter(chapterPath: string): Promise { + const params = new URLSearchParams(chapterPath.split('?')[1]); + const url = params.get('url') || ''; + const ua = params.get('ua') || ''; + + return [ + '', + '', + '', + ``, + ua ? `` : '', + '', + '', + ].join('\n'); + } + + resolveUrl(path: string): string { + return this.site; + } +} + +export default new Playm3uPlugin(); diff --git a/public/static/src/vi/playm3u/icon.png b/public/static/src/vi/playm3u/icon.png new file mode 100644 index 00000000..22ecaa59 Binary files /dev/null and b/public/static/src/vi/playm3u/icon.png differ