diff --git a/package-lock.json b/package-lock.json index 7070afe..a9fc324 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vyla-entertainment/sdk", - "version": "1.15.0", + "version": "1.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vyla-entertainment/sdk", - "version": "1.15.0", + "version": "1.16.0", "license": "CC-BY-NC-4.0", "dependencies": { "node-tls-client": "^2.1.0" diff --git a/src/config.js b/src/config.js index 4bb0ef6..5f7f4bf 100644 --- a/src/config.js +++ b/src/config.js @@ -279,6 +279,25 @@ export const SOURCES = [ }], }, + { + key: 'peestream', + label: 'PeeStream', + sourceFile: 'peestream', + proxyParam: 'pee', + timeout: 25000, + jitter: 500, + retries: 2, + multiUrl: true, + skipProxy: true, + cdnHeaders: [{ + pattern: /peestream\.in/i, + headers: { + Referer: 'https://peestream.in/', + Origin: 'https://peestream.in', + }, + }], + }, + { key: 'pengu', label: 'Pengu', diff --git a/src/manga/atsu.js b/src/manga/atsu.js new file mode 100644 index 0000000..d194028 --- /dev/null +++ b/src/manga/atsu.js @@ -0,0 +1,168 @@ +import { Session, ClientIdentifier, initTLS, destroyTLS } from "node-tls-client"; + +const BASE_URL = "https://atsu.moe"; +const CDN_URL = "https://cdn.atsu.moe"; + +const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + +const HEADERS = { + "User-Agent": UA, + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Referer": `${BASE_URL}/`, + "Origin": BASE_URL, + "Sec-Ch-Ua": '"Chromium";v="131", "Not_A Brand";v="24"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"Windows"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "Priority": "u=1, i" +}; + +let tlsInitialized = false; +let session = null; + +async function ensureSession() { + if (!tlsInitialized) { + await initTLS(); + tlsInitialized = true; + } + if (!session) { + session = new Session({ + clientIdentifier: ClientIdentifier.chrome_131, + timeout: 15000, + insecureSkipVerify: false, + }); + } + return session; +} + +const formatAssetUrl = (url) => { + if (!url) return null; + if (url.startsWith("http://") || url.startsWith("https://")) return url; + if (url.startsWith("/static/")) return `${CDN_URL}${url}`; + if (url.startsWith("/")) return `${CDN_URL}${url}`; + return `${CDN_URL}/static/${url}`; +}; + +async function atsuFetch(url) { + const sess = await ensureSession(); + const res = await sess.get(url, { headers: HEADERS }); + if (res.status !== 200) { + throw new Error(`Atsu request failed: ${res.status}`); + } + const text = await res.text(); + return JSON.parse(text); +} + +export async function searchManga(query, page = 1, perPage = 12) { + const params = new URLSearchParams({ + q: query, + query_by: "title,englishTitle,otherNames,authors,acronyms", + query_by_weights: "4,3,2,2,1", + num_typos: "4,3,2,1,0", + prefix: "true,true,true,true,false", + include_fields: "id,title,englishTitle,authors,poster,posterSmall,posterMedium,type,medium,isAdult,status,year,mbRating,popularity,dateAdded", + filter_by: "isAdult:=false && views:>0 && hidden:!=true", + page: String(page), + per_page: String(perPage), + infix: "off,off,fallback,off,off" + }); + + const data = await atsuFetch(`${BASE_URL}/collections/manga/documents/search?${params.toString()}`); + const results = (data?.hits || []).map(hit => { + const doc = hit.document || {}; + return { + id: doc.id, + title: doc.title, + englishTitle: doc.englishTitle || null, + authors: Array.isArray(doc.authors) ? doc.authors : [], + type: doc.type, + medium: doc.medium, + status: doc.status, + year: doc.year, + rating: doc.mbRating ?? null, + popularity: doc.popularity ?? null, + poster: formatAssetUrl(doc.poster), + posterSmall: formatAssetUrl(doc.posterSmall), + posterMedium: formatAssetUrl(doc.posterMedium) + }; + }); + + return { + page: data.page, + total: data.found, + results + }; +} + +export async function getMangaDetails(id) { + const data = await atsuFetch(`${BASE_URL}/api/manga/page?id=${encodeURIComponent(id)}`); + const page = data?.mangaPage; + if (!page) throw new Error(`Manga with ID "${id}" not found`); + + return { + id: page.id, + title: page.title, + englishTitle: page.englishTitle || null, + synopsis: page.synopsis || null, + status: page.status, + type: page.type, + medium: page.medium, + rating: page.avgRating ?? null, + views: page.views ?? null, + authors: Array.isArray(page.authors) ? page.authors.map(a => a.name) : [], + genres: Array.isArray(page.genres) ? page.genres.map(g => g.name) : [], + tags: Array.isArray(page.tags) ? page.tags.map(t => t.name) : [], + poster: formatAssetUrl(page.poster?.largeImage || page.poster?.image), + totalChapters: page.totalChapterCount || page.chapters?.length || 0, + chapters: (page.chapters || []).map(ch => ({ + id: ch.id, + number: ch.number, + title: ch.title, + pageCount: ch.pageCount, + createdAt: ch.createdAt ? new Date(ch.createdAt).toISOString() : null + })) + }; +} + +export async function getChapterPages(mangaId, chapterId) { + const data = await atsuFetch( + `${BASE_URL}/api/read/chapter?mangaId=${encodeURIComponent(mangaId)}&chapterId=${encodeURIComponent(chapterId)}` + ); + const chapter = data?.readChapter; + if (!chapter) throw new Error(`Chapter "${chapterId}" not found for manga "${mangaId}"`); + + return { + id: chapter.id, + number: chapter.number, + title: chapter.title, + scanlatorId: chapter.scanlationMangaId || null, + pages: (chapter.pages || []).map(p => ({ + number: p.number, + url: formatAssetUrl(p.image), + width: p.width, + height: p.height, + aspectRatio: p.aspectRatio + })) + }; +} + +export async function cleanup() { + if (session) { + try { + await session.close(); + } catch { } + session = null; + } + if (tlsInitialized) { + try { + await destroyTLS(); + } catch { } + tlsInitialized = false; + } +} \ No newline at end of file diff --git a/src/sdk.js b/src/sdk.js index e5fece4..48268d5 100644 --- a/src/sdk.js +++ b/src/sdk.js @@ -1,6 +1,7 @@ import SOURCES, { SOURCE_MAP } from "./config.js"; import { fetchDownloads } from "./downloads/downloads.js"; import { probeSource } from "./health.js"; +import { getChapterPages, getMangaDetails, searchManga } from "./manga/atsu.js"; import { fetchSubtitles, getSubPathsMovie, getSubPathsTv } from "./subtitles.js"; import { createStreamArgs } from "./utils/helpers.js"; @@ -65,4 +66,13 @@ export default class VylaSDK { async getDownloads(id, s = null, e = null) { return await fetchDownloads(this, id, s, e); } + async searchManga(query, page = 1, perPage = 12) { + return await searchManga(query, page, perPage); + } + async getManga(id) { + return await getMangaDetails(id); + } + async getMangaChapter(mangaId, chapterId) { + return await getChapterPages(mangaId, chapterId); + } } \ No newline at end of file diff --git a/src/sources/peestream.js b/src/sources/peestream.js new file mode 100644 index 0000000..1bb656a --- /dev/null +++ b/src/sources/peestream.js @@ -0,0 +1,139 @@ +import { USER_AGENT, fetchJson, getTmdbInfo } from '../utils/helpers.js'; + +const BASE_URL = 'https://providers.peestream.in'; + +const PROVIDERS = { + purstream: 'Athena', + vidrock: 'Thor', + vaplayer: 'Poseidon', + netmirror: 'Ra' +}; + +export async function getStream(args) { + const { id, s, e, server, tmdbApiKey } = args; + const isTv = s != null && e != null; + const type = isTv ? 'tv' : 'movie'; + + let title = args.title || ''; + let releaseYear = args.year || ''; + let imdbId = args.imdbId || ''; + let seasonTmdbId = args.seasonTmdbId || ''; + let episodeTmdbId = args.episodeTmdbId || ''; + + try { + const info = await getTmdbInfo(id, type, s, tmdbApiKey); + if (info) { + title = title || info.name || info.title || ''; + + if (!releaseYear) { + const date = info.release_date || info.first_air_date || ''; + releaseYear = date ? date.split('-')[0] : (info.year || ''); + } + + if (!imdbId && info.external_ids?.imdb_id) { + imdbId = info.external_ids.imdb_id; + } else if (!imdbId && info.imdb_id) { + imdbId = info.imdb_id; + } + + if (isTv) { + if (!seasonTmdbId && Array.isArray(info.seasons)) { + const matchedSeason = info.seasons.find( + item => item.season_number === Number(s) + ); + if (matchedSeason) { + seasonTmdbId = String(matchedSeason.id); + } + } + + if (!episodeTmdbId && tmdbApiKey) { + try { + const seasonData = await fetchJson( + `https://api.themoviedb.org/3/tv/${id}/season/${s}?api_key=${tmdbApiKey}` + ); + if (seasonData?.episodes) { + const matchedEp = seasonData.episodes.find( + ep => ep.episode_number === Number(e) + ); + if (matchedEp) { + episodeTmdbId = String(matchedEp.id); + } + } + } catch { } + } + } + } + } catch { } + + const queryParams = new URLSearchParams({ + type, + tmdbId: String(id), + title: title || String(id) + }); + + if (releaseYear) queryParams.set('releaseYear', String(releaseYear)); + if (imdbId) queryParams.set('imdbId', String(imdbId)); + + if (isTv) { + queryParams.set('seasonNumber', String(s)); + queryParams.set('episodeNumber', String(e)); + if (seasonTmdbId) queryParams.set('seasonTmdbId', String(seasonTmdbId)); + if (episodeTmdbId) queryParams.set('episodeTmdbId', String(episodeTmdbId)); + } + + const allUrls = []; + + try { + const scrapeUrl = `${BASE_URL}/scrape?${queryParams.toString()}`; + const res = await fetch(scrapeUrl, { + headers: { + 'User-Agent': USER_AGENT, + 'Accept': 'text/event-stream', + 'Referer': `${BASE_URL}/` + }, + signal: AbortSignal.timeout(20000) + }); + + if (res.ok) { + const text = await res.text(); + const events = text.split('\n\n'); + + for (const ev of events) { + if (ev.includes('event: completed')) { + const dataMatch = ev.match(/data:\s*(.+)/); + if (dataMatch) { + try { + const parsed = JSON.parse(dataMatch[1].trim()); + const stream = parsed.stream; + const streamUrl = stream?.playlist || stream?.url || stream?.file; + if (streamUrl) { + const sourceName = PROVIDERS[parsed.sourceId] || parsed.sourceId || 'Poseidon'; + allUrls.push({ + url: streamUrl, + server: `PeeStream - ${sourceName}`, + quality: 'Auto', + type: stream.type === 'hls' || streamUrl.includes('.m3u8') ? 'hls' : 'mp4', + headers: stream.headers || { + 'User-Agent': USER_AGENT + } + }); + } + } catch { } + } + } + } + } + } catch { } + + if (server && server !== 'all') { + const clean = server.toLowerCase().replace('peestream - ', '').trim(); + const filtered = allUrls.filter(u => u.server.toLowerCase().includes(clean)); + if (filtered.length) return { allUrls: filtered }; + } + + return allUrls.length ? { allUrls } : null; +} + +export async function getSources() { + return Object.values(PROVIDERS).map(name => `PeeStream - ${name}`); +} \ No newline at end of file diff --git a/test/index.js b/test/index.js index db0f1cf..22a28ef 100644 --- a/test/index.js +++ b/test/index.js @@ -13,17 +13,20 @@ const CONFIG = { TV_ID: "37854", // TV TMDB ID to test, for anime I recommend using a random ID like 37854 TV_SEASON: "1", // TV Season to test TV_EPISODE: "1", // TV Episode to test - STREAM_SOURCE: "", // Source key to test + STREAM_SOURCE: "atsu", // Source key to test - TEST_SUBTITLE: false, // Toggle subtitle testing - TEST_DOWNLOAD: false, // Toggle download testing - TEST_MOVIE: true, // Toggle movie testing - TEST_TV: true, // Toggle TV testing + TEST_SUBTITLE: false, + TEST_DOWNLOAD: false, + TEST_MOVIE: false, + TEST_TV: false, + TEST_MANGA: true, + MANGA_QUERY: "Steel Ball Run", - EXTRA_SDK_PARAMS: { // Additional parameters for the SDK constructor + EXTRA_SDK_PARAMS: { // penguManifest: "" } }; + dotenv.config({ debug: CONFIG.DEBUG }); @@ -189,4 +192,33 @@ if (CONFIG.TEST_TV) { } } else { console.log("\nSkipped Stream Test"); +} + +if (CONFIG.TEST_MANGA) { + try { + console.log(`\nSearching Manga for: "${CONFIG.MANGA_QUERY}"`); + const searchRes = await sdk.searchManga(CONFIG.MANGA_QUERY, 1, 5); + console.log("Manga Search Found:", searchRes.total); + console.log("Top Hit:", searchRes.results[0]); + + if (searchRes.results.length > 0) { + const mangaId = searchRes.results[0].id; + console.log(`\nFetching Manga Details for ID: ${mangaId}`); + const mangaDetails = await sdk.getManga(mangaId); + console.log("Manga Title:", mangaDetails.title); + console.log("Total Chapters:", mangaDetails.totalChapters); + + const firstChapter = mangaDetails.chapters[mangaDetails.chapters.length - 1] || mangaDetails.chapters[0]; + if (firstChapter) { + console.log(`\nFetching Pages for Chapter: ${firstChapter.title} (${firstChapter.id})`); + const chapterPages = await sdk.getMangaChapter(mangaId, firstChapter.id); + console.log("Page Count:", chapterPages.pages.length); + console.log("Sample First Page:", chapterPages.pages[0]); + } + } + } catch (err) { + console.error("Manga Test Error:", err.message); + } +} else { + console.log("\nSkipped Manga Test"); } \ No newline at end of file