From c28308c38997d258d734c571f97459f02cf25914 Mon Sep 17 00:00:00 2001 From: alwaysmesmerizingyou Date: Wed, 7 Oct 2026 20:03:38 -0700 Subject: [PATCH] huge doc rewrite --- ai-catalog.json | 282 +++--- architecture/index.html | 190 ---- assets/app.js | 1092 ++++++++++++++------- assets/highlight.css | 107 --- assets/mintlify-overrides.css | 121 --- assets/{ => styling}/base.css | 848 ++++++++++++----- assets/styling/highlight.css | 140 +++ assets/styling/main.css | 3 + assets/styling/roots.css | 60 ++ concepts/providers.md | 87 ++ concepts/providers/index.html | 293 ------ go/index.html | 152 --- guides/error-handling/index.html | 432 --------- guides/integrations/index.html | 1253 ------------------------- home/index.html | 202 ---- index.html | 287 ++---- llms.txt | 90 +- manifest.webmanifest | 30 + misc/credit/index.html | 167 ---- misc/dmca/index.html | 182 ---- misc/project/DMCA.md | 59 ++ misc/project/credit.md | 55 ++ player/index.html | 184 ---- products/go.md | 17 + products/home.md | 73 ++ products/player.md | 37 + products/tv.md | 19 + robots.txt | 99 +- sdk-quickstart/index.html | 194 ---- sdk/index.html | 588 ------------ sdk/vyla-sdk/index.md | 346 +++++++ sdk/vyla-sdk/quickstart.md | 82 ++ self-hosting/index.html | 280 ------ sitemap.xml | 96 +- start-here/architecture.md | 38 + start-here/introduction.md | 54 ++ start-here/who-are-we.md | 17 + stream-api/deployment/self-hosting.md | 121 +++ stream-api/media-data/index.html | 199 ---- stream-api/operations/index.html | 167 ---- stream-api/overview/index.html | 194 ---- stream-api/overview/index.md | 36 + stream-api/paths/index.html | 196 ---- stream-api/reference/media-data.md | 54 ++ stream-api/reference/operations.md | 37 + stream-api/reference/paths.md | 72 ++ tv/index.html | 157 ---- 47 files changed, 3185 insertions(+), 6304 deletions(-) delete mode 100644 architecture/index.html delete mode 100644 assets/highlight.css delete mode 100644 assets/mintlify-overrides.css rename assets/{ => styling}/base.css (64%) create mode 100644 assets/styling/highlight.css create mode 100644 assets/styling/main.css create mode 100644 assets/styling/roots.css create mode 100644 concepts/providers.md delete mode 100644 concepts/providers/index.html delete mode 100644 go/index.html delete mode 100644 guides/error-handling/index.html delete mode 100644 guides/integrations/index.html delete mode 100644 home/index.html create mode 100644 manifest.webmanifest delete mode 100644 misc/credit/index.html delete mode 100644 misc/dmca/index.html create mode 100644 misc/project/DMCA.md create mode 100644 misc/project/credit.md delete mode 100644 player/index.html create mode 100644 products/go.md create mode 100644 products/home.md create mode 100644 products/player.md create mode 100644 products/tv.md delete mode 100644 sdk-quickstart/index.html delete mode 100644 sdk/index.html create mode 100644 sdk/vyla-sdk/index.md create mode 100644 sdk/vyla-sdk/quickstart.md delete mode 100644 self-hosting/index.html create mode 100644 start-here/architecture.md create mode 100644 start-here/introduction.md create mode 100644 start-here/who-are-we.md create mode 100644 stream-api/deployment/self-hosting.md delete mode 100644 stream-api/media-data/index.html delete mode 100644 stream-api/operations/index.html delete mode 100644 stream-api/overview/index.html create mode 100644 stream-api/overview/index.md delete mode 100644 stream-api/paths/index.html create mode 100644 stream-api/reference/media-data.md create mode 100644 stream-api/reference/operations.md create mode 100644 stream-api/reference/paths.md delete mode 100644 tv/index.html diff --git a/ai-catalog.json b/ai-catalog.json index c9e6751..0f109fa 100644 --- a/ai-catalog.json +++ b/ai-catalog.json @@ -1,177 +1,113 @@ { - "name": "Vyla Documentation", - "description": "Comprehensive documentation for Vyla - a community-built, self-hosted media project focused on freedom, privacy, and user control. Includes SDK reference, Stream API documentation, and guides for building custom media players.", - "url": "https://vyla.cc", - "type": "documentation", - "category": "software", - "tags": [ - "media streaming", - "self-hosted", - "privacy", - "open source", - "SDK", - "API", - "Node.js", - "TypeScript", - "Android", - "Android TV", - "desktop", - "streaming", - "video player", - "HLS", - "SSE" - ], - "language": "en", - "version": "1.0.0", - "lastUpdated": "2026-10-06", - "maintainer": { - "name": "Vyla Entertainment", - "url": "https://gitlab.com/vyla-entertainment/" + "$schema": "https://json-schema.org/draft/2020-12/schema", + "name": "Vyla Entertainment", + "identifier": "vyla-entertainment", + "description": "A community-built media streaming and scraper ecosystem providing native applications, a Node.js resolver SDK, and a self-hosted Stream API.", + "homepage": "https://vyla.cc", + "documentation": "https://vyla.cc", + "llms_context": "https://vyla.cc/llms.txt", + "repository": "https://gitlab.com/vyla-entertainment", + "community": "https://discord.gg/5eqRhKeywG", + "license": "Open Source", + "ecosystem": { + "clients": [ + { + "name": "Vyla Home", + "platform": [ + "Windows", + "macOS", + "Linux" + ], + "description": "Desktop control center and self-contained runtime.", + "documentation": "https://vyla.cc/products/home" + }, + { + "name": "Vyla Go", + "platform": [ + "Android" + ], + "description": "Mobile application running local resolver services.", + "documentation": "https://vyla.cc/products/go" + }, + { + "name": "Vyla TV", + "platform": [ + "Android TV" + ], + "description": "Television interface built with Jetpack Compose and ExoPlayer.", + "documentation": "https://vyla.cc/products/tv" + } + ], + "libraries": [ + { + "package": "@vyla-entertainment/sdk", + "language": "Node.js (>=18)", + "description": "Server-side resolver library for stream links, subtitle extraction, and download links.", + "documentation": "https://vyla.cc/sdk" + } + ], + "services": [ + { + "name": "Stream API", + "type": "Self-hosted HTTP/SSE Proxy", + "description": "REST and Server-Sent Events service wrapping Vyla SDK for remote clients.", + "default_port": 7860, + "protocols": [ + "HTTP", + "SSE" + ], + "documentation": "https://vyla.cc/stream-api/overview", + "endpoints": [ + { + "path": "/movie", + "method": "GET", + "query_parameters": [ + "id" + ], + "response_type": "text/event-stream" + }, + { + "path": "/tv", + "method": "GET", + "query_parameters": [ + "id", + "season", + "episode" + ], + "response_type": "text/event-stream" + }, + { + "path": "/subtitles/movie/:id", + "method": "GET", + "response_type": "application/json" + }, + { + "path": "/subtitles/tv/:id/:season/:episode", + "method": "GET", + "response_type": "application/json" + }, + { + "path": "/downloads/movie/:id", + "method": "GET", + "response_type": "application/json" + }, + { + "path": "/downloads/tv/:id/:season/:episode", + "method": "GET", + "response_type": "application/json" + }, + { + "path": "/health", + "method": "GET", + "response_type": "application/json" + } + ] + } + ] }, - "documentation": { - "main": "https://vyla.cc", - "llms": "https://vyla.cc/llms.txt", - "repository": "https://gitlab.com/vyla-entertainment/" - }, - "topics": [ - { - "name": "Getting Started", - "pages": [ - { - "title": "Introduction", - "url": "/" - }, - { - "title": "Architecture", - "url": "/architecture" - } - ] - }, - { - "name": "Products", - "pages": [ - { - "title": "Vyla Home", - "url": "/home" - }, - { - "title": "Vyla Go", - "url": "/go" - }, - { - "title": "Vyla TV", - "url": "/tv" - }, - { - "title": "Build a Player", - "url": "/player" - } - ] - }, - { - "name": "SDK", - "pages": [ - { - "title": "SDK Reference", - "url": "/sdk" - }, - { - "title": "SDK Quickstart", - "url": "/sdk-quickstart" - } - ] - }, - { - "name": "Stream API", - "pages": [ - { - "title": "Overview", - "url": "/stream-api/overview" - }, - { - "title": "Paths and Events", - "url": "/stream-api/paths" - }, - { - "title": "Subtitles and Downloads", - "url": "/stream-api/media-data" - }, - { - "title": "Health and Operations", - "url": "/stream-api/operations" - } - ] - }, - { - "name": "Deployment", - "pages": [ - { - "title": "Self-hosting", - "url": "/self-hosting" - } - ] - }, - { - "name": "Concepts", - "pages": [ - { - "title": "Providers", - "url": "/concepts/providers" - } - ] - }, - { - "name": "Guides", - "pages": [ - { - "title": "Client Integration", - "url": "/guides/integrations" - }, - { - "title": "Error Handling", - "url": "/guides/error-handling" - } - ] - }, - { - "name": "Miscellaneous", - "pages": [ - { - "title": "Credits", - "url": "/misc/credit" - }, - { - "title": "DMCA Policy", - "url": "/misc/dmca" - } - ] - } - ], - "apis": [ - { - "name": "Vyla SDK", - "type": "npm", - "package": "@vyla-entertainment/sdk", - "description": "Node.js/TypeScript SDK for direct provider resolution", - "language": "TypeScript" - }, - { - "name": "Stream API", - "type": "http", - "description": "Self-hosted HTTP/SSE API for browser and mobile clients", - "protocol": "SSE" - } - ], - "platforms": [ - "Windows", - "macOS", - "Linux", - "Android", - "Android TV" - ], - "license": "ISC", - "community": { - "discord": "https://discord.gg/5eqRhKeywG", - "gitlab": "https://gitlab.com/vyla-entertainment/" + "compliance": { + "hosting_policy": "Non-hosting scraper proxy; all video segments originate from third-party hosts.", + "dmca_contact": "alwaysmesmerizingyou@gmail.com", + "dmca_policy": "https://vyla.cc/misc/project/dmca" } } \ No newline at end of file diff --git a/architecture/index.html b/architecture/index.html deleted file mode 100644 index 38494d1..0000000 --- a/architecture/index.html +++ /dev/null @@ -1,190 +0,0 @@ - - - - - - - Our Architecture — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
-

Our Architecture

-

How Vyla Home, SDK, and Stream API relate, and where each responsibility belongs. -

-

The Vyla stack

-

Vyla separates local application management, provider resolution, and client-facing delivery. That separation - lets each layer be used on its own while keeping the integration points clear.

-

Vyla Home

-

Home is responsible for a desktop session: local configuration, encrypted local credentials, update - coordination, managed processes, and the embedded interface. It is the right layer for launching and - supervising a local experience.

-

Vyla SDK

-

The SDK contains the provider-facing work: source configuration, stream resolution, subtitle retrieval, - download lookup, and health probes. It is the right layer when your Node.js application needs to decide how - requests are authorized, cached, selected, and returned.

-

Stream API

-

Stream API wraps the SDK in a self-hosted HTTP service. Its movie and TV endpoints use Server-Sent Events so - metadata can arrive first and usable sources can be delivered progressively. It also provides subtitle, - download, health, test, and optional debug routes.

-

Common deployment patterns

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PatternBest forKey decision
Home onlya managed local desktop experienceHome owns the lifecycle of bundled local services
App + SDKa Node.js backend with a custom product APIyour app owns authentication, policy, and response design
Player + Stream APIbrowser or mobile clients that need a stable HTTP contractdeploy Stream API and place it behind TLS when exposed remotely
Home + Stream APIa local player backed by a local servicekeep the service bound and access-controlled for the local environment
-

Data flow for playback

-
    -
  1. A client provides a TMDB media ID (plus season and episode for TV).
  2. -
  3. Stream API, or your own SDK-based backend, asks active providers for candidates.
  4. -
  5. Candidates are checked and successful sources are returned progressively.
  6. -
  7. The player begins with the first compatible candidate and keeps later candidates as fallbacks.
  8. -
  9. Subtitle and download lookups are separate operations; neither should block first playback.
  10. -
-

Provider availability varies by source and network environment. Build the player experience around fallbacks - and empty-result states, as described in Error handling.

-
- -
-
- - - - - \ No newline at end of file diff --git a/assets/app.js b/assets/app.js index 02af3db..e751221 100644 --- a/assets/app.js +++ b/assets/app.js @@ -9,14 +9,110 @@ var ICONS = { hash: '', copy: '', check: '', - chevron: '' + chevron: '', + chevronLeft: '', + chevronRight: '' }; + +var SITE_TREE = [ + { + id: "vyla", + title: "Vyla", + path: "/", + groups: [ + { + title: "Welcome", + items: [ + { title: "Who Are We", file: "/start-here/who-are-we.md", path: "/start-here/who-are-we" } + ] + }, + { + title: "Start here", + items: [ + { title: "Introduction", file: "/start-here/introduction.md", path: "/start-here/introduction" }, + { title: "Architecture", file: "/start-here/architecture.md", path: "/start-here/architecture" } + ] + }, + { + title: "Products", + items: [ + { title: "Home", file: "/products/home.md", path: "/products/home" }, + { title: "Go", file: "/products/go.md", path: "/products/go" }, + { title: "Tv", file: "/products/tv.md", path: "/products/tv" }, + { title: "Player", file: "/products/player.md", path: "/products/player" } + ] + }, + { + title: "Concepts", + items: [ + { title: "Providers", file: "/concepts/providers.md", path: "/concepts/providers" } + ] + } + ] + }, + { + id: "sdk", + title: "SDK", + path: "/sdk", + groups: [ + { + title: "Vyla SDK", + items: [ + { title: "Sdk", file: "/sdk/vyla-sdk/index.md", path: "/sdk" }, + { title: "Sdk Quickstart", file: "/sdk/vyla-sdk/quickstart.md", path: "/sdk/vyla-sdk/quickstart" } + ] + } + ] + }, + { + id: "stream-api", + title: "Stream API", + path: "/stream-api/overview", + groups: [ + { + title: "Overview", + items: [ + { title: "Overview", file: "/stream-api/overview/index.md", path: "/stream-api/overview" } + ] + }, + { + title: "Reference", + items: [ + { title: "Paths", file: "/stream-api/reference/paths.md", path: "/stream-api/reference/paths" }, + { title: "Media Data", file: "/stream-api/reference/media-data.md", path: "/stream-api/reference/media-data" }, + { title: "Operations", file: "/stream-api/reference/operations.md", path: "/stream-api/reference/operations" } + ] + }, + { + title: "Deployment", + items: [ + { title: "Self Hosting", file: "/stream-api/deployment/self-hosting.md", path: "/stream-api/deployment/self-hosting" } + ] + } + ] + }, + { + id: "misc", + title: "Misc", + path: "/misc/project/credit", + groups: [ + { + title: "Project", + items: [ + { title: "Credit", file: "/misc/project/credit.md", path: "/misc/project/credit" }, + { title: "DMCA", file: "/misc/project/DMCA.md", path: "/misc/project/DMCA" } + ] + } + ] + } +]; + var searchData = null; var searchResults = []; var searchActive = 0; function icon(name) { - return '' + ICONS[name] + ""; + return '' + ICONS[name] + ''; } function esc(s) { @@ -49,7 +145,8 @@ function setTheme(mode) { function toggleThemeMenu(e) { e.stopPropagation(); - document.getElementById("theme-menu").classList.toggle("open"); + var menu = document.getElementById("theme-menu"); + if (menu) menu.classList.toggle("open"); } function refreshThemeUI() { @@ -73,24 +170,25 @@ function buildThemeControls() { var menu = document.createElement("div"); menu.id = "theme-menu"; menu.innerHTML = modes.map(function (m) { - return ''; + return ''; }).join(""); wrap.appendChild(menu); - var seg = document.createElement("div"); - seg.id = "theme-seg"; - seg.innerHTML = modes.map(function (m) { - return '"; - }).join(""); - (document.querySelector("#sidebar .side-links") || links).appendChild(seg); - [menu, seg].forEach(function (el) { - el.addEventListener("click", function (e) { - var b = e.target.closest("button"); - if (b) setTheme(b.getAttribute("data-mode")); - }); + menu.addEventListener("click", function (e) { + var b = e.target.closest("button"); + if (b) setTheme(b.getAttribute("data-mode")); }); refreshThemeUI(); } +function dismissBanner() { + var banner = document.getElementById("banner"); + if (banner) banner.setAttribute("data-dismissed", "true"); + document.body.classList.add("banner-dismissed"); + localStorage.setItem("vyla-banner-dismissed", "true"); + var header = document.getElementById("header-container"); + if (header) document.documentElement.style.setProperty("--header-height", header.offsetHeight + "px"); +} + function toggleSidebar() { var sidebar = document.getElementById("sidebar"); var nav = document.getElementById("navbar"); @@ -100,132 +198,513 @@ function toggleSidebar() { document.body.classList.toggle("nav-open", open); } -function findActiveTab(root) { - var hrefs = {}; - root.querySelectorAll("#sidebar .group li a").forEach(function (a) { - hrefs[a.getAttribute("href")] = true; +function cgSwitch(btn) { + var group = btn.closest(".code-group"); + var index = btn.getAttribute("data-tab-index"); + group.querySelectorAll(".code-group-tab").forEach(function (t) { + t.classList.toggle("active", t === btn); }); - var tabs = root.querySelectorAll("#navbar .tabs a"); - for (var i = 0; i < tabs.length; i++) { - if (hrefs[tabs[i].getAttribute("href")]) return tabs[i]; + group.querySelectorAll(".code-group-panel").forEach(function (p, i) { + p.classList.toggle("active", String(i) === index); + }); + window.dispatchEvent(new Event("resize")); +} + +function parseMarkdown(md) { + var lines = md.replace(/\r\n/g, "\n").split("\n"); + var out = []; + var inPre = false; + var preLang = ""; + var preCode = []; + var inTable = false; + var tableRows = []; + var inList = false; + var listType = "ul"; + var inCodeGroup = false; + var codeGroupTabs = []; + var codeGroupPanels = []; + + function flushList() { + if (inList) { + out.push(""); + inList = false; + } } - return root.querySelector("#navbar .tabs a.active"); + + function flushTable() { + if (inTable) { + var html = '
'; + if (tableRows.length > 0) { + html += ""; + var ths = tableRows[0]; + for (var i = 0; i < ths.length; i++) html += ""; + html += ""; + } + if (tableRows.length > 1) { + html += ""; + for (var r = 1; r < tableRows.length; r++) { + html += ""; + var tds = tableRows[r]; + for (var c = 0; c < tds.length; c++) html += ""; + html += ""; + } + html += ""; + } + html += "
" + parseInline(ths[i]) + "
" + parseInline(tds[c]) + "
"; + out.push(html); + inTable = false; + tableRows = []; + } + } + + function slugify(text) { + return text.toLowerCase().replace(/<[^>]+>/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + } + + function parseInline(text) { + return text + .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (_, alt, src) { + var cls = src.indexOf("devices.png") !== -1 || src.indexOf("site_preview.png") !== -1 || src.indexOf("site-preview.png") !== -1 ? ' class="vyla-banner"' : ""; + return '' + esc(alt) + '"; + }) + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, label, href) { + return '' + label + ""; + }) + .replace(/`([^`]+)`/g, function (_, code) { + return "" + esc(code) + ""; + }) + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1"); + } + + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + + if (line.startsWith(":::code-group")) { + flushList(); + flushTable(); + inCodeGroup = true; + codeGroupTabs = []; + codeGroupPanels = []; + continue; + } + + if (inCodeGroup && line === ":::") { + flushList(); + flushTable(); + var cgHtml = '
'; + codeGroupTabs.forEach(function (tab, idx) { + var activeClass = idx === 0 ? " active" : ""; + cgHtml += ''; + }); + cgHtml += '
'; + codeGroupPanels.forEach(function (panel, idx) { + var activeClass = idx === 0 ? " active" : ""; + var langClass = panel.lang ? ' class="language-' + panel.lang + '"' : ' class="language-none"'; + cgHtml += '
' + esc(panel.code) + '
'; + }); + cgHtml += '
'; + out.push(cgHtml); + inCodeGroup = false; + codeGroupTabs = []; + codeGroupPanels = []; + continue; + } + + if (line.startsWith("```")) { + flushList(); + flushTable(); + if (!inPre) { + inPre = true; + var info = line.slice(3).trim(); + var tabMatch = info.match(/\[([^\]]+)\]/); + var tabTitle = tabMatch ? tabMatch[1] : ""; + preLang = info.replace(/\[([^\]]+)\]/, "").trim(); + if (inCodeGroup && tabTitle) { + codeGroupTabs.push(tabTitle); + } + preCode = []; + } else { + inPre = false; + var codeContent = preCode.join("\n"); + var resolvedLang = preLang; + if (!resolvedLang) { + if (codeContent.trim().startsWith("{") || codeContent.trim().startsWith("[")) resolvedLang = "json"; + else if (codeContent.indexOf("=") !== -1 && !codeContent.includes("function") && !codeContent.includes(";")) resolvedLang = "ini"; + else if (codeContent.startsWith("http://") || codeContent.startsWith("https://")) resolvedLang = "http"; + else resolvedLang = "bash"; + } + if (inCodeGroup) { + codeGroupPanels.push({ lang: resolvedLang, code: codeContent }); + } else { + var langClass = resolvedLang ? ' class="language-' + resolvedLang + '"' : ' class="language-none"'; + out.push('
" + esc(codeContent) + "
"); + } + } + continue; + } + + if (inPre) { + preCode.push(line); + continue; + } + + if (line.startsWith(":::callout-")) { + flushList(); + flushTable(); + var type = line.slice(11).trim(); + out.push('
'); + continue; + } + if (line === ":::") { + flushList(); + flushTable(); + out.push("
"); + continue; + } + + if (line.startsWith(" 0 && cells[0].match(/^:?-+:?$/)) { + continue; + } + inTable = true; + tableRows.push(cells); + continue; + } else { + flushTable(); + } + + if (line.match(/^---+$/)) { + flushList(); + out.push("
"); + continue; + } + + var hMatch = line.match(/^(#{1,6})\s+(.*)$/); + if (hMatch) { + flushList(); + var level = hMatch[1].length; + var titleText = parseInline(hMatch[2]); + var rawTitle = hMatch[2]; + var id = slugify(rawTitle); + out.push("' + titleText + ""); + continue; + } + + var olMatch = line.match(/^\d+\.\s+(.*)$/); + if (olMatch) { + if (!inList || listType !== "ol") { + flushList(); + inList = true; + listType = "ol"; + out.push("
    "); + } + out.push("
  1. " + parseInline(olMatch[1]) + "
  2. "); + continue; + } + + var ulMatch = line.match(/^[-*]\s+(.*)$/); + if (ulMatch) { + if (!inList || listType !== "ul") { + flushList(); + inList = true; + listType = "ul"; + out.push("
      "); + } + out.push("
    • " + parseInline(ulMatch[1]) + "
    • "); + continue; + } + + flushList(); + + if (line.trim().length === 0) { + continue; + } + + if (line.startsWith("> ")) { + out.push("

      " + parseInline(line.slice(2)) + "

      "); + continue; + } + + out.push("

      " + parseInline(line) + "

      "); + } + + flushList(); + flushTable(); + return out.join("\n"); } -function fixActiveTab() { - var current = findActiveTab(document); - if (!current) return; - document.querySelectorAll("#navbar .tabs a").forEach(function (a) { - a.classList.toggle("active", a === current); - }); +function resolveRoute(pathname) { + var path = pathname || window.location.pathname; + if (!path.startsWith("/")) path = "/" + path; + path = path.replace(/\/+$/, "") || "/"; + + for (var t = 0; t < SITE_TREE.length; t++) { + var tab = SITE_TREE[t]; + for (var g = 0; g < tab.groups.length; g++) { + var grp = tab.groups[g]; + for (var i = 0; i < grp.items.length; i++) { + var item = grp.items[i]; + var itemPath = item.path.replace(/\/+$/, "") || "/"; + if (itemPath === path) { + return { tab: tab, group: grp, item: item }; + } + } + } + } + return { tab: SITE_TREE[0], group: SITE_TREE[0].groups[0], item: SITE_TREE[0].groups[0].items[0] }; } -function activeLink() { - return document.querySelector("#sidebar .group:not(.mobile-tabs) li a.active"); -} - -function activeGroupTitle() { - var a = activeLink(); - var g = a && a.closest(".group"); - var t = g && g.querySelector(".group-title"); - return t ? t.textContent.trim() : ""; -} - -function buildMobileBar() { - var nav = document.getElementById("navbar"); - var sidebar = document.getElementById("sidebar"); +function renderNavTabs(currentTab) { + var nav = document.getElementById("nav-tabs"); if (!nav) return; - var a = activeLink(); - var group = activeGroupTitle(); + nav.innerHTML = SITE_TREE.map(function (tab) { + var activeClass = tab.id === currentTab.id ? " active" : ""; + return '' + esc(tab.title) + ""; + }).join(""); +} + +function renderSidebar(currentTab, currentItem) { + var sidebar = document.getElementById("sidebar"); + if (!sidebar) return; + var html = ""; + currentTab.groups.forEach(function (group) { + html += '
      '; + html += '

      ' + esc(group.title) + "

      "; + html += "
        "; + group.items.forEach(function (item) { + var activeClass = item.path === currentItem.path ? ' class="active"' : ""; + html += '
      • ' + esc(item.title) + "
      • "; + }); + html += "
      "; + }); + sidebar.innerHTML = html; + buildMobileBar(currentTab, currentItem); +} + +function buildMobileBar(currentTab, currentItem) { + var oldBar = document.getElementById("mobile-bar"); + if (oldBar) oldBar.remove(); + + var headerContainer = document.getElementById("header-container") || document.getElementById("navbar"); + if (!headerContainer) return; + + var route = resolveRoute(window.location.pathname); var bar = document.createElement("div"); bar.id = "mobile-bar"; bar.innerHTML = - '" + - (group ? "" + esc(group) + '›' : "") + - '' + esc(a ? a.textContent.trim() : document.title) + ""; - nav.parentNode.insertBefore(bar, nav.nextSibling); - var tabs = document.querySelectorAll("#navbar .tabs a"); - if (sidebar && tabs.length) { - var currentTab = document.querySelector("#navbar .tabs a.active") || tabs[0]; + '' + + '
      ' + + '' + esc(route.group.title) + '' + + '›' + + '' + esc(currentItem.title) + '' + + '' + + '
      '; + headerContainer.appendChild(bar); + + var pageBtn = bar.querySelector("#mobile-crumb-page"); + if (pageBtn) { + pageBtn.addEventListener("click", function () { + window.scrollTo({ top: 0, behavior: "smooth" }); + }); + } + + var subBtn = bar.querySelector("#mobile-crumb-sub"); + if (subBtn) { + subBtn.addEventListener("click", function () { + var targetId = subBtn.getAttribute("data-target-id"); + var targetEl = targetId ? document.getElementById(targetId) : null; + if (targetEl) { + var header = document.getElementById("header-container") || document.getElementById("navbar"); + var offset = header ? header.offsetHeight + 16 : 80; + var topPos = targetEl.getBoundingClientRect().top + window.pageYOffset - offset; + window.scrollTo({ top: Math.max(0, topPos), behavior: "smooth" }); + } + }); + } + + var sidebar = document.getElementById("sidebar"); + if (sidebar) { + var existingTabs = sidebar.querySelector(".mobile-tabs"); + if (existingTabs) existingTabs.remove(); + var g = document.createElement("div"); g.className = "mobile-tabs"; g.innerHTML = - '" + - '
      ' + Array.prototype.map.call(tabs, function (t) { - return '" + esc(t.textContent.trim()) + icon("check") + ""; + '" + + '
      ' + SITE_TREE.map(function (t) { + var onClass = t.id === currentTab.id ? " on" : ""; + return '"; }).join("") + "
      "; sidebar.insertBefore(g, sidebar.firstChild); + g.querySelector(".mt-btn").addEventListener("click", function () { g.classList.toggle("open"); }); - document.addEventListener("click", function (e) { - if (!g.contains(e.target)) g.classList.remove("open"); - }); - g.querySelectorAll(".mt-menu a").forEach(function (a) { - a.addEventListener("click", function (e) { - e.preventDefault(); + + g.querySelectorAll(".mt-item").forEach(function (btn) { + btn.addEventListener("click", function (e) { e.stopPropagation(); - var href = a.getAttribute("href"); - g.querySelectorAll(".mt-menu a").forEach(function (link) { - link.classList.remove("on"); - }); - a.classList.add("on"); - g.querySelector(".mt-btn span").textContent = a.textContent.trim(); - g.classList.remove("open"); - var sidebar = document.getElementById("sidebar"); - if (sidebar) { - var mobileTabs = sidebar.querySelector(".mobile-tabs"); - var nav = document.getElementById("navbar"); - var top = Math.max(0, Math.round(nav.getBoundingClientRect().bottom)); - sidebar.style.setProperty("--sidebar-top", top + "px"); - fetch(href) - .then(function (r) { return r.text(); }) - .then(function (html) { - var doc = new DOMParser().parseFromString(html, "text/html"); - var newSidebar = doc.querySelector("#sidebar"); - if (newSidebar) { - var groups = newSidebar.querySelectorAll(".group"); - var sideHead = sidebar.querySelector(".side-head"); - sidebar.innerHTML = ""; - if (sideHead) sidebar.appendChild(sideHead); - if (mobileTabs) sidebar.appendChild(mobileTabs); - groups.forEach(function (group) { - sidebar.appendChild(group.cloneNode(true)); - }); - addEyebrow(); - } - }) - .catch(function () { }); - if (!sidebar.classList.contains("open")) { - sidebar.classList.add("open"); - document.body.classList.add("nav-open"); - } + var targetId = btn.getAttribute("data-tab-id"); + var targetTab = SITE_TREE.find(function (t) { return t.id === targetId; }); + if (targetTab) { + g.querySelectorAll(".mt-item").forEach(function (el) { el.classList.remove("on"); }); + btn.classList.add("on"); + g.classList.remove("open"); + renderSidebar(targetTab, currentItem); + sidebar.scrollTop = 0; } }); }); + + document.addEventListener("click", function (e) { + if (!g.contains(e.target)) g.classList.remove("open"); + }); } + + var existingHead = sidebar.querySelector(".side-head"); + if (existingHead) existingHead.remove(); + var head = document.createElement("div"); head.className = "side-head"; var brand = document.querySelector("#navbar .brand"); if (brand && sidebar) { var b = brand.cloneNode(true); b.querySelector("img").removeAttribute("id"); + var sideLinks = document.createElement("div"); sideLinks.className = "side-links"; + + var modes = ["system", "light", "dark"]; + var seg = document.createElement("div"); + seg.id = "theme-seg"; + seg.innerHTML = modes.map(function (m) { + return ''; + }).join(""); + + seg.addEventListener("click", function (e) { + var btn = e.target.closest("button"); + if (btn) setTheme(btn.getAttribute("data-mode")); + }); + + sideLinks.appendChild(seg); head.appendChild(b); head.appendChild(sideLinks); sidebar.insertBefore(head, sidebar.firstChild); + refreshThemeUI(); } } -function addEyebrow() { - var h1 = document.querySelector("main.prose h1"); - var group = activeGroupTitle(); - if (!h1 || !group) return; - var el = document.createElement("div"); - el.className = "eyebrow"; - el.textContent = group; - h1.parentNode.insertBefore(el, h1); +function updateFooterNav(currentItem) { + var allItems = []; + SITE_TREE.forEach(function (tab) { + tab.groups.forEach(function (grp) { + grp.items.forEach(function (itm) { + allItems.push(itm); + }); + }); + }); + + var idx = allItems.findIndex(function (it) { + return it.path.replace(/\/+$/, "") === currentItem.path.replace(/\/+$/, ""); + }); + + var prev = idx > 0 ? allItems[idx - 1] : null; + var next = idx >= 0 && idx < allItems.length - 1 ? allItems[idx + 1] : null; + + var prevBtn = document.getElementById("footer-prev"); + var nextBtn = document.getElementById("footer-next"); + + if (prevBtn) { + if (prev) { + prevBtn.href = prev.path; + prevBtn.innerHTML = icon("chevronLeft") + "" + esc(prev.title) + ""; + prevBtn.classList.remove("hidden"); + } else { + prevBtn.classList.add("hidden"); + } + } + + if (nextBtn) { + if (next) { + nextBtn.href = next.path; + nextBtn.innerHTML = "" + esc(next.title) + "" + icon("chevronRight"); + nextBtn.classList.remove("hidden"); + } else { + nextBtn.classList.add("hidden"); + } + } +} + +function updateToc() { + var main = document.getElementById("main-content"); + var tocList = document.getElementById("toc-list"); + if (!main || !tocList) return; + var headings = main.querySelectorAll("h2"); + var html = ""; + headings.forEach(function (h) { + var id = h.id || h.textContent.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + h.id = id; + html += '
    • ' + esc(h.textContent) + "
    • "; + }); + tocList.innerHTML = html; + initTocSpy(); +} + +function initTocSpy() { + var main = document.getElementById("main-content"); + var links = Array.prototype.slice.call(document.querySelectorAll("aside#toc a")); + var headings = main ? Array.prototype.slice.call(main.querySelectorAll("h2, h3")) : []; + + function update() { + var activeHeading = null; + headings.forEach(function (h) { + if (h.getBoundingClientRect().top <= 160) { + activeHeading = h; + } + }); + + if (links.length) { + var activeId = activeHeading ? activeHeading.id : ""; + links.forEach(function (a) { + var href = a.getAttribute("href").replace(/^#/, ""); + a.classList.toggle("active", href === activeId); + }); + } + + var crumbPage = document.getElementById("mobile-crumb-page"); + var crumbSub = document.getElementById("mobile-crumb-sub"); + var crumbTarget = document.getElementById("mobile-crumb-target"); + + if (crumbPage && crumbSub && crumbTarget) { + if (activeHeading && window.scrollY > 80) { + crumbPage.classList.add("muted"); + crumbSub.style.display = "inline-flex"; + crumbSub.setAttribute("data-target-id", activeHeading.id); + crumbTarget.textContent = activeHeading.textContent; + } else { + crumbPage.classList.remove("muted"); + crumbSub.style.display = "none"; + crumbSub.removeAttribute("data-target-id"); + crumbTarget.textContent = ""; + } + } + } + + window.addEventListener("scroll", update, { passive: true }); + update(); } function addCopyButtons() { @@ -249,11 +728,13 @@ function addCopyButtons() { document.querySelectorAll("main.prose pre").forEach(function (pre) { if (pre.closest(".code-group")) return; - if (pre.parentNode.classList.contains("code-wrap")) return; - var wrap = document.createElement("div"); - wrap.className = "code-wrap"; - pre.parentNode.insertBefore(wrap, pre); - wrap.appendChild(pre); + if (pre.parentNode.classList.contains("code-wrap") && pre.parentNode.querySelector(".copy-btn")) return; + var wrap = pre.parentNode.classList.contains("code-wrap") ? pre.parentNode : document.createElement("div"); + if (!pre.parentNode.classList.contains("code-wrap")) { + wrap.className = "code-wrap"; + pre.parentNode.insertBefore(wrap, pre); + wrap.appendChild(pre); + } var b = document.createElement("button"); b.type = "button"; b.className = "copy-btn"; @@ -284,7 +765,6 @@ function bindScrollFade(box, getScroller) { } box.addEventListener("scroll", update, true); window.addEventListener("resize", update); - window.addEventListener("load", update); update(); } @@ -299,36 +779,105 @@ function initCodeFades() { function wrapTables() { document.querySelectorAll("main.prose table").forEach(function (table) { - if (table.parentNode.classList.contains("table-wrap")) return; - var wrap = document.createElement("div"); - wrap.className = "table-wrap"; - table.parentNode.insertBefore(wrap, table); - wrap.appendChild(table); - bindScrollFade(wrap, function () { - return wrap; - }); + if (table.parentNode.classList.contains("table-wrap")) { + bindScrollFade(table.parentNode, function () { return table.parentNode; }); + } }); } -function initTocSpy() { - var links = Array.prototype.slice.call(document.querySelectorAll("aside#toc a[href^='#']")); - if (!links.length) return; - var heads = links.map(function (a) { - return document.getElementById(decodeURIComponent(a.getAttribute("href").slice(1))); - }); - function update() { - var idx = 0; - heads.forEach(function (h, i) { - if (h && h.getBoundingClientRect().top < 140) idx = i; - }); - links.forEach(function (a, i) { a.classList.toggle("active", i === idx); }); +function navigateTo(path) { + var cleanPath = path.startsWith("/") ? path : "/" + path; + if (window.location.pathname !== cleanPath) { + window.history.pushState(null, "", cleanPath); } - window.addEventListener("scroll", update, { passive: true }); - update(); + loadPage(); } -var SEARCH_KEY = "vyla-search-v1"; -var searchPromise = null; +function loadPage() { + var route = resolveRoute(window.location.pathname); + renderNavTabs(route.tab); + renderSidebar(route.tab, route.item); + + document.title = route.item.title + " — Vyla"; + + var contentEl = document.getElementById("main-content"); + if (contentEl) { + contentEl.classList.remove("content-animate"); + contentEl.innerHTML = + ''; + } + + fetch(route.item.file) + .then(function (res) { + if (!res.ok) throw new Error("Not found"); + return res.text(); + }) + .then(function (md) { + if (contentEl) { + contentEl.innerHTML = parseMarkdown(md); + + if (window.Prism) { + window.Prism.highlightAllUnder(contentEl); + } + + var eyebrow = document.createElement("div"); + eyebrow.className = "eyebrow"; + eyebrow.textContent = route.group.title; + var h1 = contentEl.querySelector("h1"); + if (h1) h1.parentNode.insertBefore(eyebrow, h1); + + contentEl.classList.remove("caught-up"); + contentEl.classList.add("content-animate"); + + var catchUpOnScroll = function () { + if (contentEl.classList.contains("content-animate")) { + contentEl.classList.add("caught-up"); + } + window.removeEventListener("scroll", catchUpOnScroll, { passive: true }); + window.removeEventListener("wheel", catchUpOnScroll, { passive: true }); + window.removeEventListener("touchmove", catchUpOnScroll, { passive: true }); + }; + + window.addEventListener("scroll", catchUpOnScroll, { passive: true }); + window.addEventListener("wheel", catchUpOnScroll, { passive: true }); + window.addEventListener("touchmove", catchUpOnScroll, { passive: true }); + + setTimeout(function () { + contentEl.classList.add("caught-up"); + window.removeEventListener("scroll", catchUpOnScroll); + window.removeEventListener("wheel", catchUpOnScroll); + window.removeEventListener("touchmove", catchUpOnScroll); + }, 700); + + updateFooterNav(route.item); + updateToc(); + addCopyButtons(); + initCodeFades(); + wrapTables(); + window.scrollTo(0, 0); + } + }) + .catch(function () { + if (contentEl) { + contentEl.innerHTML = "

      Page not found

      The requested page could not be located.

      "; + } + }); + + var sidebar = document.getElementById("sidebar"); + if (sidebar && sidebar.classList.contains("open")) { + sidebar.classList.remove("open"); + document.body.classList.remove("nav-open"); + } +} function getRecent() { try { return JSON.parse(localStorage.getItem("vyla-recent-v2")) || []; } catch (e) { return []; } @@ -336,168 +885,39 @@ function getRecent() { function saveRecent(item) { if (!item) return; - var slim = { title: item.title, crumbs: item.crumbs, href: item.href, type: item.type, text: "" }; + var slim = { title: item.title, crumbs: item.crumbs, href: item.href, type: item.type }; var list = getRecent().filter(function (r) { return r.href !== slim.href; }); list.unshift(slim); localStorage.setItem("vyla-recent-v2", JSON.stringify(list.slice(0, 5))); } -function cleanText(s) { - return s.replace(/\s+/g, " ").trim(); -} - -function fetchDoc(href) { - return fetch(href) - .then(function (r) { - if (!r.ok) throw new Error(href); - return r.text(); - }) - .then(function (html) { - return { href: href, doc: new DOMParser().parseFromString(html, "text/html") }; - }) - .catch(function () { - return null; - }); -} - -function indexDoc(res) { - var doc = res.doc; - var href = res.href.split("#")[0]; - var prose = doc.querySelector("main.prose"); - if (!prose) return []; - var tabEl = findActiveTab(doc); - var activeEl = doc.querySelector("#sidebar li a.active"); - var groupEl = activeEl && activeEl.closest(".group"); - var groupTitleEl = groupEl && groupEl.querySelector(".group-title"); - var h1 = prose.querySelector("h1"); - var pageTitle = h1 ? cleanText(h1.textContent) : activeEl ? cleanText(activeEl.textContent) : href; - var base = []; - if (tabEl) base.push(cleanText(tabEl.textContent)); - if (groupTitleEl) base.push(cleanText(groupTitleEl.textContent)); - var current = { title: pageTitle, id: "", type: "page", crumbs: base.slice(), text: [] }; - var entries = [current]; - Array.prototype.forEach.call(prose.children, function (el) { - if (el.tagName === "H1") return; - if (el.tagName === "H2" || el.tagName === "H3") { - var title = cleanText(el.textContent); - current = { title: title, id: el.id, type: "heading", crumbs: base.concat([pageTitle, title]), text: [] }; - entries.push(current); - return; - } - current.text.push(cleanText(el.textContent)); - }); - return entries.map(function (e) { - return { - title: e.title, - crumbs: e.crumbs, - href: e.id ? href + "#" + e.id : href, - type: e.type, - text: e.text.join(" ").slice(0, 4000) - }; - }); -} - -function crawlSite() { - var seen = {}; - var all = []; - function visit(hrefs) { - var fresh = hrefs.map(function (h) { - return new URL(h, window.location.href); - }).filter(function (u) { - if (u.origin !== window.location.origin || seen[u.pathname]) return false; - seen[u.pathname] = true; - return true; - }).map(function (u) { - return u.pathname; - }); - return Promise.all(fresh.map(fetchDoc)).then(function (results) { - var next = []; - results.forEach(function (res) { - if (!res) return; - all = all.concat(indexDoc(res)); - res.doc.querySelectorAll("#sidebar li a").forEach(function (a) { - var h = a.getAttribute("href"); - if (h && h.charAt(0) !== "#") next.push(h); - }); - }); - return next.length ? visit(next) : all; - }); - } - var start = Array.prototype.map.call(document.querySelectorAll("#navbar .tabs a"), function (a) { - return a.getAttribute("href"); - }); - start.push(window.location.pathname); - return visit(start).then(function () { - return all; - }); -} - function loadSearchData() { if (searchData) return Promise.resolve(searchData); - if (searchPromise) return searchPromise; - try { - var cached = JSON.parse(sessionStorage.getItem(SEARCH_KEY)); - if (cached && cached.data && cached.data.length && Date.now() - cached.time < 600000) { - searchData = cached.data; - return Promise.resolve(searchData); - } - } catch (e) { } - searchPromise = crawlSite().then(function (all) { - searchData = all; - try { sessionStorage.setItem(SEARCH_KEY, JSON.stringify({ time: Date.now(), data: all })); } catch (e) { } - return all; + var promises = []; + SITE_TREE.forEach(function (tab) { + tab.groups.forEach(function (group) { + group.items.forEach(function (item) { + promises.push( + fetch(item.file) + .then(function (r) { return r.text(); }) + .then(function (text) { + return { + title: item.title, + crumbs: [tab.title, group.title, item.title], + href: item.path, + type: "page", + text: text.replace(/[#*`[\]()]/g, " ").slice(0, 3000) + }; + }) + .catch(function () { return null; }) + ); + }); + }); }); - return searchPromise; -} - -function escRe(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function hl(text, tokens) { - if (!tokens.length) return esc(text); - var re = new RegExp("(" + tokens.map(escRe).join("|") + ")", "ig"); - return text.split(re).map(function (part, i) { - return i % 2 ? '' + esc(part) + "" : esc(part); - }).join(""); -} - -function lower(it) { - if (!it._l) it._l = { t: it.title.toLowerCase(), c: it.crumbs.join(" ").toLowerCase(), x: (it.text || "").toLowerCase() }; - return it._l; -} - -function scoreEntry(it, tokens, q) { - var low = lower(it); - var score = 0; - for (var i = 0; i < tokens.length; i++) { - var t = tokens[i]; - var inTitle = low.t.indexOf(t) !== -1; - var inCrumbs = low.c.indexOf(t) !== -1; - var inText = low.x.indexOf(t) !== -1; - if (!inTitle && !inCrumbs && !inText) return 0; - score += (inTitle ? 20 : 0) + (inCrumbs ? 4 : 0) + (inText ? 1 : 0); - } - if (low.t === q) score += 100; - else if (low.t.indexOf(q) === 0) score += 50; - else if (low.t.indexOf(q) !== -1) score += 25; - if (it.type === "page") score += 5; - return score; -} - -function makeSnippet(it, tokens) { - var low = lower(it); - var inTitle = tokens.every(function (t) { return low.t.indexOf(t) !== -1; }); - if (inTitle || !it.text) return ""; - var hit = -1; - tokens.forEach(function (t) { - var k = low.x.indexOf(t); - if (k !== -1 && (hit === -1 || k < hit)) hit = k; + return Promise.all(promises).then(function (results) { + searchData = results.filter(Boolean); + return searchData; }); - if (hit === -1) return ""; - var start = Math.max(0, hit - 50); - var end = hit + 120; - return (start > 0 ? "…" : "") + it.text.slice(start, end) + (end < it.text.length ? "…" : ""); } function renderSearch(keepActive) { @@ -505,6 +925,7 @@ function renderSearch(keepActive) { var tokens = q.split(/\s+/).filter(Boolean); var box = document.getElementById("search-results"); if (!keepActive) searchActive = 0; + if (tokens.length) { if (!searchData) { searchResults = []; @@ -512,31 +933,36 @@ function renderSearch(keepActive) { return; } searchResults = searchData - .map(function (it) { return { it: it, s: scoreEntry(it, tokens, q) }; }) + .map(function (it) { + var score = 0; + var t = it.title.toLowerCase(); + var txt = it.text.toLowerCase(); + tokens.forEach(function (tok) { + if (t.indexOf(tok) !== -1) score += 20; + if (txt.indexOf(tok) !== -1) score += 2; + }); + return { it: it, s: score }; + }) .filter(function (r) { return r.s > 0; }) .sort(function (a, b) { return b.s - a.s; }) - .slice(0, 12) + .slice(0, 10) .map(function (r) { return r.it; }); } else { searchResults = getRecent(); } + if (!searchResults.length) { box.innerHTML = '
      ' + (tokens.length ? "No results found" : "No recent searches") + "
      "; return; } + box.innerHTML = (tokens.length ? "" : '
      Recent searches
      ') + searchResults.map(function (it, i) { - var snippet = tokens.length ? makeSnippet(it, tokens) : ""; - var crumbs = it.crumbs.map(function (c) { return hl(c, tokens); }).join('›'); - return '' + - icon(it.type === "heading" ? "hash" : "doc") + + var crumbs = it.crumbs.join('›'); + return '' + + icon("doc") + '
      ' + esc(it.title) + "
      " + - (snippet ? '
      ' + hl(snippet, tokens) + "
      " : "") + '
      ' + crumbs + "
      "; }).join(""); - if (keepActive) { - var on = box.querySelector(".sr.on"); - if (on) on.scrollIntoView({ block: "nearest" }); - } } function openSearch() { @@ -573,7 +999,7 @@ function onSearchKey(e) { var item = searchResults[searchActive]; saveRecent(item); closeSearch(); - window.location.href = item.href; + navigateTo(item.href); } } @@ -592,53 +1018,51 @@ function buildSearch() { document.getElementById("search-results").addEventListener("click", function (e) { var a = e.target.closest(".sr"); if (a) { + e.preventDefault(); saveRecent(searchResults[Number(a.getAttribute("data-i"))]); closeSearch(); + navigateTo(a.getAttribute("href")); } }); document.addEventListener("keydown", onSearchKey); } -applyTheme(); -window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyTheme); - -function dismissBanner() { - var banner = document.getElementById("banner"); - if (banner) banner.setAttribute("data-dismissed", "true"); - document.body.classList.add("banner-dismissed"); - localStorage.setItem("vyla-banner-dismissed", "true"); -} - -function cgSwitch(btn) { - var group = btn.closest(".code-group"); - var index = btn.getAttribute("data-tab-index"); - group.querySelectorAll(".code-group-tab").forEach(function (t) { - t.classList.toggle("active", t === btn); - }); - group.querySelectorAll(".code-group-panel").forEach(function (p, i) { - p.classList.toggle("active", String(i) === index); - }); - window.dispatchEvent(new Event("resize")); -} - document.addEventListener("DOMContentLoaded", function () { if (localStorage.getItem("vyla-banner-dismissed") === "true") { var banner = document.getElementById("banner"); if (banner) banner.setAttribute("data-dismissed", "true"); document.body.classList.add("banner-dismissed"); } + + var header = document.getElementById("header-container"); + if (header) { + document.documentElement.style.setProperty("--header-height", header.offsetHeight + "px"); + window.addEventListener("resize", function () { + document.documentElement.style.setProperty("--header-height", header.offsetHeight + "px"); + }); + } + applyTheme(); - fixActiveTab(); - buildMobileBar(); - addEyebrow(); - addCopyButtons(); - initCodeFades(); - wrapTables(); buildThemeControls(); buildSearch(); - setTimeout(loadSearchData, 1500); - initTocSpy(); + loadPage(); + + window.addEventListener("popstate", loadPage); + document.addEventListener("click", function (e) { + var a = e.target.closest("a"); + if (a) { + var href = a.getAttribute("href"); + if (href && href.startsWith("#")) { + return; + } + if (href && href.startsWith("/") && !href.startsWith("//") && a.target !== "_blank") { + e.preventDefault(); + navigateTo(href); + return; + } + } + var sidebar = document.getElementById("sidebar"); var menu = document.getElementById("theme-menu"); if (menu && !e.target.closest(".theme-wrap")) menu.classList.remove("open"); @@ -654,14 +1078,4 @@ document.addEventListener("DOMContentLoaded", function () { document.body.classList.remove("nav-open"); } }); - var sidebarLinks = document.querySelectorAll("#sidebar .group:not(.mobile-tabs) li a"); - sidebarLinks.forEach(function (a) { - a.addEventListener("click", function () { - var sidebar = document.getElementById("sidebar"); - if (sidebar && sidebar.classList.contains("open")) { - sidebar.classList.remove("open"); - document.body.classList.remove("nav-open"); - } - }); - }); }); \ No newline at end of file diff --git a/assets/highlight.css b/assets/highlight.css deleted file mode 100644 index 68923ec..0000000 --- a/assets/highlight.css +++ /dev/null @@ -1,107 +0,0 @@ -pre code.hljs { - display: block; - overflow-x: auto; - padding: 1em; - scrollbar-width: none; - -ms-overflow-style: none; - - ::-webkit-scrollbar { - width: 0; - } -} - -code.hljs { - padding: 3px 5px; -} - -.hljs { - color: #5e5e5e; - background: #0d1117 -} - -.hljs-doctag, -.hljs-keyword, -.hljs-meta .hljs-keyword, -.hljs-template-tag, -.hljs-template-variable, -.hljs-type, -.hljs-variable.language_ { - color: #ff7b72 -} - -.hljs-title, -.hljs-title.class_, -.hljs-title.class_.inherited__, -.hljs-title.function_ { - color: #d2a8ff -} - -.hljs-attr, -.hljs-attribute, -.hljs-literal, -.hljs-meta, -.hljs-number, -.hljs-operator, -.hljs-variable, -.hljs-selector-attr, -.hljs-selector-class, -.hljs-selector-id { - color: #79c0ff -} - -.hljs-regexp, -.hljs-string, -.hljs-meta .hljs-string { - color: #a5d6ff -} - -.hljs-built_in, -.hljs-symbol { - color: #ffa657 -} - -.hljs-comment, -.hljs-code, -.hljs-formula { - color: #8b949e -} - -.hljs-name, -.hljs-quote, -.hljs-selector-tag, -.hljs-selector-pseudo { - color: #7ee787 -} - -.hljs-subst { - color: #5e5e5e -} - -.hljs-section { - color: #1f6feb; - font-weight: bold -} - -.hljs-bullet { - color: #f2cc60 -} - -.hljs-emphasis { - color: #5e5e5e; - font-style: italic -} - -.hljs-strong { - color: #5e5e5e; - font-weight: bold -} - -.hljs-addition { - color: #aff5b4; - background-color: #033a16 -} - -.hljs-deletion { - color: #ffdcd7; - background-color: #67060c -} \ No newline at end of file diff --git a/assets/mintlify-overrides.css b/assets/mintlify-overrides.css deleted file mode 100644 index 14b277a..0000000 --- a/assets/mintlify-overrides.css +++ /dev/null @@ -1,121 +0,0 @@ -:root { - --table-header-text: #000000; - --vyla-black: #000000; - --vyla-white: #ffffff; - --vyla-border-light: rgba(0, 0, 0, 0.12); - --vyla-border-dark: rgba(255, 255, 255, 0.16); - --banner-bg: #ffffff; - --banner-text: #000000; -} - -.dark { - --table-header-text: #ffffff; - --banner-bg: #000000; - --banner-text: #ffffff; -} - -table th { - color: var(--table-header-text) !important; -} - -#banner, -#banner *, -#banner p, -#banner a { - color: var(--banner-text) !important; -} - -.data-\[is-opaque\=true\]\:bg-background-light[data-is-opaque=true] { - background-color: rgba(255, 255, 255, 0.2) !important; -} - -.data-\[is-opaque\=true\]\:dark\:bg-background-dark\/75:is(.dark *)[data-is-opaque=true] { - background-color: rgba(0, 0, 0, 0.2) !important; -} - -#banner a { - text-decoration: underline !important; - text-underline-offset: 3px; -} - -#banner button { - color: var(--banner-text) !important; -} - -#navbar a[href*="gitlab"], -#navbar a[href*="discord"], -#navbar a[href="/"] { - border-radius: 999px !important; -} - -#navbar a[href="/"] { - padding-left: 16px !important; - padding-right: 16px !important; -} - -.dark\:border-gray-300\/\[0\.06\]:is(.dark *) { - border-color: transparent !important; -} - -.dark\:ring-0:is(.dark *), -.dark\:ring-1:is(.dark *), -.ring-1, -.ring-2 { - box-shadow: none !important; -} - -.dark\:hover\:\!border-primary-light:hover:is(.dark *) { - background-color: transparent !important; - border-color: transparent !important; -} - -.hover\:\!border-primary:hover { - background-color: transparent !important; - border-color: transparent !important; -} - -.dark\:border-white\/10:is(.dark *) { - border-color: transparent !important; -} - -.dark\:bg-white\/10:is(.dark *) { - background-color: rgba(25, 25, 25, 0.1) !important; -} - -html.dark .code-block-background { - background-color: rgba(5, 5, 5) !important; - border-color: transparent !important; -} - -html .code-block-background { - background-color: rgba(250, 250, 250) !important; - border-color: transparent !important; -} - -[data-fade-overlay] { - background-color: transparent !important; -} - -.border { - border-color: transparent !important; -} - -.h-\[1\.5px\] { - height: 0px !important; -} - -.prose :where(img):not(:where([class~=not-prose], [class~=not-prose] *)) { - border-radius: 30px; -} - -html.dark [data-fade-overlay] { - background: transparent !important; -} - -.dark\:text-gray-500:is(.dark *) { - display: none !important; -} - -.px-6 { - padding-left: 0px !important; -} \ No newline at end of file diff --git a/assets/base.css b/assets/styling/base.css similarity index 64% rename from assets/base.css rename to assets/styling/base.css index 1622437..2f3a33d 100644 --- a/assets/base.css +++ b/assets/styling/base.css @@ -1,48 +1,18 @@ -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); - -:root { - --primary: #000000; - --bg-light: #ffffff; - --bg-dark: #000000; - --text-light: #0b0b0c; - --text-dark: #f3f3f3; - --muted-light: #6b7280; - --muted-dark: #a3a3a3; - --sidebar-width: 256px; - --toc-width: 240px; - --header-height: 109px; - --font: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; - --border-light: #e5e7eb; - --border-dark: #2b2b2b98; +#header-container { + position: sticky; + top: 0; + z-index: 40; + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(0, 0, 0, 0.05); } -* { - box-sizing: border-box; -} - -html { - height: 100%; -} - -body { - min-height: 100%; - margin: 0; - font-family: var(--font); - background: var(--bg-light); - color: var(--text-light); - line-height: 1.65; - overflow-x: hidden; -} - -html.dark body { - background: var(--bg-dark); - color: var(--text-dark); -} - -a { - color: inherit; - text-decoration: none; +html.dark #header-container { + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); } #banner { @@ -52,12 +22,11 @@ a { font-size: 13px; line-height: 1.45; text-align: center; - z-index: 100; - background: var(--bg-light); + background: transparent; } html.dark #banner { - background: var(--bg-dark); + background: transparent; } #banner[data-dismissed="true"] { @@ -95,34 +64,16 @@ html.dark #banner-close:hover { } #navbar { - position: sticky; - top: 0; - z-index: 40; - background: var(--bg-light); -} - -#navbar::after { - content: ""; - position: absolute; - top: 100%; - left: 0; - width: 100%; - height: 40px; - background: linear-gradient(to bottom, - rgba(255, 255, 255), - transparent); - pointer-events: none; -} - -html.dark #navbar::after { - background: linear-gradient(to bottom, - rgba(0, 0, 0), - transparent); + position: relative; + background: transparent; + backdrop-filter: none; + -webkit-backdrop-filter: none; } html.dark #navbar { - background: var(--bg-dark); - border-color: transparent; + background: transparent; + backdrop-filter: none; + -webkit-backdrop-filter: none; } #navbar .nav-top { @@ -132,7 +83,7 @@ html.dark #navbar { height: 60px; max-width: 1400px; margin: 0 auto; - padding: 0 16px; + padding: 0 10px; } #navbar .nav-top::after { @@ -173,7 +124,6 @@ html.dark #navbar .nav-top::after { height: 48px; max-width: 1400px; margin: 0 auto; - padding: 0 16px; overflow-x: auto; } @@ -212,18 +162,8 @@ html.dark #navbar .tabs a.active { margin: 0 auto; } -#sidebar { - width: var(--sidebar-width); - flex-shrink: 0; - padding: 28px 20px; - position: sticky; - top: var(--header-height); - height: calc(100vh - var(--header-height)); - overflow-y: auto; -} - #sidebar .group { - margin-bottom: 22px; + margin-bottom: 10px; } #sidebar .group-title { @@ -257,6 +197,14 @@ html.dark #sidebar li a { color: var(--muted-dark); } +#sidebar li a.active { + color: var(--text-light); +} + +html.dark #sidebar li a.active { + color: var(--text-dark); +} + #sidebar li a:hover { background: rgba(0, 0, 0, 0.05); color: var(--text-light); @@ -270,7 +218,7 @@ html.dark #sidebar li a:hover { #content-col { flex: 1; min-width: 0; - padding: 40px 48px 100px; + padding: 40px 48px 40px; display: flex; gap: 48px; } @@ -315,13 +263,8 @@ main.prose h3 { margin: 26px 0 10px; } -main.prose p { - margin: 14px 0; -} - main.prose ul, main.prose ol { - margin: 14px 0; padding-left: 22px; } @@ -342,21 +285,18 @@ main.prose img.vyla-banner { main.prose hr { border: none; - margin: 32px 0; } main.prose code { + background: rgba(250, 250, 250, 0.5); font-family: var(--mono); font-size: 13px; - background: rgba(0, 0, 0, 0.06); padding: 2px 6px; border-radius: 5px; } html.dark main.prose code { - background: rgba(255, 255, 255, 0.1); - position: relative; - overflow: hidden; + background: rgba(5, 5, 5); } main.prose pre { @@ -382,9 +322,12 @@ main.prose pre code { --fade-l: 0px; --fade-r: 0px; max-width: 100%; - margin: 18px 0; + margin: 20px 0; overflow-x: auto; scrollbar-width: thin; + border-radius: 14px; + border: 1px solid rgba(0, 0, 0, 0.08); + background: rgba(0, 0, 0, 0.01); -webkit-mask-image: linear-gradient(to right, transparent 0, #000 var(--fade-l), #000 calc(100% - var(--fade-r)), transparent 100%); mask-image: linear-gradient(to right, transparent 0, #000 var(--fade-l), #000 calc(100% - var(--fade-r)), transparent 100%); } @@ -395,27 +338,60 @@ main.prose pre code { table { width: 100%; - border-collapse: collapse; - margin: 18px 0; - font-size: 14px; -} - -th, -td { + border-collapse: separate; + border-spacing: 0; + margin: 0; + font-size: 13.5px; + line-height: 1.55; text-align: left; - padding: 10px 14px; } th { - font-weight: 700; + padding: 12px 18px; + font-size: 11.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted-light); + white-space: nowrap; +} + +html.dark th { + color: var(--muted-dark); +} + +td { + padding: 14px 18px; + vertical-align: top; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); +} + +html.dark td { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +tbody tr:last-child td { + border-bottom: none; +} + +tbody tr { + transition: background 0.15s ease; +} + +tbody tr:hover { + background: rgba(0, 0, 0, 0.02); +} + +td code { + white-space: nowrap; + font-size: 12.5px; } .callout { display: flex; gap: 12px; - padding: 14px 16px; + padding: 14px 11px; border-radius: 12px; - margin: 18px 0; font-size: 14px; } @@ -435,19 +411,38 @@ th { background: rgba(5, 5, 5, 0.08); } +html.dark .callout-note { + background: rgba(50, 50, 50, 0.1); +} + .callout-info { background: rgba(39, 39, 39, 0.08); } .callout-warning { - background: rgba(245, 158, 11, 0.1); + background: #fefce8 !important; + color: #854d0e !important; +} + +.callout-warning p, +.callout-warning strong { + color: inherit !important; +} + +html.dark .callout-warning { + background: oklab(68.064% 0.0348215 0.137956 / 0.2) !important; + color: #fde047 !important; +} + +html.dark .callout-warning p, +html.dark .callout-warning strong { + color: inherit !important; } .card { display: block; border-radius: 14px; - padding: 16px 18px; - margin: 18px 0; + padding: 16px 0px 18px; } a.card:hover { @@ -643,6 +638,20 @@ aside#toc { width: var(--toc-width); flex-shrink: 0; display: none; + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-radius: 12px; + padding-left: 10px; + height: fit-content; + max-height: calc(100vh - var(--header-height) - 40px); + overflow-y: auto; + position: sticky; + top: calc(var(--header-height) + 40px); +} + +html.dark aside#toc { + background: rgba(0, 0, 0, 0.4); } @media (min-width: 1100px) { @@ -700,28 +709,108 @@ html.dark aside#toc li a:hover { color: var(--text-dark); } -aside#toc { - position: sticky; - top: calc(var(--header-height) + 40px); - max-height: calc(100vh - var(--header-height) - 56px); - overflow-y: auto; - padding-top: 0; +#main-wrap { + flex: 1; + min-width: 0; + max-width: 720px; + width: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; +} + +main.prose { + min-width: 0; + max-width: 100%; + width: 100%; + box-sizing: border-box; } footer#site-footer { - padding: 28px 48px; + min-width: 0; + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: 48px 0 60px; + margin: 0; display: flex; - justify-content: center; - gap: 18px; - font-size: 13px; - color: var(--muted-light); + flex-direction: column; } -html.dark footer#site-footer { +.footer-nav { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + width: 100%; + margin: 0 0 36px; +} +.footer-link { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--text-light); + transition: opacity 0.15s ease; +} + +html.dark .footer-link { + color: #ffffff; +} + +.footer-link:hover { + opacity: 0.8; +} + +.footer-link.hidden { + visibility: hidden; +} + +.footer-link.next { + margin-left: auto; +} + +.footer-bottom { + width: 100%; + border-top: 1px solid rgba(0, 0, 0, 0.08); + padding-top: 28px; +} + +.footer-socials { + display: flex; + gap: 16px; +} + +html.dark .footer-bottom { + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.footer-socials a { + display: flex; + align-items: center; + color: var(--muted-light); + transition: color 0.15s ease; +} + +html.dark .footer-socials a { color: var(--muted-dark); } +.footer-socials a:hover { + color: var(--text-light); +} + +html.dark .footer-socials a:hover { + color: #ffffff; +} + +.footer-icon { + width: 20px; + height: 20px; +} + .hljs { background: none !important; } @@ -739,28 +828,20 @@ html.dark footer#site-footer { display: flex; align-items: center; gap: 10px; + background: rgba(0, 0, 0, 0.05); width: min(440px, 40vw); height: 36px; padding: 0 12px; border: none; border-radius: 100px; - background: none; color: var(--muted-light); font: inherit; font-size: 14px; cursor: pointer; } -#search-trigger:hover { - background: rgba(0, 0, 0, 0.05); -} - html.dark #search-trigger { - color: var(--muted-dark); -} - -html.dark #search-trigger:hover { - background: rgba(255, 255, 255, 0.08); + background: rgba(50, 50, 50, 0.1); } #search-trigger kbd { @@ -804,7 +885,7 @@ html.dark #theme-toggle:hover { } #theme-menu { - display: none; + display: block; position: absolute; right: 0; top: 46px; @@ -814,6 +895,11 @@ html.dark #theme-toggle:hover { background: var(--bg-light); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12); z-index: 50; + opacity: 0; + visibility: hidden; + transform: translateY(-8px) scale(0.96); + transition: opacity 0.2s cubic-bezier(0.16, 1, 0.3, 1), transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.2s; + pointer-events: none; } html.dark #theme-menu { @@ -821,7 +907,10 @@ html.dark #theme-menu { } #theme-menu.open { - display: block; + opacity: 1; + visibility: visible; + transform: translateY(0) scale(1); + pointer-events: auto; } #theme-menu button { @@ -895,60 +984,113 @@ html.dark #theme-seg button.on { #mobile-bar { display: none; - position: sticky; - top: 60px; - z-index: 35; - background: var(--bg-light); + position: relative; align-items: center; gap: 8px; - padding: 14px 20px; + padding: 10px 20px; font-size: 14px; color: var(--muted-light); + background: transparent; + backdrop-filter: none; + -webkit-backdrop-filter: none; + border-top: 1px solid rgba(0, 0, 0, 0.05); } html.dark #mobile-bar { - background: var(--bg-dark); -} - -html.dark #mobile-bar { + background: transparent; + backdrop-filter: none; + -webkit-backdrop-filter: none; color: var(--muted-dark); -} - -#mobile-bar::after { - content: ""; - position: absolute; - top: 100%; - left: 0; - width: 100%; - height: 40px; - background: linear-gradient(to bottom, - rgba(255, 255, 255), - transparent); - pointer-events: none; -} - -html.dark #mobile-bar::after { - background: linear-gradient(to bottom, - rgba(0, 0, 0), - transparent); + border-top: 1px solid rgba(255, 255, 255, 0.08); } #mobile-bar button { display: flex; padding: 0; - margin-right: 8px; + margin-right: 4px; border: none; background: none; color: inherit; cursor: pointer; + flex-shrink: 0; } -#mobile-bar .crumb-current { +.mobile-crumbs { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.mobile-crumbs span { + flex-shrink: 0; +} + +.mobile-crumbs .crumb-group { + color: var(--muted-light); +} + +html.dark .mobile-crumbs .crumb-group { + color: var(--muted-dark); +} + +.mobile-crumbs .crumb-sep { + opacity: 0.55; + font-size: 12px; +} + +.mobile-crumbs .crumb-page { color: var(--text-light); font-weight: 600; + cursor: pointer; + user-select: none; } -html.dark #mobile-bar .crumb-current { +html.dark .mobile-crumbs .crumb-page { + color: var(--text-dark); +} + +.mobile-crumbs .crumb-page:hover { + text-decoration: underline; + text-underline-offset: 3px; +} + +.mobile-crumbs .crumb-page.muted { + color: var(--muted-light); + font-weight: 400; +} + +html.dark .mobile-crumbs .crumb-page.muted { + color: var(--muted-dark); + font-weight: 400; +} + +.mobile-crumb-sub-btn { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; + overflow: hidden; + cursor: pointer; + user-select: none; +} + +.mobile-crumb-sub-btn:hover .crumb-heading { + text-decoration: underline; + text-underline-offset: 3px; +} + +.mobile-crumbs .crumb-heading { + color: var(--text-light); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 1; +} + +html.dark .mobile-crumbs .crumb-heading { color: var(--text-dark); } @@ -991,57 +1133,109 @@ html.dark .mt-btn .ico { } .mt-menu { - display: none; + display: block; position: absolute; - top: 100%; + top: calc(100% + 4px); left: 0; right: 0; z-index: 5; - padding: 8px; + padding: 6px; border-radius: 16px; background: var(--bg-light); border: 1px solid var(--border-light); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08); + opacity: 0; + visibility: hidden; + transform: translateY(-8px) scale(0.97); + transition: opacity 0.2s cubic-bezier(0.16, 1, 0.3, 1), transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.2s; + pointer-events: none; } html.dark .mt-menu { - background: var(--bg-dark); - border: 1px solid var(--border-dark); - + background: #000000; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.6); } .mobile-tabs.open .mt-menu { - display: block; + opacity: 1; + visibility: visible; + transform: translateY(0) scale(1); + pointer-events: auto; } -.mt-menu a { +.mt-menu .mt-item { display: flex; align-items: center; justify-content: space-between; - padding: 8px 12px; + width: 100%; + padding: 10px 14px; + border: none; + background: none; border-radius: 10px; - font-size: 13px; - font-weight: 600; + font: inherit; + font-size: 14px; + font-weight: 500; + color: var(--muted-light); + cursor: pointer; + text-align: left; + transition: background 0.15s ease, color 0.15s ease; +} + +html.dark .mt-menu .mt-item { + color: #a3a3a3; +} + +.mt-menu .mt-item:hover { + background: rgba(0, 0, 0, 0.05); color: var(--text-light); } -html.dark .mt-menu a { - color: var(--text-dark); +html.dark .mt-menu .mt-item:hover { + background: rgba(255, 255, 255, 0.06); + color: #ffffff; } -.mt-menu a .ico { - visibility: hidden; +.mt-menu .mt-item.on { + color: var(--text-light); + font-weight: 600; } -.mt-menu a.on .ico { - visibility: visible; +html.dark .mt-menu .mt-item.on { + color: #ffffff; +} + +.mt-menu .mt-item svg { + display: none; + width: 16px; + height: 16px; + stroke: currentColor; + stroke-width: 2.5; + fill: none; + flex-shrink: 0; +} + +.mt-menu .mt-item.on svg { + display: block; } #sidebar { - padding: 36px 0 24px; + margin-top: 0; + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-radius: 12px; + width: var(--sidebar-width); + flex-shrink: 0; + padding-top: 20px; + position: sticky; + top: var(--header-height); + max-height: calc(100vh - var(--header-height)); + overflow-y: auto; } -#sidebar .group { - margin-bottom: 32px; +html.dark #sidebar { + background: rgba(0, 0, 0, 0.4); } #sidebar .group-title { @@ -1066,12 +1260,10 @@ html.dark #sidebar .group-title { } #content-col { - padding: 40px 32px 100px 72px; + padding: 16px 20px 20px; gap: 92px; -} - -main.prose { - max-width: 720px; + max-width: 100%; + box-sizing: border-box; } main.prose h2, @@ -1105,7 +1297,7 @@ main.prose h2 { font-weight: 600; border-top: none; padding-top: 0; - margin: 40px 0 14px; + margin-top: 0px !important; } main.prose h1, @@ -1140,6 +1332,9 @@ html.dark main.prose strong { main.prose img { border-radius: 30px; + height: auto; + aspect-ratio: auto; + content-visibility: auto; } main.prose a:not(.card) { @@ -1221,24 +1416,22 @@ html.dark aside#toc li a.active { display: block; } -.search-backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.5); -} - .search-panel { position: relative; width: min(640px, calc(100vw - 24px)); margin: 52px auto 0; border-radius: 20px; overflow: hidden; - background: var(--bg-light); + background: rgb(210, 210, 210, 0.1) !important; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); } html.dark .search-panel { - background: var(--bg-dark); + background: rgb(10, 10, 10, 0.2) !important; + backdrop-filter: blur(25px); + -webkit-backdrop-filter: blur(25px); } .search-input-row { @@ -1265,7 +1458,7 @@ html.dark .search-panel { } #search-results { - max-height: 60vh; + max-height: 40vh; overflow-y: auto; padding: 0 6px 8px; } @@ -1388,7 +1581,7 @@ html.dark .search-footer { #navbar .nav-top { padding-right: 20px; - padding-left: 0px; + padding-left: 15px; } #navbar .links { @@ -1403,6 +1596,17 @@ html.dark .search-footer { transform: none; width: auto; padding: 8px; + background: transparent; + } + + #search-trigger .ico { + font-weight: 900; + width: 1.53rem; + height: 1.3rem; + } + + html.dark #search-trigger { + background: transparent; } #search-trigger span, @@ -1419,18 +1623,45 @@ html.dark .search-footer { margin-top: 8px; } - body.nav-open #theme-seg { + #sidebar #theme-seg { display: flex; - background: rgba(25, 25, 25, 0.1); + align-items: center; + background: rgba(0, 0, 0, 0.05); border-radius: 50px; - padding-left: 5px; - padding-right: 5px; - padding-top: 1px; - padding-bottom: 1px; + padding: 2px 4px; + gap: 2px; } - #theme-seg button.on { - background: transparent; + html.dark #sidebar #theme-seg { + background: rgba(50, 50, 50, 0.08); + } + + #sidebar #theme-seg button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 50%; + background: none; + color: var(--muted-light); + cursor: pointer; + padding: 0; + } + + html.dark #sidebar #theme-seg button { + color: var(--muted-dark); + } + + #sidebar #theme-seg button.on { + color: var(--text-light); + font-weight: 900; + } + + html.dark #sidebar #theme-seg button.on { + color: var(--text-dark); + font-weight: 900; } #banner { @@ -1446,14 +1677,17 @@ html.dark .search-footer { position: fixed; left: 0; top: 0; + margin-top: 0 !important; width: 80%; max-width: 340px; - height: 100vh; - height: 100dvh; + height: 100vh !important; + height: 100dvh !important; + max-height: 100vh !important; padding: 0 16px 20px; background: var(--bg-light); transform: translateX(-100%); - transition: transform 0.2s ease; + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15); z-index: 200; } @@ -1465,8 +1699,36 @@ html.dark .search-footer { transform: translateX(0); } + #layout { + display: block; + width: 100%; + max-width: 100vw; + } + #content-col { - padding: 16px 20px 80px; + display: block; + width: 100%; + max-width: 100%; + padding: 16px 20px 20px; + gap: 0; + box-sizing: border-box; + overflow-x: hidden; + } + + #main-wrap { + width: 100%; + max-width: 100%; + } + + main.prose { + width: 100%; + max-width: 100%; + } + + footer#site-footer { + width: 100%; + max-width: 100%; + padding: 36px 0 40px; } aside#toc { @@ -1491,10 +1753,6 @@ html.dark .search-footer { } } -main.prose p { - margin: 20px 0; -} - main.prose p, main.prose li { line-height: 1.75; @@ -1511,7 +1769,7 @@ main.prose li { justify-content: space-between; height: 60px; margin: 0 -16px 12px; - padding: 0 20px; + padding: 0 16px; } #sidebar .side-head .brand { @@ -1529,6 +1787,158 @@ main.prose li { #sidebar .side-links { display: flex; align-items: center; - gap: 10px; + margin-right: 4px; + } +} + +.skeleton-wrap { + width: 100%; +} + +.skeleton-line, +.skeleton-media { + background: linear-gradient(90deg, rgba(0, 0, 0, 0.05) 25%, rgba(0, 0, 0, 0.12) 37%, rgba(0, 0, 0, 0.05) 63%); + background-size: 400% 100%; + animation: skeletonPulse 1.3s cubic-bezier(0.4, 0, 0.6, 1) infinite; + border-radius: 8px; +} + +html.dark .skeleton-line, +html.dark .skeleton-media { + background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 25%, rgba(255, 255, 255, 0.12) 37%, rgba(255, 255, 255, 0.05) 63%); + background-size: 400% 100%; +} + +@keyframes skeletonPulse { + 0% { + background-position: 100% 50%; + } + + 100% { + background-position: 0 50%; + } +} + +.skeleton-eyebrow { + width: 90px; + height: 16px; + margin-bottom: 12px; +} + +.skeleton-title { + width: 65%; + height: 38px; + margin-bottom: 18px; + border-radius: 10px; +} + +.skeleton-desc { + width: 85%; + height: 22px; + margin-bottom: 24px; +} + +.skeleton-media { + width: 100%; + height: 320px; + border-radius: 24px; + margin: 24px 0; +} + +.skeleton-text { + height: 16px; + margin: 12px 0; +} + +.w-100 { + width: 100%; +} + +.w-90 { + width: 90%; +} + +.w-85 { + width: 85%; +} + +.w-75 { + width: 75%; +} + +.content-animate>* { + animation: smoothBlurReveal 0.44s cubic-bezier(0.22, 1, 0.36, 1) both; + will-change: filter, opacity, transform; +} + +.content-animate>*:nth-child(1) { + animation-delay: 0ms; +} + +.content-animate>*:nth-child(2) { + animation-delay: 35ms; +} + +.content-animate>*:nth-child(3) { + animation-delay: 70ms; +} + +.content-animate>*:nth-child(4) { + animation-delay: 105ms; +} + +.content-animate>*:nth-child(5) { + animation-delay: 140ms; +} + +.content-animate>*:nth-child(6) { + animation-delay: 175ms; +} + +.content-animate>*:nth-child(7) { + animation-delay: 210ms; +} + +.content-animate>*:nth-child(8) { + animation-delay: 245ms; +} + +.content-animate>*:nth-child(9) { + animation-delay: 280ms; +} + +.content-animate>*:nth-child(n+10) { + animation-delay: 315ms; +} + +.content-animate.caught-up>* { + animation: none !important; + opacity: 1 !important; + filter: none !important; + transform: none !important; +} + +@keyframes smoothBlurReveal { + 0% { + opacity: 0; + filter: blur(8px); + transform: translateY(6px); + } + + 100% { + opacity: 1; + filter: blur(0px); + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .content-animate>* { + animation: none !important; + } + + .skeleton-line, + .skeleton-media { + animation: none !important; } } \ No newline at end of file diff --git a/assets/styling/highlight.css b/assets/styling/highlight.css new file mode 100644 index 0000000..002416b --- /dev/null +++ b/assets/styling/highlight.css @@ -0,0 +1,140 @@ +html.dark pre[class*="language-"], +html.dark code[class*="language-"] { + color: #abb2bf; +} + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: #6a737d; + font-style: italic; +} + +html.dark .token.comment, +html.dark .token.prolog, +html.dark .token.doctype, +html.dark .token.cdata { + color: #5c6370; + font-style: italic; +} + +.token.punctuation { + color: #24292e; +} + +html.dark .token.punctuation { + color: #abb2bf; +} + +.token.function, +.token.function-name { + color: #6f42c1; + font-weight: 500; +} + +html.dark .token.function, +html.dark .token.function-name { + color: #61afef; + font-weight: 500; +} + +.token.keyword, +.token.selector { + color: #d73a49; + font-weight: 600; +} + +html.dark .token.keyword, +html.dark .token.selector { + color: #c678dd; + font-weight: 600; +} + +.token.string, +.token.url { + color: #22863a; +} + +html.dark .token.string, +html.dark .token.url { + color: #98c379; +} + +.token.variable, +.token.attr-name, +.token.property { + color: #e36209; +} + +html.dark .token.variable, +html.dark .token.attr-name, +html.dark .token.property { + color: #e06c75; +} + +.token.operator, +.token.entity { + color: #d73a49; +} + +html.dark .token.operator, +html.dark .token.entity { + color: #56b6c2; +} + +.token.number, +.token.boolean, +.token.constant { + color: #005cc5; +} + +html.dark .token.number, +html.dark .token.boolean, +html.dark .token.constant { + color: #d19a66; +} + +.token.parameter { + color: #b08800; +} + +html.dark .token.parameter { + color: #d19a66; +} + +.token.class-name { + color: #6f42c1; +} + +html.dark .token.class-name { + color: #e5c07b; +} + +.token.tag { + color: #22863a; +} + +html.dark .token.tag { + color: #e06c75; +} + +.token.deleted { + color: #b31d28; + background-color: #ffeef0; +} + +html.dark .token.deleted { + color: #e06c75; + background-color: rgba(224, 108, 117, 0.15); +} + +.token.inserted { + color: #22863a; + background-color: #f0fff4; +} + +html.dark .token.inserted { + color: #98c379; + background-color: rgba(152, 195, 121, 0.15); +} \ No newline at end of file diff --git a/assets/styling/main.css b/assets/styling/main.css new file mode 100644 index 0000000..7b29adc --- /dev/null +++ b/assets/styling/main.css @@ -0,0 +1,3 @@ +@import "roots.css"; +@import "base.css"; +@import "highlight.css"; \ No newline at end of file diff --git a/assets/styling/roots.css b/assets/styling/roots.css new file mode 100644 index 0000000..bf59570 --- /dev/null +++ b/assets/styling/roots.css @@ -0,0 +1,60 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); + +:root { + --primary: #000000; + --bg-light: #ffffff; + --bg-dark: #000000; + --text-light: #0b0b0c; + --text-dark: #f3f3f3; + --muted-light: #6b7280; + --muted-dark: #a3a3a3; + --sidebar-width: 256px; + --toc-width: 240px; + --header-height: 148px; + --font: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + --border-light: #e5e7eb; + --border-dark: #2b2b2b98; +} + +body.banner-dismissed { + --header-height: 109px; +} + +* { + box-sizing: border-box; +} + +html { + height: 100%; +} + +body { + min-height: 100%; + margin: 0; + font-family: var(--font); + background: var(--bg-light); + color: var(--text-light); + line-height: 1.65; + overflow-x: hidden; +} + +html.dark body { + background: var(--bg-dark); + color: var(--text-dark); +} + +.video-bg { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: -1; +} + +a { + color: inherit; + text-decoration: none; +} \ No newline at end of file diff --git a/concepts/providers.md b/concepts/providers.md new file mode 100644 index 0000000..f162b13 --- /dev/null +++ b/concepts/providers.md @@ -0,0 +1,87 @@ +# Providers + +

      How Vyla SDK sources are configured, selected, and verified.

      + +Vyla SDK aggregates multiple independent providers. Each has different internal mechanisms, reliability profiles, and timeout characteristics. This page explains the source layer that powers both direct SDK use and the optional Stream API. + +:::callout-note +Provider availability and performance can vary based on your network environment. When self-hosting, providers may work differently than on residential connections due to IP-based blocking from upstream sources. +::: + +--- + +## How Providers Are Used + +When your app calls `getStream()`—directly or through Stream API: + +1. All active (non-disabled) providers are queried **simultaneously** with per-source random jitter to stagger upstream requests +2. Each provider has its own **timeout**, **retry count**, and **backoff delay** +3. Every returned URL is **verified** — first via a HEAD request to the raw upstream URL, then via a proxied HLS playability check +4. Only URLs that pass both verification steps appear in the response as `source` events + +Because the endpoint is SSE, **verified sources are emitted immediately as each provider resolves** — your player receives them progressively. The stream closes once every provider has either resolved or timed out. + +:::callout-info +Call `sdk.getSources(true)` to inspect enabled providers and `sdk.probeAllSources()` for a live availability signal. If you use Stream API, its `/health` endpoint exposes probes over HTTP. +::: + +--- + +## Provider Configuration + +Each provider is defined with the following properties: + +| Field | Description | +| --- | --- | +| `key` | Internal identifier used in API responses and the `source` field | +| `label` | Human-readable name shown in the `label` field | +| `proxyParam` | Short query parameter used to route proxied requests | +| `timeout` | Maximum time (ms) to wait for a stream URL | +| `sourcesTimeout` | Optional secondary timeout (ms) for an internal source-listing step | +| `jitter` | Max random delay (ms) before the request fires | +| `retries` | Number of retry attempts on failure | +| `skipProxy` | If `true`, stream URLs are returned as-is without proxy wrapping | +| `disabled` | If `true`, the provider is excluded from all requests | + +--- + +## Response Time + +The first `source` event typically arrives within **3–8 seconds**. Subsequent sources stream in as each provider resolves. The stream closes once the **slowest configured provider** times out. + +**Best practice:** always show a loading state, begin playback on the first `source` event, and implement a client-side timeout for the overall stream. See the [Error Handling](/guides/error-handling) guide. + +--- + +## Caching + +Results are cached in-memory with a **5-minute TTL**. The per-source stream cache is keyed by `source_key + tmdb_id + season + episode`. + +``` +CACHE_TTL = 5 minutes +``` + +--- + +## The HLS Proxy + +All provider stream URLs are wrapped in the server proxy (unless `skipProxy: true`): + +``` +http://localhost:7860/api?url=&=1 +``` + +When your player fetches this path from your self-hosted instance, the server: + +1. Fetches the upstream M3U8 with the correct `Referer`, `Origin`, and any provider-specific headers +2. Rewrites all segment URLs and encryption key URIs in the playlist to route through the same proxy +3. Returns the rewritten playlist with `Access-Control-Allow-Origin: *` + +--- + +## Stream Verification + +Before a URL is emitted as a `source` event, it passes two verification steps: + +1. **`verifyStream`** — a HEAD request to the raw upstream URL. Must return HTTP `< 400`. +2. **`verifyPlayable`** — fetches the proxied URL, validates `#EXTM3U`, checks for `#EXTINF` or `#EXT-X-STREAM-INF`, then fetches the first segment. \ No newline at end of file diff --git a/concepts/providers/index.html b/concepts/providers/index.html deleted file mode 100644 index fae5cc3..0000000 --- a/concepts/providers/index.html +++ /dev/null @@ -1,293 +0,0 @@ - - - - - - - Providers — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      Providers

      -

      How Vyla SDK sources are configured, selected, and verified.

      -

      Vyla SDK aggregates multiple independent providers. Each has different internal mechanisms, reliability - profiles, and timeout characteristics. This page explains the source layer that powers both direct SDK use and - the optional Stream API.

      -
      -
      -

      Provider availability and performance can vary based on your network environment. When self-hosting, - providers may work differently than on residential connections due to IP-based blocking from upstream - sources.

      -
      -
      -
      -

      How Providers Are Used

      -

      When your app calls getStream()—directly or through Stream API:

      -
        -
      1. All active (non-disabled) providers are queried simultaneously with per-source random - jitter to stagger upstream requests
      2. -
      3. Each provider has its own timeout, retry count, and backoff - delay
      4. -
      5. Every returned URL is verified — first via a HEAD request to the raw upstream URL, then - via a proxied HLS playability check
      6. -
      7. Only URLs that pass both verification steps appear in the response as source events
      8. -
      -

      Because the endpoint is SSE, verified sources are emitted immediately as each provider - resolves — your player receives them progressively. The stream closes once every provider has - either resolved or timed out.

      -
      -
      -

      Call sdk.getSources(true) to inspect enabled providers and - sdk.probeAllSources() for a live availability signal. If you use Stream API, its /health endpoint exposes probes over HTTP. -

      -
      -
      -
      -

      Provider Configuration

      -

      Each provider is defined with the following properties:

      - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      FieldDescription
      keyInternal identifier used in API responses and the source field
      labelHuman-readable name shown in the label field
      proxyParamShort query parameter used to route proxied requests
      timeoutMaximum time (ms) to wait for a stream URL
      sourcesTimeoutOptional secondary timeout (ms) for an internal source-listing step, used by some providers
      jitterMax random delay (ms) before the request fires, to stagger upstream hits
      retriesNumber of retry attempts on failure
      skipProxyIf true, stream URLs are returned as-is without proxy wrapping
      disabledIf true, the provider is excluded from all requests
      -

      Providers with disabled: true are completely skipped — they won't appear in health checks or - fanout responses.

      -
      -

      Response Time

      -

      The first source event typically arrives within 3–8 seconds. Subsequent sources - stream in as each provider resolves. The stream closes once the slowest configured provider - times out — in the worst case this can be 60+ seconds (e.g. cinezo has a 60 s timeout), though - most sources emit well before then.

      -

      Best practice: always show a loading state, begin playback on the first source - event, and implement a client-side timeout for the overall stream. See the Error Handling guide.

      -
      -

      Caching

      -

      Results are cached in-memory with a 5-minute TTL. The per-source stream cache is keyed by - source_key + tmdb_id + season + episode; the aggregate source-results cache is keyed by - tmdb_id + season + episode + base. Cache hits skip the provider fetch entirely and return - near-instantly. -

      -
      CACHE_TTL = 5 minutes
      -
      -

      The cache resets on server restart. Cache size is reported in the /api/health response under the - cache field. -

      -
      -

      The HLS Proxy

      -

      All provider stream URLs are wrapped in the server proxy (unless skipProxy: true):

      -
      http://localhost:7860/api?url=<encoded_stream_url>&<proxyParam>=1
      -
      -

      When your player fetches this path from your self-hosted instance, the server:

      -
        -
      1. Fetches the upstream M3U8 with the correct Referer, Origin, and any - provider-specific headers
      2. -
      3. Rewrites all segment URLs and encryption key URIs in the playlist to route through the same proxy
      4. -
      5. Returns the rewritten playlist with Access-Control-Allow-Origin: *
      6. -
      -

      This means your player never talks to the provider directly — all HLS traffic flows through - the proxy, keeping CORS and rate-limiting issues fully contained.

      -
      -

      Stream Verification

      -

      Before a URL is emitted as a source event, it passes two verification steps:

      -
        -
      1. verifyStream — a HEAD request to the raw upstream URL (skipped for - SKIP_VERIFY sources). Must return HTTP < 400. -
      2. -
      3. verifyPlayable — fetches the proxied URL, validates #EXTM3U, - checks for #EXTINF or #EXT-X-STREAM-INF, then fetches the first segment with a - range request.
      4. -
      -

      Sources that fail either step — HTTP errors, timeouts, non-HLS/non-MP4 content — are silently dropped. MP4 - sources skip the #EXTM3U check and are verified via HEAD + content-type inspection instead. HTTP - 429 responses and timeout/abort errors are treated as passing (stream exists but is - rate-limited).

      -
      -
      -

      /test runs the same two-step verification pipeline as /movie and - /tv. Check the ok field in the response body — HTTP status is always - 200 regardless of result. -

      -
      -
      -
      -

      Disabled Providers

      -

      Providers marked disabled: true in config are completely excluded from:

      -
        -
      • /api/movie and /api/tv fanout
      • -
      • /api/health probes
      • -
      • GET /api?sources_meta listings
      • -
      -

      If a provider you expect to see is missing from responses, check whether it's been disabled.

      -
      -

      Multi-URL Sources

      -

      Some providers return multiple candidate stream URLs (via allUrls). For these, verification runs - through each candidate in order and returns the first one that passes. If none pass, the source is dropped. -

      -

      For providers with SKIP_VERIFY, all candidate URLs are returned as separate source events - without verification. These are not distinguished by numbered labels in the response.

      -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/go/index.html b/go/index.html deleted file mode 100644 index ab50548..0000000 --- a/go/index.html +++ /dev/null @@ -1,152 +0,0 @@ - - - - - - - Vyla Go — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      Vyla Go

      -

      The Android control center for running the Vyla media stack locally.

      - A collage of films and shows available through a Vyla experience -

      Vyla Go is the Android application in the Vyla ecosystem. It prepares and runs the local - services that power your media experience, then opens the interface in a secure embedded window. It is - designed for people who want Vyla on their Android device without managing a separate server process or - service lifecycle by hand.

      -

      Downloads

      -

      Vyla Go is available for Android. Get the latest version below:

      - - - - - - - - - - - - - -
      PlatformDownload
      AndroidDownload .apk
      -

      Inside the releases page, you can find the latest version of Vyla Go for Android, find the .apk - file, and install it on your device.

      -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/guides/error-handling/index.html b/guides/error-handling/index.html deleted file mode 100644 index 896109b..0000000 --- a/guides/error-handling/index.html +++ /dev/null @@ -1,432 +0,0 @@ - - - - - - - Stream API error handling — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      Stream API error handling

      -

      Handle SSE failures, source fallbacks, and edge cases in an optional Stream API - client.

      -

      The /movie and /tv endpoints stream via SSE — error handling looks different from a - standard JSON API. This page covers every failure mode and the recommended pattern for each.

      -
      -
      -

      These patterns apply when you choose the optional Stream API boundary. SDK integrations should apply the - same fallback principles inside their own server-side request flow.

      -
      -
      -
      -

      Error Response Shape

      -

      Non-streaming endpoints (/health, /subtitles, /downloads, - /test) return standard JSON errors: -

      -
      { "error": "description of what went wrong" }
      -
      - - - - - - - - - - - - - - - - - - - - - -
      HTTP StatusWhen
      400Missing or invalid request parameters
      404No data found (subtitles, downloads)
      500Unexpected server error
      -

      For /movie and /tv, HTTP 200 is returned immediately when the SSE - stream opens. Failures surface as zero source events followed by a - done event with total: 0 — not as HTTP error codes. -

      -
      -

      Handling No Sources

      -

      The most common failure — all providers failed to return a playable stream. Detect it by checking - total in the done event. -

      -
      const BASE = 'http://localhost:7860';
      -
      -const res     = await fetch(`${BASE}/movie?id=550`);
      -const reader  = res.body!.getReader();
      -const decoder = new TextDecoder();
      -let buffer    = '';
      -let started   = false;
      -
      -while (true) {
      -  const { done, value } = await reader.read();
      -  if (done) break;
      -
      -  buffer += decoder.decode(value, { stream: true });
      -  const lines = buffer.split('\n');
      -  buffer = lines.pop()!;
      -
      -  for (const line of lines) {
      -    if (!line.startsWith('data: ')) continue;
      -    const event = JSON.parse(line.slice(6));
      -
      -    if (event.type === 'source') { started = true; playSource(event.source.url); }
      -
      -    if (event.type === 'done' && !started) {
      -      showError('This title is currently unavailable. Try again later.');
      -    }
      -  }
      -}
      -
      -
      -

      Source Fallback Queue

      -

      Sources stream in as providers resolve. Queue every source event URL and fall back to the next - one if playback fails mid-stream:

      -
      import Hls from 'hls.js';
      -
      -const BASE = 'http://localhost:7860';
      -
      -async function playWithFallback(
      -  videoEl: HTMLVideoElement,
      -  tmdbId: number,
      -  season?: number,
      -  episode?: number,
      -  onSourceChange?: (label: string) => void
      -) {
      -  const endpoint = season && episode
      -    ? `${BASE}/tv?id=${tmdbId}&season=${season}&episode=${episode}`
      -    : `${BASE}/movie?id=${tmdbId}`;
      -
      -  const queue: { url: string; label: string }[] = [];
      -  let started = false;
      -  let hls: Hls | null = null;
      -
      -  function tryNext() {
      -    if (!queue.length) {
      -      console.error('All sources exhausted');
      -      return;
      -    }
      -
      -    const { url, label } = queue.shift()!;
      -    onSourceChange?.(label);
      -
      -    hls?.destroy();
      -    hls = new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60 });
      -
      -    hls.on(Hls.Events.ERROR, (_, err) => {
      -      if (err.fatal) {
      -        console.warn(`Source "${label}" failed:`, err.type);
      -        tryNext();
      -      }
      -    });
      -
      -    hls.loadSource(url);
      -    hls.attachMedia(videoEl);
      -    hls.on(Hls.Events.MANIFEST_PARSED, () => videoEl.play());
      -  }
      -
      -  const res = await fetch(endpoint);
      -  const reader  = res.body!.getReader();
      -  const decoder = new TextDecoder();
      -  let buffer    = '';
      -
      -  while (true) {
      -    const { done, value } = await reader.read();
      -    if (done) break;
      -
      -    buffer += decoder.decode(value, { stream: true });
      -    const lines = buffer.split('\n');
      -    buffer = lines.pop()!;
      -
      -    for (const line of lines) {
      -      if (!line.startsWith('data: ')) continue;
      -      const event = JSON.parse(line.slice(6));
      -
      -      if (event.type === 'source') {
      -        queue.push({ url: event.source.url, label: event.source.label });
      -        if (!started) { started = true; tryNext(); }
      -      }
      -    }
      -  }
      -
      -  return () => hls?.destroy();
      -}
      -
      -
      -

      Network Timeouts

      -

      The SSE stream can stay open 20+ seconds while slow providers resolve. Set a client-side timeout using - AbortController: -

      -
      async function fetchStreamWithTimeout(
      -  url: string,
      -  timeoutMs = 20000
      -): Promise<ReadableStreamDefaultReader<Uint8Array>> {
      -  const controller = new AbortController();
      -  const timer = setTimeout(() => controller.abort(), timeoutMs);
      -
      -  try {
      -    const res = await fetch(url, {
      -      signal: controller.signal
      -    });
      -    clearTimeout(timer);
      -    return res.body!.getReader();
      -  } catch (err: any) {
      -    clearTimeout(timer);
      -    if (err.name === 'AbortError') throw new Error('Stream timed out');
      -    throw err;
      -  }
      -}
      -
      -
      -

      Handling Missing Subtitles

      -

      Subtitles arrive in the meta event. Always guard against an empty array:

      -
      if (event.type === 'meta') {
      -  if (!event.subtitles.length) {
      -    subtitleButton.style.display = 'none';
      -  } else {
      -    attachSubtitles(videoEl, event.subtitles);
      -  }
      -}
      -
      -
      -

      Retry Logic for Stream Failures

      -

      Add a top-level retry for complete stream failures (network errors, server 5xx before the stream opens):

      -
      async function fetchStreamWithRetry(
      -  url: string,
      -  attempts = 3,
      -  delayMs = 2000
      -): Promise<Response> {
      -  for (let i = 0; i < attempts; i++) {
      -    try {
      -      const res = await fetch(url);
      -      if (res.ok) return res;
      -      if (i === attempts - 1) throw new Error(`HTTP ${res.status}`);
      -    } catch (err) {
      -      if (i === attempts - 1) throw err;
      -      await new Promise(r => setTimeout(r, delayMs * (i + 1)));
      -    }
      -  }
      -  throw new Error('Max retries exceeded');
      -}
      -
      -
      -

      Checking Health Before Requests

      -

      If you're building a dashboard, check provider health before opening a stream to give users an early warning: -

      -
      async function checkAndStream(tmdbId: number) {
      -  const health = await fetch(
      -    'http://localhost:7860/api/health'
      -  ).then(r => r.json());
      -
      -  if (health.status === 'degraded') {
      -    const down = Object.entries(health.sources)
      -      .filter(([, v]: any) => !v.ok)
      -      .map(([k]) => k);
      -    console.warn(`Degraded providers: ${down.length}`);
      -  }
      -
      -  return fetch(
      -    `http://localhost:7860/movie?id=${tmdbId}`
      -  );
      -}
      -
      -
      -

      Full Error Boundary (React)

      -
      import { useState, useCallback, useRef } from 'react';
      -import Hls from 'hls.js';
      -
      -const BASE = 'http://localhost:7860';
      -
      -type PlayerState =
      -  | { status: 'idle' }
      -  | { status: 'loading' }
      -  | { status: 'playing'; sourceLabel: string }
      -  | { status: 'error'; message: string };
      -
      -export function useStreamPlayer(tmdbId: number) {
      -  const [state, setState] = useState<PlayerState>({ status: 'idle' });
      -  const hlsRef = useRef<Hls | null>(null);
      -
      -  const load = useCallback(async (videoEl: HTMLVideoElement) => {
      -    setState({ status: 'loading' });
      -
      -    const queue: { url: string; label: string }[] = [];
      -    let started = false;
      -
      -    function tryNext() {
      -      if (!queue.length) {
      -        setState({ status: 'error', message: 'All sources failed.' });
      -        return;
      -      }
      -
      -      const { url, label } = queue.shift()!;
      -      hlsRef.current?.destroy();
      -
      -      const hls = new Hls();
      -      hlsRef.current = hls;
      -
      -      hls.on(Hls.Events.MANIFEST_PARSED, () => {
      -        setState({ status: 'playing', sourceLabel: label });
      -        videoEl.play();
      -      });
      -      hls.on(Hls.Events.ERROR, (_, err) => {
      -        if (err.fatal) { hls.destroy(); tryNext(); }
      -      });
      -
      -      hls.loadSource(url);
      -      hls.attachMedia(videoEl);
      -    }
      -
      -    try {
      -      const res = await fetch(`${BASE}/movie?id=${tmdbId}`);
      -      const reader  = res.body!.getReader();
      -      const decoder = new TextDecoder();
      -      let buffer    = '';
      -
      -      while (true) {
      -        const { done, value } = await reader.read();
      -        if (done) break;
      -
      -        buffer += decoder.decode(value, { stream: true });
      -        const lines = buffer.split('\n');
      -        buffer = lines.pop()!;
      -
      -        for (const line of lines) {
      -          if (!line.startsWith('data: ')) continue;
      -          const event = JSON.parse(line.slice(6));
      -
      -          if (event.type === 'source') {
      -            queue.push({ url: event.source.url, label: event.source.label });
      -            if (!started) { started = true; tryNext(); }
      -          }
      -
      -          if (event.type === 'done' && !started) {
      -            setState({ status: 'error', message: 'No sources available for this title.' });
      -          }
      -        }
      -      }
      -    } catch {
      -      setState({ status: 'error', message: 'Network error. Check your connection.' });
      -    }
      -  }, [tmdbId]);
      -
      -  return { state, load };
      -}
      -
      -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/guides/integrations/index.html b/guides/integrations/index.html deleted file mode 100644 index c042cec..0000000 --- a/guides/integrations/index.html +++ /dev/null @@ -1,1253 +0,0 @@ - - - - - - - Stream API client integration — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      Stream API client integration

      -

      Build an optional Stream API player client with HLS/MP4 handling, SSE sources, - subtitles, and fallbacks.

      -

      Every source URL from /movie and /tv is either an HLS stream - (.m3u8) or an MP4 file — your player code must handle both. HLS goes through - HLS.js; MP4 sets video.src directly. The detection logic is the same everywhere.

      -
      -
      -

      These examples are for clients that use the optional self-hosted Stream API. If your product already has - a Node.js backend, prefer Vyla SDK directly and expose only the API your product - needs.

      -
      -
      -
      -

      Source Type Detection

      -

      This is the single utility everything below depends on. A URL is MP4 if the proxy path ends in - .mp4 or the upstream served a video content-type that isn't HLS. -

      -
      export function isMp4(url: string): boolean {
      -  try {
      -    const inner = new URL(url).searchParams.get('url') ?? url;
      -    return /\.mp4(\?|$)/i.test(inner) || /\.mkv(\?|$)/i.test(inner);
      -  } catch {
      -    return /\.mp4(\?|$)/i.test(url);
      -  }
      -}
      -
      -
      -

      Installation

      -
      -
      -
      -
      -
      npm install hls.js
      -
      -
      -
      yarn add hls.js
      -
      -
      -
      <script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
      -
      -
      -
      -
      -

      How the Stream Works

      -

      /movie and /tv are Server-Sent Events — not JSON. Each response is - a live stream of data: lines. Three event types arrive in sequence:

      - - - - - - - - - - - - - - - - - - - - - - - - - -
      EventWhenContains
      metaImmediately, before any provider resolvesTMDB metadata + all subtitle tracks
      sourceOnce per working provider, as each resolvesProxied URL (HLS or MP4) + provider label
      doneAfter all providers finish or time outTotal working source count
      -

      Start playback on the first source event — don't wait for done. - Queue every subsequent source as a fallback.

      -
      -

      Minimal HTML Player

      -

      Zero dependencies beyond HLS.js. Handles both HLS and MP4 correctly.

      -
      <!DOCTYPE html>
      -<html>
      -<head>
      -  <script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
      -</head>
      -<body>
      -  <video id="player" controls style="width: 100%; max-width: 900px;"></video>
      -  <div id="status">Loading…</div>
      -
      -  <script>
      -    const BASE   = 'http://localhost:7860';
      -    const video  = document.getElementById('player');
      -    const status = document.getElementById('status');
      -    let hlsInstance = null;
      -
      -    function isMp4(url) {
      -      try {
      -        const inner = new URL(url).searchParams.get('url') ?? url;
      -        return /\.(mp4|mkv)(\?|$)/i.test(inner);
      -      } catch { return /\.(mp4|mkv)(\?|$)/i.test(url); }
      -    }
      -
      -    function attachSource(url, label) {
      -      status.textContent = `Loading ${label}…`;
      -      hlsInstance?.destroy();
      -      hlsInstance = null;
      -
      -      if (isMp4(url)) {
      -        video.src = url;
      -        video.play().catch(() => {});
      -        status.textContent = `Playing ${label} (MP4)`;
      -        return;
      -      }
      -
      -      if (Hls.isSupported()) {
      -        hlsInstance = new Hls();
      -        hlsInstance.loadSource(url);
      -        hlsInstance.attachMedia(video);
      -        hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
      -          video.play().catch(() => {});
      -          status.textContent = `Playing ${label}`;
      -        });
      -        return;
      -      }
      -
      -      if (video.canPlayType('application/vnd.apple.mpegurl')) {
      -        video.src = url;
      -        video.play().catch(() => {});
      -        status.textContent = `Playing ${label}`;
      -        return;
      -      }
      -
      -      status.textContent = 'HLS not supported in this browser.';
      -    }
      -
      -    async function play(tmdbId) {
      -      const res = await fetch(`${BASE}/movie?id=${tmdbId}`);
      -      const reader  = res.body.getReader();
      -      const decoder = new TextDecoder();
      -      let buffer    = '';
      -      let started   = false;
      -
      -      while (true) {
      -        const { done, value } = await reader.read();
      -        if (done) break;
      -        buffer += decoder.decode(value, { stream: true });
      -        const lines = buffer.split('\n');
      -        buffer = lines.pop();
      -
      -        for (const line of lines) {
      -          if (!line.startsWith('data: ')) continue;
      -          const event = JSON.parse(line.slice(6));
      -
      -          if (event.type === 'source' && !started) {
      -            started = true;
      -            attachSource(event.source.url, event.source.label);
      -          }
      -          if (event.type === 'done' && !started) {
      -            status.textContent = 'No sources available.';
      -          }
      -        }
      -      }
      -    }
      -
      -    play(550);
      -  </script>
      -</body>
      -</html>
      -
      -
      - -

      Source switching, fallback queue, subtitle tracks, quality selector. All source types handled.

      -
      <!DOCTYPE html>
      -<html>
      -<head>
      -  <script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
      -  <style>
      -    * { box-sizing: border-box; }
      -    body      { font-family: sans-serif; background: #0f0f0f; color: #fff; padding: 1rem; margin: 0; }
      -    video     { width: 100%; max-width: 960px; display: block; background: #000; border-radius: 6px; }
      -    #controls { max-width: 960px; margin-top: 0.5rem; display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
      -    #sources  { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.4rem; max-width: 960px; }
      -    button    { padding: 0.35rem 0.75rem; background: #1e1e1e; color: #ccc; border: 1px solid #333;
      -                border-radius: 4px; cursor: pointer; font-size: 0.8rem; }
      -    button:hover   { background: #2a2a2a; }
      -    button.active  { background: #fff; color: #111; border-color: #fff; }
      -    select    { background: #1e1e1e; color: #ccc; border: 1px solid #333; border-radius: 4px;
      -                padding: 0.35rem 0.5rem; font-size: 0.8rem; }
      -    #status   { font-size: 0.8rem; color: #666; margin-top: 0.4rem; max-width: 960px; }
      -    .badge    { font-size: 0.65rem; background: #333; color: #aaa; padding: 1px 5px;
      -                border-radius: 3px; margin-left: 4px; vertical-align: middle; }
      -  </style>
      -</head>
      -<body>
      -  <video id="player" controls></video>
      -  <div id="controls">
      -    <select id="quality" style="display:none"><option value="-1">Quality: Auto</option></select>
      -  </div>
      -  <div id="sources"></div>
      -  <div id="status">Connecting…</div>
      -
      -  <script>
      -    const BASE      = 'http://localhost:7860';
      -    const video     = document.getElementById('player');
      -    const statusEl  = document.getElementById('status');
      -    const sourcesEl = document.getElementById('sources');
      -    const qualitySel = document.getElementById('quality');
      -
      -    let hlsInstance = null;
      -    let allSources  = [];
      -    let activeUrl   = null;
      -
      -    function isMp4(url) {
      -      try {
      -        const inner = new URL(url).searchParams.get('url') ?? url;
      -        return /\.(mp4|mkv)(\?|$)/i.test(inner);
      -      } catch { return /\.(mp4|mkv)(\?|$)/i.test(url); }
      -    }
      -
      -    function setActive(url) {
      -      activeUrl = url;
      -      sourcesEl.querySelectorAll('button').forEach(b => {
      -        b.classList.toggle('active', b.dataset.url === url);
      -      });
      -    }
      -
      -    function switchSource(url, label) {
      -      setActive(url);
      -      statusEl.textContent = `Loading ${label}…`;
      -      qualitySel.style.display = 'none';
      -      qualitySel.innerHTML = '<option value="-1">Quality: Auto</option>';
      -
      -      hlsInstance?.destroy();
      -      hlsInstance = null;
      -
      -      if (isMp4(url)) {
      -        video.src = url;
      -        video.play().catch(() => {});
      -        statusEl.textContent = `Playing ${label} · MP4`;
      -        return;
      -      }
      -
      -      if (Hls.isSupported()) {
      -        hlsInstance = new Hls({ startLevel: -1 });
      -        hlsInstance.loadSource(url);
      -        hlsInstance.attachMedia(video);
      -
      -        hlsInstance.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
      -          video.play().catch(() => {});
      -          statusEl.textContent = `Playing ${label} · HLS`;
      -
      -          if (data.levels.length > 1) {
      -            data.levels.forEach((level, i) => {
      -              const opt = document.createElement('option');
      -              opt.value = i;
      -              opt.textContent = level.height ? `${level.height}p` : `Level ${i}`;
      -              qualitySel.appendChild(opt);
      -            });
      -            qualitySel.style.display = '';
      -          }
      -        });
      -
      -        hlsInstance.on(Hls.Events.LEVEL_SWITCHED, (_, data) => {
      -          qualitySel.value = hlsInstance.autoLevelEnabled ? '-1' : data.level;
      -        });
      -
      -        hlsInstance.on(Hls.Events.ERROR, (_, err) => {
      -          if (err.fatal) {
      -            statusEl.textContent = `${label} failed — trying next…`;
      -            tryFallback(url);
      -          }
      -        });
      -        return;
      -      }
      -
      -      if (video.canPlayType('application/vnd.apple.mpegurl')) {
      -        video.src = url;
      -        video.play().catch(() => {});
      -        statusEl.textContent = `Playing ${label} · HLS`;
      -        return;
      -      }
      -
      -      statusEl.textContent = 'HLS not supported — try a different browser.';
      -    }
      -
      -    function tryFallback(failedUrl) {
      -      const next = allSources.find(s => s.url !== failedUrl && s.url !== activeUrl);
      -      if (next) switchSource(next.url, next.label);
      -      else statusEl.textContent = 'All sources failed.';
      -    }
      -
      -    function addSourceButton(source) {
      -      allSources.push(source);
      -      const { url, label } = source;
      -      const btn = document.createElement('button');
      -      btn.dataset.url = url;
      -      btn.innerHTML = label + (isMp4(url) ? '<span class="badge">MP4</span>' : '<span class="badge">HLS</span>');
      -      btn.onclick = () => switchSource(url, label);
      -      sourcesEl.appendChild(btn);
      -    }
      -
      -    qualitySel.onchange = () => {
      -      if (hlsInstance) hlsInstance.currentLevel = parseInt(qualitySel.value);
      -    };
      -
      -    async function loadContent(tmdbId, season, episode) {
      -      const url = (season && episode)
      -        ? `${BASE}/tv?id=${tmdbId}&season=${season}&episode=${episode}`
      -        : `${BASE}/movie?id=${tmdbId}`;
      -
      -      allSources = [];
      -      sourcesEl.innerHTML = '';
      -      statusEl.textContent = 'Connecting…';
      -      let started = false;
      -
      -      const res     = await fetch(url);
      -      const reader  = res.body.getReader();
      -      const decoder = new TextDecoder();
      -      let buffer    = '';
      -
      -      while (true) {
      -        const { done, value } = await reader.read();
      -        if (done) break;
      -        buffer += decoder.decode(value, { stream: true });
      -        const lines = buffer.split('\n');
      -        buffer = lines.pop();
      -
      -        for (const line of lines) {
      -          if (!line.startsWith('data: ')) continue;
      -          const event = JSON.parse(line.slice(6));
      -
      -          if (event.type === 'meta') {
      -            statusEl.textContent = `Loading ${event.meta?.title ?? event.meta?.name ?? ''}…`;
      -            Array.from(video.querySelectorAll('track')).forEach(t => t.remove());
      -            (event.subtitles ?? []).forEach((sub, i) => {
      -              const track = Object.assign(document.createElement('track'), {
      -                kind: 'subtitles', label: sub.label, src: sub.file,
      -              });
      -              if (i === 0) track.default = true;
      -              video.appendChild(track);
      -            });
      -          }
      -
      -          if (event.type === 'source') {
      -            addSourceButton(event.source);
      -            if (!started) {
      -              started = true;
      -              switchSource(event.source.url, event.source.label);
      -            }
      -          }
      -
      -          if (event.type === 'done' && !started) {
      -            statusEl.textContent = 'No sources available.';
      -          }
      -        }
      -      }
      -    }
      -
      -    loadContent(550);
      -  </script>
      -</body>
      -</html>
      -
      -
      -

      React

      -

      useStream Hook

      -

      Handles SSE parsing, abort on unmount, and re-fetch on param change. Drop-in for any framework.

      -
      import { useState, useEffect, useRef } from 'react';
      -
      -const BASE = 'http://localhost:7860';
      -
      -export interface StreamSource {
      -  source: string;
      -  label:  string;
      -  url:    string;
      -}
      -
      -export interface StreamSubtitle {
      -  label:  string;
      -  file:   string;
      -  type:   string;
      -  source: string;
      -}
      -
      -export interface StreamState {
      -  sources:   StreamSource[];
      -  subtitles: StreamSubtitle[];
      -  meta:      Record<string, unknown> | null;
      -  loading:   boolean;
      -  done:      boolean;
      -  error:     string | null;
      -}
      -
      -export function useStream(
      -  tmdbId:  number | null,
      -  season?: number | null,
      -  episode?: number | null,
      -): StreamState {
      -  const [state, setState] = useState<StreamState>({
      -    sources: [], subtitles: [], meta: null, loading: false, done: false, error: null,
      -  });
      -  const abortRef = useRef<AbortController | null>(null);
      -
      -  useEffect(() => {
      -    if (!tmdbId) return;
      -
      -    abortRef.current?.abort();
      -    const controller = new AbortController();
      -    abortRef.current = controller;
      -
      -    const url = season && episode
      -      ? `${BASE}/tv?id=${tmdbId}&season=${season}&episode=${episode}`
      -      : `${BASE}/movie?id=${tmdbId}`;
      -
      -    setState({ sources: [], subtitles: [], meta: null, loading: true, done: false, error: null });
      -
      -    (async () => {
      -      try {
      -        const res     = await fetch(url, {
      -          signal: controller.signal,
      -        });
      -        const reader  = res.body!.getReader();
      -        const decoder = new TextDecoder();
      -        let buffer    = '';
      -
      -        while (true) {
      -          const { done, value } = await reader.read();
      -          if (done) break;
      -          buffer += decoder.decode(value, { stream: true });
      -          const lines = buffer.split('\n');
      -          buffer = lines.pop()!;
      -
      -          for (const line of lines) {
      -            if (!line.startsWith('data: ')) continue;
      -            const event = JSON.parse(line.slice(6));
      -            if (event.type === 'meta')
      -              setState(s => ({ ...s, meta: event.meta ?? null, subtitles: event.subtitles ?? [] }));
      -            if (event.type === 'source')
      -              setState(s => ({ ...s, sources: [...s.sources, event.source] }));
      -            if (event.type === 'done')
      -              setState(s => ({ ...s, loading: false, done: true }));
      -          }
      -        }
      -      } catch (err: any) {
      -        if (err.name !== 'AbortError')
      -          setState(s => ({ ...s, loading: false, error: err.message }));
      -      }
      -    })();
      -
      -    return () => controller.abort();
      -  }, [tmdbId, season, episode]);
      -
      -  return state;
      -}
      -
      -

      attachSource Utility

      -

      Centralises HLS vs MP4 detection so every component shares one path.

      -
      import Hls from 'hls.js';
      -
      -export function isMp4(url: string): boolean {
      -  try {
      -    const inner = new URL(url).searchParams.get('url') ?? url;
      -    return /\.(mp4|mkv)(\?|$)/i.test(inner);
      -  } catch { return /\.(mp4|mkv)(\?|$)/i.test(url); }
      -}
      -
      -export interface AttachResult {
      -  hls: Hls | null;
      -  type: 'hls' | 'mp4' | 'native-hls';
      -}
      -
      -export function attachSource(
      -  videoEl:  HTMLVideoElement,
      -  url:      string,
      -  prevHls?: Hls | null,
      -  onLevels?: (levels: Hls.Level[]) => void,
      -  onError?:  () => void,
      -): AttachResult {
      -  prevHls?.destroy();
      -
      -  if (isMp4(url)) {
      -    videoEl.src = url;
      -    videoEl.play().catch(() => {});
      -    return { hls: null, type: 'mp4' };
      -  }
      -
      -  if (Hls.isSupported()) {
      -    const hls = new Hls({ startLevel: -1 });
      -    hls.loadSource(url);
      -    hls.attachMedia(videoEl);
      -    hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
      -      videoEl.play().catch(() => {});
      -      onLevels?.(data.levels);
      -    });
      -    hls.on(Hls.Events.ERROR, (_, err) => {
      -      if (err.fatal) onError?.();
      -    });
      -    return { hls, type: 'hls' };
      -  }
      -
      -  if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
      -    videoEl.src = url;
      -    videoEl.play().catch(() => {});
      -    return { hls: null, type: 'native-hls' };
      -  }
      -
      -  return { hls: null, type: 'mp4' };
      -}
      -
      -

      StreamPlayer Component

      -

      Full-featured player: auto-starts on first source, queues fallbacks, source switcher, quality selector, - subtitle tracks. Works for movies and TV.

      -
      'use client';
      -import { useEffect, useRef, useState } from 'react';
      -import Hls from 'hls.js';
      -import { useStream, StreamSource } from '@/hooks/useStream';
      -import { attachSource, isMp4 } from '@/lib/attachSource';
      -
      -interface Props {
      -  tmdbId:   number;
      -  season?:  number;
      -  episode?: number;
      -}
      -
      -export function StreamPlayer({ tmdbId, season, episode }: Props) {
      -  const videoRef    = useRef<HTMLVideoElement>(null);
      -  const hlsRef      = useRef<Hls | null>(null);
      -  const queueRef    = useRef<StreamSource[]>([]);
      -  const startedRef  = useRef(false);
      -  const activeRef   = useRef<string>('');
      -
      -  const { sources, subtitles, meta, loading, done, error } = useStream(tmdbId, season, episode);
      -  const [activeSource, setActiveSource] = useState<string>('');
      -  const [levels, setLevels]             = useState<Hls.Level[]>([]);
      -  const [quality, setQuality]           = useState(-1);
      -  const [status, setStatus]             = useState('Connecting…');
      -
      -  useEffect(() => {
      -    startedRef.current = false;
      -    queueRef.current   = [];
      -    activeRef.current  = '';
      -    setActiveSource('');
      -    setLevels([]);
      -    setQuality(-1);
      -    setStatus('Connecting…');
      -  }, [tmdbId, season, episode]);
      -
      -  function playSource(src: StreamSource) {
      -    if (!videoRef.current) return;
      -    activeRef.current = src.url;
      -    setActiveSource(src.source);
      -    setLevels([]);
      -    setQuality(-1);
      -    setStatus(`Loading ${src.label}…`);
      -
      -    const { hls } = attachSource(
      -      videoRef.current,
      -      src.url,
      -      hlsRef.current,
      -      (newLevels) => {
      -        setLevels(newLevels);
      -        setStatus(`Playing via ${src.label}${isMp4(src.url) ? ' · MP4' : ' · HLS'}`);
      -      },
      -      () => {
      -        const next = queueRef.current.shift();
      -        if (next) playSource(next);
      -        else setStatus('All sources failed.');
      -      },
      -    );
      -
      -    hlsRef.current = hls;
      -
      -    if (isMp4(src.url))
      -      setStatus(`Playing via ${src.label} · MP4`);
      -  }
      -
      -  useEffect(() => {
      -    if (!sources.length) return;
      -    const latest = sources[sources.length - 1];
      -    if (!startedRef.current) {
      -      startedRef.current = true;
      -      playSource(latest);
      -    } else {
      -      queueRef.current.push(latest);
      -    }
      -  }, [sources]);
      -
      -  useEffect(() => {
      -    if (hlsRef.current) hlsRef.current.currentLevel = quality;
      -  }, [quality]);
      -
      -  useEffect(() => () => { hlsRef.current?.destroy(); }, []);
      -
      -  if (error) return (
      -    <div style={{ padding: '1rem', color: '#f87171' }}>
      -      Stream error: {error}
      -    </div>
      -  );
      -
      -  return (
      -    <div style={{ width: '100%' }}>
      -      <video
      -        ref={videoRef}
      -        controls
      -        style={{ width: '100%', background: '#000', display: 'block', borderRadius: 6 }}
      -      >
      -        {subtitles.map(sub => (
      -          <track
      -            key={`${sub.label}-${sub.source}`}
      -            kind="subtitles"
      -            label={sub.label}
      -            src={sub.file}
      -            srcLang={sub.label.slice(0, 2).toLowerCase()}
      -          />
      -        ))}
      -      </video>
      -
      -      <div style={{ fontSize: '0.75rem', color: '#666', marginTop: 4 }}>
      -        {loading && !sources.length ? 'Connecting to providers…' : status}
      -        {done && ` · ${sources.length} source${sources.length !== 1 ? 's' : ''} found`}
      -      </div>
      -
      -      {(sources.length > 0 || levels.length > 1) && (
      -        <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.5rem', alignItems: 'center' }}>
      -          {levels.length > 1 && (
      -            <select
      -              value={quality}
      -              onChange={e => setQuality(Number(e.target.value))}
      -              style={{ fontSize: '0.8rem', padding: '0.3rem 0.5rem', borderRadius: 4,
      -                       background: '#1e1e1e', color: '#ccc', border: '1px solid #333' }}
      -            >
      -              <option value={-1}>Quality: Auto</option>
      -              {levels.map((l, i) => (
      -                <option key={i} value={i}>
      -                  {l.height ? `${l.height}p` : `Level ${i}`}
      -                </option>
      -              ))}
      -            </select>
      -          )}
      -
      -          {sources.length > 1 && sources.map(src => (
      -            <button
      -              key={src.source}
      -              onClick={() => playSource(src)}
      -              style={{
      -                fontSize: '0.75rem', padding: '0.3rem 0.65rem', borderRadius: 4, cursor: 'pointer',
      -                background: activeSource === src.source ? '#fff' : '#1e1e1e',
      -                color:      activeSource === src.source ? '#111' : '#aaa',
      -                border:     `1px solid ${activeSource === src.source ? '#fff' : '#333'}`,
      -              }}
      -            >
      -              {src.label}
      -              <span style={{ fontSize: '0.6rem', marginLeft: 4, opacity: 0.6 }}>
      -                {isMp4(src.url) ? 'MP4' : 'HLS'}
      -              </span>
      -            </button>
      -          ))}
      -        </div>
      -      )}
      -    </div>
      -  );
      -}
      -
      -

      Episode Selector

      -

      Renders an episode list and mounts the player only when an episode is selected — no streams open until the - user picks something.

      -
      'use client';
      -import { useState } from 'react';
      -import { StreamPlayer } from './StreamPlayer';
      -
      -interface Episode {
      -  episode_number: number;
      -  name: string;
      -}
      -
      -interface Props {
      -  seriesId: number;
      -  season:   number;
      -  episodes: Episode[];
      -}
      -
      -export function EpisodeSelector({ seriesId, season, episodes }: Props) {
      -  const [selected, setSelected] = useState<number | null>(null);
      -
      -  return (
      -    <div style={{ display: 'grid', gridTemplateColumns: '220px 1fr', gap: '1rem' }}>
      -      <aside style={{ overflowY: 'auto', maxHeight: 480, borderRight: '1px solid #222', paddingRight: '0.75rem' }}>
      -        {episodes.map(ep => (
      -          <button
      -            key={ep.episode_number}
      -            onClick={() => setSelected(ep.episode_number)}
      -            style={{
      -              display: 'block', width: '100%', textAlign: 'left',
      -              padding: '0.5rem 0.6rem', marginBottom: 2, borderRadius: 4, cursor: 'pointer',
      -              background: selected === ep.episode_number ? '#fff' : 'transparent',
      -              color:      selected === ep.episode_number ? '#111' : '#aaa',
      -              border: 'none', fontSize: '0.82rem',
      -            }}
      -          >
      -            <span style={{ opacity: 0.5, marginRight: 6 }}>E{ep.episode_number}</span>
      -            {ep.name}
      -          </button>
      -        ))}
      -      </aside>
      -
      -      <section>
      -        {selected === null
      -          ? <div style={{ color: '#555', paddingTop: '2rem' }}>Select an episode to watch</div>
      -          : <StreamPlayer tmdbId={seriesId} season={season} episode={selected} />
      -        }
      -      </section>
      -    </div>
      -  );
      -}
      -
      -
      -

      Next.js App Router

      -

      Movie Page

      -
      import { StreamPlayer } from '@/components/StreamPlayer';
      -
      -export default function MoviePage({ params }: { params: { id: string } }) {
      -  return (
      -    <main style={{ maxWidth: 960, margin: '0 auto', padding: '1.5rem 1rem' }}>
      -      <StreamPlayer tmdbId={Number(params.id)} />
      -    </main>
      -  );
      -}
      -
      -

      TV Episode Page

      -
      import { StreamPlayer } from '@/components/StreamPlayer';
      -
      -export default function EpisodePage({
      -  params,
      -}: {
      -  params: { id: string; season: string; episode: string };
      -}) {
      -  return (
      -    <main style={{ maxWidth: 960, margin: '0 auto', padding: '1.5rem 1rem' }}>
      -      <StreamPlayer
      -        tmdbId={Number(params.id)}
      -        season={Number(params.season)}
      -        episode={Number(params.episode)}
      -      />
      -    </main>
      -  );
      -}
      -
      -

      Dynamic HLS.js Import (SSR-safe)

      -

      HLS.js accesses window and will crash during SSR. Either mark the component - 'use client' (already done above) or use a dynamic import to exclude it from the server bundle - entirely: -

      -
      import dynamic from 'next/dynamic';
      -
      -const StreamPlayer = dynamic(
      -  () => import('@/components/StreamPlayer').then(m => m.StreamPlayer),
      -  { ssr: false, loading: () => <div style={{ aspectRatio: '16/9', background: '#111', borderRadius: 6 }} /> }
      -);
      -
      -
      -
      -

      Never import hls.js at the top level of a file that can run on the server. Always use - 'use client' or a dynamic import with ssr: false. -

      -
      -
      -
      -

      Vue 3

      -
      <template>
      -  <div>
      -    <video ref="videoEl" controls style="width:100%; background:#000; border-radius:6px; display:block">
      -      <track
      -        v-for="sub in subtitles"
      -        :key="sub.label + sub.source"
      -        kind="subtitles"
      -        :label="sub.label"
      -        :src="sub.file"
      -      />
      -    </video>
      -
      -    <div style="font-size:0.75rem; color:#666; margin-top:4px">{{ status }}</div>
      -
      -    <div v-if="sources.length > 1" style="display:flex; gap:0.4rem; flex-wrap:wrap; margin-top:0.5rem">
      -      <button
      -        v-for="src in sources"
      -        :key="src.source"
      -        @click="playSource(src)"
      -        :style="{
      -          fontSize: '0.75rem', padding: '0.3rem 0.65rem', borderRadius: '4px', cursor: 'pointer',
      -          background: activeSource === src.source ? '#fff' : '#1e1e1e',
      -          color:      activeSource === src.source ? '#111' : '#aaa',
      -          border:    `1px solid ${activeSource === src.source ? '#fff' : '#333'}`,
      -        }"
      -      >
      -        {{ src.label }}
      -        <span style="font-size:0.6rem; margin-left:4px; opacity:0.6">
      -          {{ isMp4(src.url) ? 'MP4' : 'HLS' }}
      -        </span>
      -      </button>
      -    </div>
      -  </div>
      -</template>
      -
      -<script setup lang="ts">
      -import { ref, watch, onUnmounted } from 'vue';
      -import Hls from 'hls.js';
      -
      -const props = defineProps<{
      -  tmdbId:   number;
      -  season?:  number;
      -  episode?: number;
      -}>();
      -
      -const BASE = 'https://api.vyla.cc';
      -
      -interface Source   { source: string; label: string; url: string; }
      -interface Subtitle { label: string; file: string; type: string; source: string; }
      -
      -const videoEl     = ref<HTMLVideoElement | null>(null);
      -const sources     = ref<Source[]>([]);
      -const subtitles   = ref<Subtitle[]>([]);
      -const status      = ref('Connecting…');
      -const activeSource = ref('');
      -
      -let hlsInstance: Hls | null = null;
      -let abortController: AbortController | null = null;
      -let started = false;
      -let queue: Source[] = [];
      -
      -function isMp4(url: string): boolean {
      -  try {
      -    const inner = new URL(url).searchParams.get('url') ?? url;
      -    return /\.(mp4|mkv)(\?|$)/i.test(inner);
      -  } catch { return /\.(mp4|mkv)(\?|$)/i.test(url); }
      -}
      -
      -function playSource(src: Source) {
      -  if (!videoEl.value) return;
      -  hlsInstance?.destroy();
      -  hlsInstance = null;
      -  activeSource.value = src.source;
      -
      -  if (isMp4(src.url)) {
      -    videoEl.value.src = src.url;
      -    videoEl.value.play().catch(() => {});
      -    status.value = `Playing ${src.label} · MP4`;
      -    return;
      -  }
      -
      -  if (Hls.isSupported()) {
      -    hlsInstance = new Hls({ startLevel: -1 });
      -    hlsInstance.loadSource(src.url);
      -    hlsInstance.attachMedia(videoEl.value);
      -    hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
      -      videoEl.value?.play().catch(() => {});
      -      status.value = `Playing ${src.label} · HLS`;
      -    });
      -    hlsInstance.on(Hls.Events.ERROR, (_, err) => {
      -      if (err.fatal) {
      -        const next = queue.shift();
      -        if (next) playSource(next);
      -        else status.value = 'All sources failed.';
      -      }
      -    });
      -    return;
      -  }
      -
      -  if (videoEl.value.canPlayType('application/vnd.apple.mpegurl')) {
      -    videoEl.value.src = src.url;
      -    videoEl.value.play().catch(() => {});
      -    status.value = `Playing ${src.label} · HLS`;
      -  }
      -}
      -
      -async function loadStream() {
      -  abortController?.abort();
      -  abortController = new AbortController();
      -  sources.value   = [];
      -  subtitles.value = [];
      -  status.value    = 'Connecting…';
      -  started         = false;
      -  queue           = [];
      -
      -  const url = props.season && props.episode
      -    ? `${BASE}/tv?id=${props.tmdbId}&season=${props.season}&episode=${props.episode}`
      -    : `${BASE}/movie?id=${props.tmdbId}`;
      -
      -  try {
      -    const res     = await fetch(url, {
      -      signal: abortController.signal,
      -    });
      -    const reader  = res.body!.getReader();
      -    const decoder = new TextDecoder();
      -    let buffer    = '';
      -
      -    while (true) {
      -      const { done, value } = await reader.read();
      -      if (done) break;
      -      buffer += decoder.decode(value, { stream: true });
      -      const lines = buffer.split('\n');
      -      buffer = lines.pop()!;
      -
      -      for (const line of lines) {
      -        if (!line.startsWith('data: ')) continue;
      -        const event = JSON.parse(line.slice(6));
      -
      -        if (event.type === 'meta') {
      -          subtitles.value = event.subtitles ?? [];
      -          status.value = `Loading ${event.meta?.title ?? event.meta?.name ?? ''}…`;
      -        }
      -        if (event.type === 'source') {
      -          sources.value = [...sources.value, event.source];
      -          if (!started) { started = true; playSource(event.source); }
      -          else queue.push(event.source);
      -        }
      -        if (event.type === 'done' && !started) {
      -          status.value = 'No sources available.';
      -        }
      -      }
      -    }
      -  } catch (err: any) {
      -    if (err.name !== 'AbortError') status.value = `Error: ${err.message}`;
      -  }
      -}
      -
      -watch(() => [props.tmdbId, props.season, props.episode], loadStream, { immediate: true });
      -onUnmounted(() => { hlsInstance?.destroy(); abortController?.abort(); });
      -</script>
      -
      -
      -

      Android (Kotlin + ExoPlayer)

      -

      ExoPlayer handles both HLS and MP4 natively — just pass the URL and it auto-detects the type.

      -
      import androidx.media3.common.MediaItem
      -import androidx.media3.exoplayer.ExoPlayer
      -import kotlinx.coroutines.*
      -import org.json.JSONObject
      -import java.io.BufferedReader
      -import java.io.InputStreamReader
      -import java.net.HttpURLConnection
      -import java.net.URL
      -
      -class StreamActivity : AppCompatActivity() {
      -
      -    private val BASE = "https://api.vyla.cc"
      -    private lateinit var player: ExoPlayer
      -    private val scope = CoroutineScope(Dispatchers.Main + Job())
      -
      -    override fun onCreate(savedInstanceState: Bundle?) {
      -        super.onCreate(savedInstanceState)
      -
      -        player = ExoPlayer.Builder(this).build()
      -        binding.playerView.player = player
      -
      -        val tmdbId = intent.getIntExtra("tmdb_id", 550)
      -        loadStream(tmdbId)
      -    }
      -
      -    private fun loadStream(tmdbId: Int, season: Int? = null, episode: Int? = null) {
      -        scope.launch {
      -            val streamUrl = if (season != null && episode != null)
      -                "$BASE/tv?id=$tmdbId&season=$season&episode=$episode"
      -            else
      -                "$BASE/movie?id=$tmdbId"
      -
      -            withContext(Dispatchers.IO) {
      -                val conn = URL(streamUrl).openConnection() as HttpURLConnection
      -                val reader = BufferedReader(InputStreamReader(conn.inputStream))
      -
      -                reader.forEachLine { line ->
      -                    if (!line.startsWith("data: ")) return@forEachLine
      -                    val json = JSONObject(line.substring(6))
      -
      -                    when (json.getString("type")) {
      -                        "source" -> {
      -                            val source = json.getJSONObject("source")
      -                            val url = source.getString("url")
      -
      -                            withContext(Dispatchers.Main) {
      -                                if (!player.isPlaying) {
      -                                    player.setMediaItem(MediaItem.fromUri(url))
      -                                    player.prepare()
      -                                    player.play()
      -                                }
      -                            }
      -                        }
      -                    }
      -                }
      -            }
      -        }
      -    }
      -
      -    override fun onDestroy() {
      -        super.onDestroy()
      -        scope.cancel()
      -        player.release()
      -    }
      -}
      -
      -
      -
      -

      ExoPlayer resolves HLS and MP4 automatically from the Content-Type returned by the proxy. No - manual type detection needed on Android.

      -
      -
      -
      -

      iOS (Swift + AVPlayer)

      -

      AVPlayer handles both HLS and MP4 natively. Same SSE parsing, different player attachment.

      -
      import UIKit
      -import AVKit
      -
      -class StreamViewController: UIViewController {
      -
      -    let BASE = "https://api.vyla.cc"
      -    var player: AVPlayer?
      -    var playerLayer: AVPlayerLayer?
      -    var streamTask: URLSessionDataTask?
      -    var started = false
      -
      -    override func viewDidLoad() {
      -        super.viewDidLoad()
      -        loadStream(tmdbId: 550)
      -    }
      -
      -    func loadStream(tmdbId: Int, season: Int? = nil, episode: Int? = nil) {
      -        let path: String
      -        if let s = season, let e = episode {
      -            path = "\(self.BASE)/tv?id=\(tmdbId)&season=\(s)&episode=\(e)"
      -        } else {
      -            path = "\(self.BASE)/movie?id=\(tmdbId)"
      -        }
      -
      -        guard let url = URL(string: path) else { return }
      -        var request = URLRequest(url: url)
      -
      -            class SSEDelegate: NSObject, URLSessionDataDelegate {
      -                var buffer = ""
      -                var onSource: (String) -> Void
      -
      -                init(_ onSource: @escaping (String) -> Void) { self.onSource = onSource }
      -
      -                func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
      -                    guard let text = String(data: data, encoding: .utf8) else { return }
      -                    buffer += text
      -                    while let range = buffer.range(of: "\n") {
      -                        let line = String(buffer[..<range.lowerBound])
      -                        buffer.removeSubrange(..<range.upperBound)
      -                        guard line.hasPrefix("data: "),
      -                              let jsonData = line.dropFirst(6).data(using: .utf8),
      -                              let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
      -                              let type = json["type"] as? String else { continue }
      -                        if type == "source",
      -                           let source = json["source"] as? [String: Any],
      -                           let streamUrl = source["url"] as? String {
      -                            onSource(streamUrl)
      -                        }
      -                    }
      -                }
      -            }
      -
      -            let delegate = SSEDelegate { [weak self] streamUrl in
      -                guard let self, !self.started else { return }
      -                self.started = true
      -                DispatchQueue.main.async {
      -                    guard let url = URL(string: streamUrl) else { return }
      -                    let player = AVPlayer(url: url)
      -                    self.player = player
      -                    let layer = AVPlayerLayer(player: player)
      -                    layer.frame = self.view.bounds
      -                    layer.videoGravity = .resizeAspect
      -                    self.view.layer.addSublayer(layer)
      -                    self.playerLayer = layer
      -                    player.play()
      -                }
      -            }
      -
      -            let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
      -            self.streamTask = session.dataTask(with: request)
      -            self.streamTask?.resume()
      -        }
      -    }
      -
      -    override func viewWillDisappear(_ animated: Bool) {
      -        super.viewWillDisappear(animated)
      -        streamTask?.cancel()
      -        player?.pause()
      -    }
      -}
      -
      -
      -
      -

      AVPlayer resolves HLS via application/vnd.apple.mpegurl and MP4 via video/mp4 - content-type returned by the proxy — no manual detection needed on iOS either.

      -
      -
      -
      -

      Python (mpv)

      -

      For desktop apps or scripts. mpv handles HLS and MP4 natively via its own demuxer.

      -
      import requests
      -import json
      -import subprocess
      -
      -BASE = 'https://api.vyla.cc'
      -
      -def stream(tmdb_id: int, season: int = None, episode: int = None):
      -    url = (
      -        f'{BASE}/tv?id={tmdb_id}&season={season}&episode={episode}'
      -        if season and episode else
      -        f'{BASE}/movie?id={tmdb_id}'
      -    )
      -
      -    with requests.get(url, stream=True) as res:
      -        for line in res.iter_lines():
      -            if not line or not line.startswith(b'data: '):
      -                continue
      -
      -            event = json.loads(line[6:])
      -
      -            if event['type'] == 'source':
      -                stream_url = event['source']['url']
      -                label      = event['source']['label']
      -                print(f'Playing via {label}: {stream_url}')
      -                subprocess.run(['mpv', stream_url])
      -                return
      -
      -            if event['type'] == 'done':
      -                print('No sources available.')
      -
      -stream(550)
      -
      -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/home/index.html b/home/index.html deleted file mode 100644 index d58864b..0000000 --- a/home/index.html +++ /dev/null @@ -1,202 +0,0 @@ - - - - - - - Vyla Home — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      Vyla Home

      -

      The desktop control center for running the Vyla media stack locally.

      - Vyla Home desktop application preview -

      Vyla Home

      -

      Vyla Home is the desktop application in the Vyla ecosystem. It prepares and runs the - local services that power your media experience, then opens the interface in a secure embedded - window. It is designed for people who want Vyla without managing a separate server process, reverse - proxy, or service lifecycle by hand.

      -

      Downloads

      -

      Vyla Home is available for Windows, macOS, and Linux. Get the latest version below:

      - - - - - - - - - - - - - - - - - - - - - -
      PlatformDownload
      WindowsDownload .exe
      macOSDownload .dmg
      LinuxBuild from source
      -

      Inside the releases page, you can find the latest version of Vyla Home for each platform, find the - .exe for windows, and .dmg for macOS. -

      -

      Linux Setup

      -

      For Linux, you'll need to build Vyla Home from source. Follow these steps to install on Fedora:

      -

      Step 1: Install dependencies

      -
      sudo dnf install webkit2gtk4.1-devel curl wget openssl-devel libappindicator-gtk3-devel librsvg2-devel
      -sudo dnf install gcc gcc-c++ make automake autoconf
      -
      -

      Step 2: Install Node.js and NPM

      -
      sudo dnf install nodejs npm
      -
      -

      Step 3: Install Rust

      -
      curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
      -source "$HOME/.cargo/env"
      -
      -

      Step 4: Clone the Git repository

      -
      git clone https://gitlab.com/vyla-entertainment/home.git vyla-home
      -cd vyla-home
      -
      -

      Step 5: Install more dependencies

      -
      npm install
      -
      -

      Step 6: Build the Fedora package (.rpm)

      -
      cargo update --manifest-path src-tauri/Cargo.toml
      -npx tauri build --bundles rpm,appimage
      -
      -

      Step 7: Final installation

      -
      sudo dnf install src-tauri/target/release/bundle/rpm/*.rpm
      -
      -

      Thank you @krishi279 for supplying the Linux setup instructions on Discord ❤️.

      -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/index.html b/index.html index a9a6385..da6ebf9 100644 --- a/index.html +++ b/index.html @@ -9,53 +9,12 @@ content="A community-built media project focused on freedom, privacy, and giving people control over their own experience." /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + +
      + +
      +
      +
      +
      + +
      + +
      +
      + \ No newline at end of file diff --git a/llms.txt b/llms.txt index b338ece..b26c054 100644 --- a/llms.txt +++ b/llms.txt @@ -1,77 +1,43 @@ -# Vyla Documentation +--- START OF FILE llms.txt --- +# Vyla Entertainment -Vyla is a community-built media project focused on freedom, privacy, and giving people control over their own experience. +> Vyla is a community-built, self-hosted media platform and developer ecosystem focused on user freedom, privacy, and decentralized control. It provides native client applications (Home, Go, TV), a Node.js scraper SDK, and an optional HTTP/SSE Stream API proxy. -## Project Overview +## Overview & Architecture -Vyla is not a streaming service and does not host or provide media. Instead, Vyla builds software that brings the media experience together while leaving control in the hands of the person using it. The project consists of: +- [Introduction](https://vyla.cc/): Community ethos, mission, platforms, and getting started overview. +- [Our Architecture](https://vyla.cc/start-here/architecture): System decoupling between local runtimes, provider resolution, and playback delivery. -- **Vyla Home**: Desktop application for Windows, macOS, and Linux -- **Vyla Go**: Android mobile application -- **Vyla TV**: Android TV application -- **Vyla SDK**: Node.js/TypeScript SDK for developers -- **Stream API**: Self-hosted HTTP/SSE API for browser and mobile clients +## Products -## Key Features +- [Vyla Home](https://vyla.cc/products/home): Desktop control center and application runtime for Windows, macOS, and Linux. +- [Vyla Go](https://vyla.cc/products/go): Android mobile client managing local service lifecycles and embedded playback. +- [Vyla TV](https://vyla.cc/products/tv): Android TV Leanback streaming client built with Jetpack Compose and ExoPlayer. +- [Build a Player](https://vyla.cc/products/player): Architectural guidelines for building resilient client media players. -- Self-hosted and privacy-focused -- Open source community project -- Provider-based streaming resolution -- Multi-platform support (desktop, Android, Android TV) -- Developer-friendly SDK and API -- Support for HLS and MP4 streaming -- Built-in health monitoring and fallback systems +## Core Concepts -## Documentation Structure +- [Providers](https://vyla.cc/concepts/providers): Source configurations, jitter, timeout policies, HLS proxy mechanics, and verification pipelines. -- `/` - Introduction to Vyla -- `/architecture` - System architecture and component relationships -- `/home` - Vyla Home desktop application -- `/go` - Vyla Go Android application -- `/tv` - Vyla TV Android TV application -- `/player` - Building custom media players -- `/sdk` - SDK reference documentation -- `/sdk-quickstart` - Quick start guide for SDK -- `/stream-api/overview` - Stream API overview -- `/stream-api/paths` - API paths and SSE events -- `/stream-api/media-data` - Subtitles and downloads API -- `/stream-api/operations` - Health, testing, and operations -- `/self-hosting` - Self-hosting deployment guide -- `/concepts/providers` - Provider configuration and verification -- `/guides/integrations` - Client integration guide -- `/guides/error-handling` - Error handling and fallbacks -- `/misc/credit` - Credits and acknowledgments -- `/misc/dmca` - DMCA and copyright policy +## Developer SDK -## Technical Stack +- [SDK Reference](https://vyla.cc/sdk): Node.js package (@vyla-entertainment/sdk) for direct stream resolution, subtitles, downloads, and health probing. +- [SDK Quickstart](https://vyla.cc/sdk/vyla-sdk/quickstart): Rapid installation, provider selection, and server-side integration guide. -- Node.js 18+ for SDK and Stream API -- TypeScript for SDK -- Server-Sent Events (SSE) for streaming -- HLS.js for web playback -- TMDB API for metadata -- Jetpack Compose for Android TV +## Stream API -## Integration Options +- [Stream API Overview](https://vyla.cc/stream-api/overview): Self-hosted HTTP and SSE progressive playback service. +- [Paths & Playback Events](https://vyla.cc/stream-api/reference/paths): SSE event contract for /movie and /tv routes emitting meta, source, and done events. +- [Subtitles & Downloads](https://vyla.cc/stream-api/reference/media-data): REST endpoints for subtitle track extraction and direct media downloads. +- [Operations & Health](https://vyla.cc/stream-api/reference/operations): /health metrics, /test probes, and safe production troubleshooting. +- [Self-Hosting Guide](https://vyla.cc/stream-api/deployment/self-hosting): Node.js deployment, cluster scaling, environment variables, and reverse proxy setup. -1. **Direct SDK**: For Node.js applications - embed provider resolution directly -2. **Stream API**: For browser/mobile clients - HTTP/SSE interface -3. **Vyla Home**: Managed desktop experience with bundled services +## Guides & Integrations -## Important Notes +- [Client Integration](https://vyla.cc/guides/integrations): Frontend implementation patterns using HLS.js, React, Vue, ExoPlayer, and AVPlayer. +- [Error Handling](https://vyla.cc/guides/error-handling): Handling SSE timeouts, fallback queues, subtitle absence, and retry strategies. -- Vyla is self-hosted only - no public hosted instance -- Requires TMDB API key for metadata-dependent sources -- Provider availability varies by network environment -- Supports fallback strategies for resilient playback -- CORS-enabled by default -- No database required for Stream API +## Policies & Attribution -## Community - -- GitLab: https://gitlab.com/vyla-entertainment/ -- Discord: https://discord.gg/5eqRhKeywG - -## Legal - -Vyla does not host or distribute media. Rights holders can request source removal via the DMCA policy. The project is intended for personal, educational, and development use. Users are responsible for complying with applicable laws. +- [Credit](https://vyla.cc/misc/project/credit): Acknowledgments for upstream open-source inspiration including P-Stream, CinePro, and Movieslay. +- [DMCA Policy](https://vyla.cc/misc/project/dmca): DMCA takedown procedures, upstream content aggregation disclosures, and contact points. \ No newline at end of file diff --git a/manifest.webmanifest b/manifest.webmanifest new file mode 100644 index 0000000..d80b0e2 --- /dev/null +++ b/manifest.webmanifest @@ -0,0 +1,30 @@ +{ + "name": "Vyla Entertainment", + "short_name": "Vyla", + "description": "A community-built media project focused on freedom, privacy, and control.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#000000", + "orientation": "any", + "categories": [ + "entertainment", + "multimedia", + "utilities" + ], + "icons": [ + { + "src": "/assets/images/logo.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/assets/images/logo.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} \ No newline at end of file diff --git a/misc/credit/index.html b/misc/credit/index.html deleted file mode 100644 index 4fd8c9f..0000000 --- a/misc/credit/index.html +++ /dev/null @@ -1,167 +0,0 @@ - - - - - - - Credit — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      Credit

      -

      Credits and inspiration behind the Vyla API ecosystem.

      -

      This page highlights the open-source projects and developers that heavily inspired the Vyla API, player - ecosystem, and overall UI direction.

      -
      -

      Player & UI Inspiration

      -

      The overall player experience, streaming layout, and much of the UI direction for Vyla was heavily inspired - by P-Stream.

      - -

      Their work served as a major reference point for the design language and viewing experience used throughout - Vyla.

      -
      -

      Stream API Inspiration

      -

      The architecture and implementation ideas behind the Vyla streaming API were heavily inspired by CinePro.

      -

      Resources

      - -

      CinePro's ecosystem played a large role in shaping how Vyla handles providers, source structures, streaming - workflows, and API organization.

      -
      -

      Source Implementation & Fixes

      -

      The owner of Movieslay helped fix and implement new sources across the Vyla API.

      - -

      Movieslay Emerald is a successor to the original Movieslay webapp. gavingogaming's direct contributions were - instrumental in expanding and stabilizing Vyla's source coverage.

      -
      -

      Open Source Appreciation

      -

      Vyla would not exist in its current form without the incredible open-source work created by these developers - and communities.

      -

      Massive thanks to:

      -
        -
      • Pasithea0
      • -
      • An0n-00
      • -
      • gavingogaming (Movieslay)
      • -
      • The P-Stream contributors
      • -
      • The CinePro contributors
      • -
      • The Movieslay contributors
      • -
      -

      for building and sharing their projects publicly.

      -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/misc/dmca/index.html b/misc/dmca/index.html deleted file mode 100644 index a21f137..0000000 --- a/misc/dmca/index.html +++ /dev/null @@ -1,182 +0,0 @@ - - - - - - - DMCA & Copyright — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      DMCA & Copyright

      -

      Content sources used by Vyla API and how rights holders can request removal.

      -

      Vyla API is a self-hosted streaming proxy that aggregates publicly accessible stream URLs from third-party - providers. Vyla does not host, store, upload, or distribute any video content. All media is served directly - from its original source.

      -
      -

      How It Works

      -

      Vyla acts as a reverse proxy and aggregator. When a stream is requested:

      -
        -
      1. Vyla queries third-party providers for a stream URL.
      2. -
      3. That URL is proxied through the Vyla server to handle CORS headers.
      4. -
      5. The underlying video segments are fetched from their original CDN and passed through to the client.
      6. -
      -

      At no point does Vyla store, transcode, or re-host any media file.

      -
      -

      Content Sources

      -

      The following third-party sources are currently scraped or proxied by Vyla API. These are the upstream - providers — Vyla does not control, operate, or own any of them.

      -

      You can find the full list of sources in the stream-api sources directory. -

      -
      -
      -

      This list reflects active sources. These sources are active or in the works.

      -
      -
      -
      -

      DMCA / Takedown Requests

      -

      If you are a rights holder and believe that content accessible through Vyla API infringes your copyright, you - may submit a takedown request. Because Vyla does not host the content itself, the most effective path is to - contact the upstream source directly.

      -

      However, Vyla can disable specific providers or sources upon valid request.

      -

      To submit a takedown request, contact:

      -
      -
      DMCA Contact
      -
      -

      Open an issue on the GitLab repository or reach out via Discord or email.

      - -

      Please include the following in your request:

      -
        -
      • Your name and organization
      • -
      • The specific content or provider you are requesting removal of
      • -
      • Evidence of your rights to the content
      • -
      • Your contact information
      • -
      -
      -
      -
      -

      Good Faith Compliance

      -

      Vyla API is operated in good faith. Upon receiving a valid and complete takedown request, the relevant source - will be reviewed and disabled within a reasonable timeframe if the claim is substantiated.

      -

      Rights holders such as Sony Pictures, Universal, Warner Bros., Disney, Netflix, or any other studio or - distributor are welcome to contact via the channels above. We will respond promptly.

      -
      -

      Disclaimer

      -

      Vyla API is intended for personal, educational, and development use only. The operator of this API does not - endorse or encourage copyright infringement. Users of this API are responsible for complying with the laws of - their jurisdiction.

      -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/misc/project/DMCA.md b/misc/project/DMCA.md new file mode 100644 index 0000000..eb6a9f8 --- /dev/null +++ b/misc/project/DMCA.md @@ -0,0 +1,59 @@ +# DMCA & Copyright + +

      Content sources used by Vyla API and how rights holders can request removal.

      + +Vyla API is a self-hosted streaming proxy that aggregates publicly accessible stream URLs from third-party providers. Vyla does not host, store, upload, or distribute any video content. All media is served directly from its original source. + +--- + +## How It Works + +Vyla acts as a **reverse proxy and aggregator**. When a stream is requested: + +1. Vyla queries third-party providers for a stream URL. +2. That URL is proxied through the Vyla server to handle CORS headers. +3. The underlying video segments are fetched from their original CDN and passed through to the client. + +At no point does Vyla store, transcode, or re-host any media file. + +--- + +## Content Sources + +The following third-party sources are scraped or proxied by Vyla API. These are upstream providers — Vyla does not control, operate, or own any of them. + +You can find the full list of sources in the [stream-api sources directory](https://gitlab.com/vyla-entertainment/sdk/-/tree/main/src/sources). + +--- + +## DMCA / Takedown Requests + +If you are a rights holder and believe that content accessible through Vyla API infringes your copyright, you may submit a takedown request. Because Vyla does not host the content itself, the most effective path is to contact the upstream source directly. + +However, Vyla can disable specific providers or sources upon valid request. + +**To submit a takedown request, contact:** + +
      +
      DMCA Contact
      +
      +

      Open an issue on the GitLab repository or reach out via Discord or email.

      + +
      +
      + +--- + +## Good Faith Compliance + +Vyla API is operated in good faith. Upon receiving a valid and complete takedown request, the relevant source will be reviewed and disabled within a reasonable timeframe if the claim is substantiated. + +--- + +## Disclaimer + +Vyla API is intended for personal, educational, and development use only. The operator of this API does not endorse or encourage copyright infringement. Users of this API are responsible for complying with the laws of their jurisdiction. \ No newline at end of file diff --git a/misc/project/credit.md b/misc/project/credit.md new file mode 100644 index 0000000..4bc7e09 --- /dev/null +++ b/misc/project/credit.md @@ -0,0 +1,55 @@ +# Credit + +

      Credits and inspiration behind the Vyla API ecosystem.

      + +This page highlights the open-source projects and developers that heavily inspired the Vyla API, player ecosystem, and overall UI direction. + +--- + +## Player & UI Inspiration + +The overall player experience, streaming layout, and much of the UI direction for Vyla was heavily inspired by P-Stream. + +- Repository: [https://github.com/p-stream](https://github.com/p-stream) +- Owner: [https://github.com/Pasithea0](https://github.com/Pasithea0) + +Their work served as a major reference point for the design language and viewing experience used throughout Vyla. + +--- + +## Stream API Inspiration + +The architecture and implementation ideas behind the Vyla streaming API were heavily inspired by CinePro. + +### Resources + +- Organization: [https://github.com/cinepro-org/](https://github.com/cinepro-org/) +- Main Website: [https://cinepro.cc/](https://cinepro.cc/) +- Documentation: [https://docs.cinepro.cc/](https://docs.cinepro.cc/) +- UI Components: [https://ui.cinepro.cc/](https://ui.cinepro.cc/) +- Owner: [https://github.com/An0n-00](https://github.com/An0n-00) + +--- + +## Source Implementation & Fixes + +The owner of Movieslay helped fix and implement new sources across the Vyla API. + +- Repository: [gitlab.com/gavingogaming/movieslay-emerald-temp](https://gitlab.com/gavingogaming/movieslay-emerald-temp) +- Discord: [https://discord.gg/rD75qxGBVG](https://discord.gg/rD75qxGBVG) +- Owner: [https://gitlab.com/gavingogaming](https://gitlab.com/gavingogaming) + +--- + +## Open Source Appreciation + +Vyla would not exist in its current form without the incredible open-source work created by these developers and communities. + +Massive thanks to: + +- Pasithea0 +- An0n-00 +- gavingogaming (Movieslay) +- The P-Stream contributors +- The CinePro contributors +- The Movieslay contributors \ No newline at end of file diff --git a/player/index.html b/player/index.html deleted file mode 100644 index 906eb3f..0000000 --- a/player/index.html +++ /dev/null @@ -1,184 +0,0 @@ - - - - - - - Build a player — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      Build a player

      -

      Design a resilient Vyla playback experience around your own product and - infrastructure.

      -

      Your player, your rules

      -

      Vyla supplies the integration layer; your application owns the player experience. That includes design, - authentication, title discovery, parental controls, analytics, playback policy, and fallback behavior.

      -

      For a local managed experience, Vyla Home starts its bundled player service. For a custom product, choose one - of two boundaries:

      - - - - - - - - - - - - - - - - - -
      IntegrationBest for
      SDK directlyNode.js products that already have a backend and want complete control
      Stream APIbrowser or mobile clients that need an HTTP/SSE source feed
      -

      Build for changing conditions

      -

      Provider results vary by title and network. A dependable player should:

      -
        -
      1. Begin playback as soon as it has a usable candidate.
      2. -
      3. Keep later candidates as fallbacks.
      4. -
      5. Keep subtitles independent from first playback.
      6. -
      7. Report a helpful unavailable state when no candidates resolve.
      8. -
      9. Destroy playback and abort in-flight work when the viewer changes titles.
      10. -
      -

      The SDK returns provider results directly; Stream API emits meta, source, and - done events. Both models benefit from the same fallback strategy. -

      -

      Playback formats

      -

      Candidates may be HLS manifests or direct media files. Let the result metadata and your player capabilities - guide how a candidate is loaded; do not assume every URL has the same format or availability. HLS-capable web - players normally use HLS.js where native HLS is unavailable, while direct media can be assigned to a video - element.

      -

      Continue building

      - -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/products/go.md b/products/go.md new file mode 100644 index 0000000..b1b4bc5 --- /dev/null +++ b/products/go.md @@ -0,0 +1,17 @@ +# Vyla Go + +

      The Android control center for running the Vyla media stack locally.

      + +A collage of films and shows available through a Vyla experience + +**Vyla Go** is the Android application in the Vyla ecosystem. It prepares and runs the local services that power your media experience, then opens the interface in a secure embedded window. It is designed for people who want Vyla on their Android device without managing a separate server process or service lifecycle by hand. + +## Downloads + +Vyla Go is available for Android. Get the latest version below: + +| Platform | Download | +| --- | --- | +| Android | [Download .apk](https://gitlab.com/vyla-entertainment/go/-/releases) | + +Inside the releases page, you can find the latest version of Vyla Go for Android, find the `.apk` file, and install it on your device. \ No newline at end of file diff --git a/products/home.md b/products/home.md new file mode 100644 index 0000000..96fcd00 --- /dev/null +++ b/products/home.md @@ -0,0 +1,73 @@ +# Vyla Home + +

      The desktop control center for running the Vyla media stack locally.

      + +Vyla Home desktop application preview + +## Vyla Home + +**Vyla Home** is the desktop application in the Vyla ecosystem. It prepares and runs the local services that power your media experience, then opens the interface in a secure embedded window. It is designed for people who want Vyla without managing a separate server process, reverse proxy, or service lifecycle by hand. + +## Downloads + +Vyla Home is available for Windows, macOS, and Linux. Get the latest version below: + +| Platform | Download | +| --- | --- | +| Windows | [Download .exe](https://gitlab.com/vyla-entertainment/home/-/releases) | +| macOS | [Download .dmg](https://gitlab.com/vyla-entertainment/home/-/releases) | +| Linux | [Build from source](#linux-setup) | + +Inside the releases page, you can find the latest version of Vyla Home for each platform, find the `.exe` for windows, and `.dmg` for macOS. + +## Linux Setup + +For Linux, you'll need to build Vyla Home from source. Follow these steps to install on Fedora: + +**Step 1: Install dependencies** + +```bash +sudo dnf install webkit2gtk4.1-devel curl wget openssl-devel libappindicator-gtk3-devel librsvg2-devel +sudo dnf install gcc gcc-c++ make automake autoconf +``` + +**Step 2: Install Node.js and NPM** + +```bash +sudo dnf install nodejs npm +``` + +**Step 3: Install Rust** + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source "$HOME/.cargo/env" +``` + +**Step 4: Clone the Git repository** + +```bash +git clone https://gitlab.com/vyla-entertainment/home.git vyla-home +cd vyla-home +``` + +**Step 5: Install more dependencies** + +```bash +npm install +``` + +**Step 6: Build the Fedora package (.rpm)** + +```bash +cargo update --manifest-path src-tauri/Cargo.toml +npx tauri build --bundles rpm,appimage +``` + +**Step 7: Final installation** + +```bash +sudo dnf install src-tauri/target/release/bundle/rpm/*.rpm +``` + +*Thank you @krishi279 for supplying the Linux setup instructions on Discord ❤️.* \ No newline at end of file diff --git a/products/player.md b/products/player.md new file mode 100644 index 0000000..739d975 --- /dev/null +++ b/products/player.md @@ -0,0 +1,37 @@ +# Build a player + +

      Design a resilient Vyla playback experience around your own product and infrastructure.

      + +## Your player, your rules + +Vyla supplies the integration layer; your application owns the player experience. That includes design, authentication, title discovery, parental controls, analytics, playback policy, and fallback behavior. + +For a local managed experience, Vyla Home starts its bundled player service. For a custom product, choose one of two boundaries: + +| Integration | Best for | +| --- | --- | +| **SDK directly** | Node.js products that already have a backend and want complete control | +| **Stream API** | browser or mobile clients that need an HTTP/SSE source feed | + +## Build for changing conditions + +Provider results vary by title and network. A dependable player should: + +1. Begin playback as soon as it has a usable candidate. +2. Keep later candidates as fallbacks. +3. Keep subtitles independent from first playback. +4. Report a helpful unavailable state when no candidates resolve. +5. Destroy playback and abort in-flight work when the viewer changes titles. + +The SDK returns provider results directly; Stream API emits `meta`, `source`, and `done` events. Both models benefit from the same fallback strategy. + +## Playback formats + +Candidates may be HLS manifests or direct media files. Let the result metadata and your player capabilities guide how a candidate is loaded; do not assume every URL has the same format or availability. HLS-capable web players normally use HLS.js where native HLS is unavailable, while direct media can be assigned to a video element. + +## Continue building + +- Embed provider resolution with [SDK quickstart](/sdk/vyla-sdk/quickstart). +- Learn the optional HTTP event contract in [Paths and playback events](/stream-api/reference/paths). +- Use the [client integration guide](/guides/integrations) for a browser-oriented example. +- Review [error handling](/guides/error-handling) before shipping a player. \ No newline at end of file diff --git a/products/tv.md b/products/tv.md new file mode 100644 index 0000000..d2a0d80 --- /dev/null +++ b/products/tv.md @@ -0,0 +1,19 @@ +# Vyla TV + +

      The Android TV application for running the Vyla media experience on your television.

      + +A collage of films and shows available through a Vyla experience + +## Vyla TV + +**Vyla TV** is an Android TV streaming app built with Jetpack Compose and ExoPlayer that lets you browse and watch movies and TV shows from TMDB. + +## Downloads + +Vyla TV is available for Android TV. Get the latest version below: + +| Platform | Download | +| --- | --- | +| Android TV | [Download .apk](https://gitlab.com/vyla-entertainment/TV/-/releases) | + +Inside the releases page, you can find the latest version of Vyla TV for Android TV, find the `.apk` file, and install it on your device. \ No newline at end of file diff --git a/robots.txt b/robots.txt index 97b36f0..c43b6a0 100644 --- a/robots.txt +++ b/robots.txt @@ -1,5 +1,102 @@ User-agent: * Allow: / -Disallow: /assets/ Sitemap: https://vyla.cc/sitemap.xml +--- END OF FILE robots.txt --- +--- START OF FILE ai-catalog.json --- +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "name": "Vyla Entertainment", + "identifier": "vyla-entertainment", + "description": "A community-built media streaming and scraper ecosystem providing native applications, a Node.js resolver SDK, and a self-hosted Stream API.", + "homepage": "https://vyla.cc", + "documentation": "https://vyla.cc", + "llms_context": "https://vyla.cc/llms.txt", + "repository": "https://gitlab.com/vyla-entertainment", + "community": "https://discord.gg/5eqRhKeywG", + "license": "Open Source", + "ecosystem": { + "clients": [ + { + "name": "Vyla Home", + "platform": ["Windows", "macOS", "Linux"], + "description": "Desktop control center and self-contained runtime.", + "documentation": "https://vyla.cc/products/home" + }, + { + "name": "Vyla Go", + "platform": ["Android"], + "description": "Mobile application running local resolver services.", + "documentation": "https://vyla.cc/products/go" + }, + { + "name": "Vyla TV", + "platform": ["Android TV"], + "description": "Television interface built with Jetpack Compose and ExoPlayer.", + "documentation": "https://vyla.cc/products/tv" + } + ], + "libraries": [ + { + "package": "@vyla-entertainment/sdk", + "language": "Node.js (>=18)", + "description": "Server-side resolver library for stream links, subtitle extraction, and download links.", + "documentation": "https://vyla.cc/sdk" + } + ], + "services": [ + { + "name": "Stream API", + "type": "Self-hosted HTTP/SSE Proxy", + "description": "REST and Server-Sent Events service wrapping Vyla SDK for remote clients.", + "default_port": 7860, + "protocols": ["HTTP", "SSE"], + "documentation": "https://vyla.cc/stream-api/overview", + "endpoints": [ + { + "path": "/movie", + "method": "GET", + "query_parameters": ["id"], + "response_type": "text/event-stream" + }, + { + "path": "/tv", + "method": "GET", + "query_parameters": ["id", "season", "episode"], + "response_type": "text/event-stream" + }, + { + "path": "/subtitles/movie/:id", + "method": "GET", + "response_type": "application/json" + }, + { + "path": "/subtitles/tv/:id/:season/:episode", + "method": "GET", + "response_type": "application/json" + }, + { + "path": "/downloads/movie/:id", + "method": "GET", + "response_type": "application/json" + }, + { + "path": "/downloads/tv/:id/:season/:episode", + "method": "GET", + "response_type": "application/json" + }, + { + "path": "/health", + "method": "GET", + "response_type": "application/json" + } + ] + } + ] + }, + "compliance": { + "hosting_policy": "Non-hosting scraper proxy; all video segments originate from third-party hosts.", + "dmca_contact": "alwaysmesmerizingyou@gmail.com", + "dmca_policy": "https://vyla.cc/misc/project/dmca" + } +} \ No newline at end of file diff --git a/sdk-quickstart/index.html b/sdk-quickstart/index.html deleted file mode 100644 index d0adbe5..0000000 --- a/sdk-quickstart/index.html +++ /dev/null @@ -1,194 +0,0 @@ - - - - - - - SDK quickstart — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      SDK quickstart

      -

      Install Vyla SDK, resolve a source, and build a safe server-side integration.

      -

      Before you start

      -

      Vyla SDK is for server-side Node.js. It contains provider integrations and makes outbound - requests from the process where you install it. Do not import it into a browser bundle or expose provider - results, credentials, or internal source keys directly to untrusted clients.

      -

      You need Node.js 18 or later and, for sources that resolve title metadata, a TMDB API key.

      -

      Install and initialize

      -
      -
      -
      -
      -
      npm install @vyla-entertainment/sdk
      -
      -
      -
      pnpm install @vyla-entertainment/sdk
      -
      -
      -
      yarn add @vyla-entertainment/sdk
      -
      -
      -
      -
      import VylaSDK from "@vyla-entertainment/sdk";
      -
      -const vyla = new VylaSDK({
      -  tmdbApiKey: process.env.TMDB_API_KEY,
      -  penguManifest: process.env.PENGU_MANIFEST
      -});
      -
      -
      -
      -

      Keep TMDB_API_KEY and any deployment configuration in server-side environment variables. - Never commit them or send them to a client.

      -
      -
      -

      Resolve a movie

      -

      First inspect active providers. Provider availability changes over time, so select a key from the running - configuration instead of hard-coding a placeholder.

      -
      const providers = vyla.getSources(true);
      -console.table(providers.map(({ key, label }) => ({ key, label })));
      -
      -const provider = providers[0];
      -const result = await vyla.getStream(provider.key, 155);
      -const candidates = result.allUrls ?? [result];
      -
      -console.log(candidates.map(({ url, type, quality }) => ({ url, type, quality })));
      -
      -

      155 is a TMDB movie ID. For an episode, include season and episode:

      -
      const result = await vyla.getStream(provider.key, 1396, 1, 1);
      -
      -

      Make it resilient

      -

      Provider results are inherently variable: a source can fail, time out, return more than one candidate, or be - temporarily disabled. In production:

      -
        -
      1. Probe sources or maintain a recent availability signal before choosing one.
      2. -
      3. Give every provider call a request deadline in your application.
      4. -
      5. Treat allUrls as fallback candidates, not a guarantee that every URL will play.
      6. -
      7. Return only the fields your client needs and validate requests at your own API boundary.
      8. -
      9. Cache and rate-limit at your boundary to avoid repeatedly triggering upstream work.
      10. -
      -
      const health = await vyla.probeAllSources();
      -const usableKey = Object.entries(health).find(([, result]) => result.ok)?.[0];
      -
      -if (!usableKey) throw new Error("No providers are currently available");
      -
      -const stream = await vyla.getStream(usableKey, 155);
      -
      -

      Next steps

      -
        -
      • Read the complete SDK reference for methods and return shapes.
      • -
      • Learn provider flags, timeout behavior, and source keys in Providers. -
      • -
      • Choose Stream API only when an SSE HTTP interface better fits your - client architecture.
      • -
      -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/sdk/index.html b/sdk/index.html deleted file mode 100644 index f002002..0000000 --- a/sdk/index.html +++ /dev/null @@ -1,588 +0,0 @@ - - - - - - - SDK Reference — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      SDK Reference

      -

      Scrape video streams, fetch subtitles, retrieve download links, and monitor provider - health directly from your Node.js application.

      -

      The Vyla SDK (@vyla-entertainment/sdk) is a server-side JavaScript/TypeScript - package that provides direct access to Vyla's stream resolvers, health probes, download resolvers, and - subtitle providers.

      -

      By embedding the SDK directly into your backend application, provider resolution executes in your own - environment. It is an alternative to operating the Stream API as a separate process: your application owns its - request boundary, caching, authorization, timeouts, and response shape.

      -
      -
      -

      The SDK can be used independently of the self-hosted Stream API. If you prefer to embed Vyla capabilities - in an existing Node.js application rather than run a separate HTTP service, begin with the SDK quickstart.

      -
      -
      -
      -

      Features

      -
        -
      • Direct-to-Client Playback: Scraped streams link directly to source CDNs. No proxy server - overhead required unless specified by provider headers.
      • -
      • Built-In Health Probing: Monitor latency and uptime per provider with built-in health - check utilities.
      • -
      • Automated Anime Sub/Dub Routing: Automatically configures audio track requirements - (sub vs dub) based on provider naming conventions.
      • -
      • Multi-Source Subtitle Aggregation: Merges subtitle tracks from multiple redundant - subtitle mirrors into a unified VTT/SRT format.
      • -
      • Metadata Fallbacks: Automatically maps TMDB IDs to external IDs (such as IMDB and - AniList) for smooth stream lookups.
      • -
      • Self-Contained: No need to deploy a separate API server — all streaming functionality - runs within your existing Node.js application.
      • -
      -
      -

      Installation

      -

      Install the package into your Node.js backend using your package manager of choice:

      -
      -
      -
      -
      -
      npm install @vyla-entertainment/sdk
      -
      -
      -
      pnpm install @vyla-entertainment/sdk
      -
      -
      -
      yarn add @vyla-entertainment/sdk
      -
      -
      -
      -
      -

      Note: Node.js v18+ is required due to modern standard fetch - and AbortSignal.timeout usage.

      -
      -
      -

      Initialization

      -

      Initialize the VylaSDK client by supplying your TMDB API Key. The key resolves - media metadata, validates movie/TV show IDs, and translates TMDB IDs into AniList IDs for anime sources.

      -

      You may optionally provide a pengu.uk manifest URL to enable the Pengu source.

      -
      import VylaSDK from "@vyla-entertainment/sdk";
      -
      -const sdk = new VylaSDK({
      -  tmdbApiKey: process.env.TMDB_API_KEY,
      -
      -  penguManifest: "" // Optional, get from pengu.uk
      -});
      -
      -
      -

      Core Methods

      -

      getStream(key, id, s?, e?, clientIP?)

      -

      Scrapes video stream URLs and playability headers for a specified provider key.

      -
      // Fetch a movie stream (TMDB ID: 155 - The Dark Knight)
      -const movieStream = await sdk.getStream("sourcename", "155");
      -
      -// Fetch a TV show episode (TMDB ID: 1399, Season 1, Episode 1)
      -const tvStream = await sdk.getStream("sourcename", "1399", 1, 1);
      -
      -// Pass client IP for providers that use geo-verification
      -const geoStream = await sdk.getStream("sourcename", "155", null, null, "203.0.113.195");
      -
      -

      Parameters

      - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      ParameterTypeRequiredDefaultDescription
      keystringYes—Unique key identifier for the provider (e.g. "sourcename", - "sourcename", "sourcename"). -
      idstringYes—TMDB media ID (e.g., "155").
      sstring | numberNonullSeason number (required for TV shows).
      estring | numberNonullEpisode number (required for TV shows).
      clientIPstringNonullEnd-user IP address forwarded for geo-restricted scrapers.
      -

      Example Response Format

      -
      {
      -  "url": "https://example-cdn.com/hls/manifest.m3u8",
      -  "quality": "auto",
      -  "isM3U8": true,
      -  "headers": {
      -    "Referer": "https://example.com/",
      -    "Origin": "https://example.com"
      -  },
      -  "allUrls": [
      -    {
      -      "url": "https://example-cdn.com/hls/manifest.m3u8",
      -      "quality": "1080p",
      -      "isM3U8": true
      -    }
      -  ]
      -}
      -
      -
      -

      getSubtitles(id, s?, e?)

      -

      Fetches subtitle files aggregated across multiple subtitle backend mirrors (v1, v2, - febbox). Returns clean VTT and SRT tracks. -

      -
      // Fetch movie subtitles
      -const movieSubs = await sdk.getSubtitles("155");
      -
      -// Fetch TV show episode subtitles
      -const tvSubs = await sdk.getSubtitles("1399", 1, 1);
      -
      -

      Parameters

      - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      ParameterTypeRequiredDefaultDescription
      idstringYes—TMDB media ID.
      sstring | numberNonullSeason number (required for TV shows).
      estring | numberNonullEpisode number (required for TV shows).
      -

      Example Response Format

      -
      [
      -  {
      -    "label": "English",
      -    "file": "https://sub.vdrk.site/v1/vtt/movie/155/English.vtt",
      -    "type": "vtt",
      -    "source": "v1"
      -  },
      -  {
      -    "label": "Spanish",
      -    "file": "https://fed-subs.pstream.mov/subtitles/spanish.srt",
      -    "type": "srt",
      -    "source": "febbox"
      -  }
      -]
      -
      -
      -

      getDownloads(id, s?, e?)

      -

      Fetches direct downloadable media files (MP4/MKV) paired with resolution and file size descriptors.

      -
      // Movie downloads
      -const downloads = await sdk.getDownloads("155");
      -
      -// TV Episode downloads
      -const tvDownloads = await sdk.getDownloads("1399", 1, 1);
      -
      -

      Example Response Format

      -
      [
      -  {
      -    "url": "https://download-node.com/file/the-dark-knight-1080p.mp4",
      -    "quality": "1080p",
      -    "size": "2.14 GB",
      -    "format": "MP4"
      -  }
      -]
      -
      -
      -

      Provider & Health Management

      -

      getSources(excludeDisabled?)

      -

      Retrieves provider configurations registered in the SDK.

      -
      // Get all sources including temporarily disabled ones
      -const allSources = sdk.getSources(false);
      -
      -// Get only active/enabled sources
      -const activeSources = sdk.getSources(true);
      -
      -

      Parameters

      - - - - - - - - - - - - - - - - - - - -
      ParameterTypeRequiredDefaultDescription
      excludeDisabledbooleanNofalseWhen true, filters out providers marked as disabled - (disabled: true).
      -
      -

      probeSource(key)

      -

      Executes a health ping test against a single provider to determine current availability and latency using a - probe payload (TMDB ID: 155).

      -
      const health = await sdk.probeSource("vidrock");
      -console.log(health);
      -// Output: { ok: true, ms: 840 }
      -
      -
      -

      probeAllSources()

      -

      Executes health tests in parallel across all non-disabled provider modules.

      -
      const results = await sdk.probeAllSources();
      -console.log(results);
      -
      -

      Example Response Format

      -
      {
      -  "sourcename1": { "ok": true, "ms": 420 },
      -  "sourcename2": { "ok": true, "ms": 1150 },
      -  "sourcename3": { "ok": false, "ms": null }
      -}
      -
      -
      -

      Provider Metadata Properties

      -

      When calling getSources(), each provider entry returns metadata describing its features and - streaming behavior:

      -
      {
      -  key: 'example',
      -  label: 'Example',
      -  sourceFile: 'example',
      -  proxyParam: 'ex',
      -  timeout: 20000,
      -  jitter: 800,
      -  retries: 3,
      -  multiUrl: true,
      -  skipProxy: false,
      -  cdnHeaders: [{
      -    pattern: /./,
      -    headers: {
      -      Referer: 'https://example.com/',
      -      Origin: 'https://example.com'
      -    }
      -  }]
      -}
      -
      -

      Property Reference

      - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      FieldTypeDescription
      keystringUnique identifier used in getStream() and probeSource().
      labelstringHuman-readable name for UI display.
      skipProxybooleanWhen true, stream URLs can be fed directly to web video players (e.g., - HLS.js, Video.js) without needing an intermediate backend proxy.
      multiUrlbooleanIndicates whether the source returns multiple stream resolution choices.
      timeoutnumberInternal request timeout threshold in milliseconds.
      retriesnumberMax automatic retry attempts executed by health/stream scrapers.
      cdnHeadersArrayRegex pattern matching rules detailing required request headers (Referer, - Origin) needed to playback stream segments without 403 Forbidden errors. -
      disabledbooleanSet to true if a source is down or undergoing maintenance.
      -
      -

      Practical Express.js Backend Example

      -

      Here is an example showing how to serve stream links to your client web application while handling custom CDN - playback headers and subtitle merging:

      -
      import express from "express";
      -import VylaSDK from "@vyla-entertainment/sdk";
      -
      -const app = express();
      -const sdk = new VylaSDK({ tmdbApiKey: process.env.TMDB_API_KEY });
      -
      -// Scrape stream route
      -app.get("/api/stream", async (req, res) => {
      -  const { provider, tmdbId, season, episode } = req.query;
      -  const clientIP = req.headers["x-forwarded-for"] || req.socket.remoteAddress;
      -
      -  try {
      -    const streamData = await sdk.getStream(
      -      provider,
      -      tmdbId,
      -      season || null,
      -      episode || null,
      -      clientIP
      -    );
      -
      -    if (!streamData || !streamData.url) {
      -      return res.status(404).json({ error: "No stream found for this provider" });
      -    }
      -
      -    return res.json({
      -      success: true,
      -      provider,
      -      stream: streamData,
      -    });
      -  } catch (error) {
      -    return res.status(500).json({ error: error.message });
      -  }
      -});
      -
      -// Fetch subtitles route
      -app.get("/api/subtitles", async (req, res) => {
      -  const { tmdbId, season, episode } = req.query;
      -
      -  try {
      -    const subtitles = await sdk.getSubtitles(
      -      tmdbId,
      -      season || null,
      -      episode || null
      -    );
      -
      -    return res.json({ subtitles });
      -  } catch (error) {
      -    return res.status(500).json({ subtitles: [] });
      -  }
      -});
      -
      -app.listen(3000, () => console.log("Vyla SDK Server running on port 3000"));
      -
      -

      SDK vs Self-Hosted API

      -

      The SDK and the self-hosted API server provide the same streaming functionality but in different deployment - models:

      - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      AspectSDKSelf-Hosted API
      DeploymentEmbedded in your Node.js appSeparate server process
      NetworkDirect source CDN accessBuilt-in proxy for CORS
      Rate LimitingNone (you control it)Per-API-key limits
      Setupnpm install + initClone repo + configure
      Use CaseExisting Node.js appsStandalone streaming service
      -

      Choose the SDK if you want to integrate streaming directly into an existing Node.js application. Choose the - self-hosted API if you want a standalone streaming service that can be used by multiple clients.

      -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/sdk/vyla-sdk/index.md b/sdk/vyla-sdk/index.md new file mode 100644 index 0000000..35f7ac9 --- /dev/null +++ b/sdk/vyla-sdk/index.md @@ -0,0 +1,346 @@ +# SDK Reference + +

      Scrape video streams, fetch subtitles, retrieve download links, and monitor provider health directly from your Node.js application.

      + +The **Vyla SDK** (`@vyla-entertainment/sdk`) is a server-side JavaScript/TypeScript package that provides direct access to Vyla's stream resolvers, health probes, download resolvers, and subtitle providers. + +By embedding the SDK directly into your backend application, provider resolution executes in your own environment. It is an alternative to operating the Stream API as a separate process: your application owns its request boundary, caching, authorization, timeouts, and response shape. + +:::callout-note +The SDK can be used independently of the self-hosted Stream API. If you prefer to embed Vyla capabilities in an existing Node.js application rather than run a separate HTTP service, begin with the [SDK quickstart](/sdk/vyla-sdk/quickstart). +::: + +--- + +## Features + +- **Direct-to-Client Playback**: Scraped streams link directly to source CDNs. No proxy server overhead required unless specified by provider headers. +- **Built-In Health Probing**: Monitor latency and uptime per provider with built-in health check utilities. +- **Automated Anime Sub/Dub Routing**: Automatically configures audio track requirements (`sub` vs `dub`) based on provider naming conventions. +- **Multi-Source Subtitle Aggregation**: Merges subtitle tracks from multiple redundant subtitle mirrors into a unified VTT/SRT format. +- **Metadata Fallbacks**: Automatically maps TMDB IDs to external IDs (such as IMDB and AniList) for smooth stream lookups. +- **Self-Contained**: No need to deploy a separate API server — all streaming functionality runs within your existing Node.js application. + +--- + +## Installation + +Install the package into your Node.js backend using your package manager of choice: + +:::code-group +```bash [npm] +npm install @vyla-entertainment/sdk +``` +```bash [pnpm] +pnpm install @vyla-entertainment/sdk +``` +```bash [yarn] +yarn add @vyla-entertainment/sdk +``` +::: + +> **Note**: Node.js **v18+** is required due to modern standard `fetch` and `AbortSignal.timeout` usage. + +--- + +## Initialization + +Initialize the `VylaSDK` client by supplying your **TMDB API Key**. The key resolves media metadata, validates movie/TV show IDs, and translates TMDB IDs into AniList IDs for anime sources. + +You may optionally provide a pengu.uk manifest URL to enable the Pengu source. + +```javascript +import VylaSDK from "@vyla-entertainment/sdk"; + +const sdk = new VylaSDK({ + tmdbApiKey: process.env.TMDB_API_KEY, + + penguManifest: "" // Optional, get from pengu.uk +}); +``` + +--- + +## Core Methods + +### `getStream(key, id, s?, e?, clientIP?)` + +Scrapes video stream URLs and playability headers for a specified provider key. + +```javascript +// Fetch a movie stream (TMDB ID: 155 - The Dark Knight) +const movieStream = await sdk.getStream("sourcename", "155"); + +// Fetch a TV show episode (TMDB ID: 1399, Season 1, Episode 1) +const tvStream = await sdk.getStream("sourcename", "1399", 1, 1); + +// Pass client IP for providers that use geo-verification +const geoStream = await sdk.getStream("sourcename", "155", null, null, "203.0.113.195"); +``` + +#### Parameters + +| Parameter | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `key` | `string` | **Yes** | — | Unique key identifier for the provider (e.g. `"sourcename"`). | +| `id` | `string` | **Yes** | — | TMDB media ID (e.g., `"155"`). | +| `s` | `string \| number` | **No** | `null` | Season number (required for TV shows). | +| `e` | `string \| number` | **No** | `null` | Episode number (required for TV shows). | +| `clientIP` | `string` | **No** | `null` | End-user IP address forwarded for geo-restricted scrapers. | + +#### Example Response Format + +```json +{ + "url": "https://example-cdn.com/hls/manifest.m3u8", + "quality": "auto", + "isM3U8": true, + "headers": { + "Referer": "https://example.com/", + "Origin": "https://example.com" + }, + "allUrls": [ + { + "url": "https://example-cdn.com/hls/manifest.m3u8", + "quality": "1080p", + "isM3U8": true + } + ] +} +``` + +--- + +### `getSubtitles(id, s?, e?)` + +Fetches subtitle files aggregated across multiple subtitle backend mirrors (`v1`, `v2`, `febbox`). Returns clean VTT and SRT tracks. + +```javascript +// Fetch movie subtitles +const movieSubs = await sdk.getSubtitles("155"); + +// Fetch TV show episode subtitles +const tvSubs = await sdk.getSubtitles("1399", 1, 1); +``` + +#### Parameters + +| Parameter | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `id` | `string` | **Yes** | — | TMDB media ID. | +| `s` | `string \| number` | **No** | `null` | Season number (required for TV shows). | +| `e` | `string \| number` | **No** | `null` | Episode number (required for TV shows). | + +#### Example Response Format + +```json +[ + { + "label": "English", + "file": "https://sub.vdrk.site/v1/vtt/movie/155/English.vtt", + "type": "vtt", + "source": "v1" + }, + { + "label": "Spanish", + "file": "https://fed-subs.pstream.mov/subtitles/spanish.srt", + "type": "srt", + "source": "febbox" + } +] +``` + +--- + +### `getDownloads(id, s?, e?)` + +Fetches direct downloadable media files (MP4/MKV) paired with resolution and file size descriptors. + +```javascript +// Movie downloads +const downloads = await sdk.getDownloads("155"); + +// TV Episode downloads +const tvDownloads = await sdk.getDownloads("1399", 1, 1); +``` + +#### Example Response Format + +```json +[ + { + "url": "https://download-node.com/file/the-dark-knight-1080p.mp4", + "quality": "1080p", + "size": "2.14 GB", + "format": "MP4" + } +] +``` + +--- + +## Provider & Health Management + +### `getSources(excludeDisabled?)` + +Retrieves provider configurations registered in the SDK. + +```javascript +// Get all sources including temporarily disabled ones +const allSources = sdk.getSources(false); + +// Get only active/enabled sources +const activeSources = sdk.getSources(true); +``` + +#### Parameters + +| Parameter | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `excludeDisabled` | `boolean` | **No** | `false` | When `true`, filters out providers marked as disabled (`disabled: true`). | + +--- + +### `probeSource(key)` + +Executes a health ping test against a single provider to determine current availability and latency using a probe payload (`TMDB ID: 155`). + +```javascript +const health = await sdk.probeSource("vidrock"); +console.log(health); +// Output: { ok: true, ms: 840 } +``` + +--- + +### `probeAllSources()` + +Executes health tests in parallel across all non-disabled provider modules. + +```javascript +const results = await sdk.probeAllSources(); +console.log(results); +``` + +#### Example Response Format + +```json +{ + "sourcename1": { "ok": true, "ms": 420 }, + "sourcename2": { "ok": true, "ms": 1150 }, + "sourcename3": { "ok": false, "ms": null } +} +``` + +--- + +## Provider Metadata Properties + +When calling `getSources()`, each provider entry returns metadata describing its features and streaming behavior: + +```javascript +{ + key: 'example', + label: 'Example', + sourceFile: 'example', + proxyParam: 'ex', + timeout: 20000, + jitter: 800, + retries: 3, + multiUrl: true, + skipProxy: false, + cdnHeaders: [{ + pattern: /./, + headers: { + Referer: 'https://example.com/', + Origin: 'https://example.com' + } + }] +} +``` + +### Property Reference + +| Field | Type | Description | +| --- | --- | --- | +| `key` | `string` | Unique identifier used in `getStream()` and `probeSource()`. | +| `label` | `string` | Human-readable name for UI display. | +| `skipProxy` | `boolean` | When `true`, stream URLs can be fed directly to web video players (e.g., HLS.js, Video.js) without needing an intermediate backend proxy. | +| `multiUrl` | `boolean` | Indicates whether the source returns multiple stream resolution choices. | +| `timeout` | `number` | Internal request timeout threshold in milliseconds. | +| `retries` | `number` | Max automatic retry attempts executed by health/stream scrapers. | +| `cdnHeaders` | `Array` | Regex pattern matching rules detailing required request headers (`Referer`, `Origin`) needed to playback stream segments without `403 Forbidden` errors. | +| `disabled` | `boolean` | Set to `true` if a source is down or undergoing maintenance. | + +--- + +## Practical Express.js Backend Example + +Here is an example showing how to serve stream links to your client web application while handling custom CDN playback headers and subtitle merging: + +```javascript +import express from "express"; +import VylaSDK from "@vyla-entertainment/sdk"; + +const app = express(); +const sdk = new VylaSDK({ tmdbApiKey: process.env.TMDB_API_KEY }); + +app.get("/api/stream", async (req, res) => { + const { provider, tmdbId, season, episode } = req.query; + const clientIP = req.headers["x-forwarded-for"] || req.socket.remoteAddress; + + try { + const streamData = await sdk.getStream( + provider, + tmdbId, + season || null, + episode || null, + clientIP + ); + + if (!streamData || !streamData.url) { + return res.status(404).json({ error: "No stream found for this provider" }); + } + + return res.json({ + success: true, + provider, + stream: streamData, + }); + } catch (error) { + return res.status(500).json({ error: error.message }); + } +}); + +app.get("/api/subtitles", async (req, res) => { + const { tmdbId, season, episode } = req.query; + + try { + const subtitles = await sdk.getSubtitles( + tmdbId, + season || null, + episode || null + ); + + return res.json({ subtitles }); + } catch (error) { + return res.status(500).json({ subtitles: [] }); + } +}); + +app.listen(3000, () => console.log("Vyla SDK Server running on port 3000")); +``` + +## SDK vs Self-Hosted API + +The SDK and the self-hosted API server provide the same streaming functionality but in different deployment models: + +| Aspect | SDK | Self-Hosted API | +| --- | --- | --- | +| **Deployment** | Embedded in your Node.js app | Separate server process | +| **Network** | Direct source CDN access | Built-in proxy for CORS | +| **Rate Limiting** | None (you control it) | Per-API-key limits | +| **Setup** | npm install + init | Clone repo + configure | +| **Use Case** | Existing Node.js apps | Standalone streaming service | + +Choose the SDK if you want to integrate streaming directly into an existing Node.js application. Choose the self-hosted API if you want a standalone streaming service that can be used by multiple clients. diff --git a/sdk/vyla-sdk/quickstart.md b/sdk/vyla-sdk/quickstart.md new file mode 100644 index 0000000..0903e99 --- /dev/null +++ b/sdk/vyla-sdk/quickstart.md @@ -0,0 +1,82 @@ +# SDK quickstart + +

      Install Vyla SDK, resolve a source, and build a safe server-side integration.

      + +## Before you start + +Vyla SDK is for **server-side Node.js**. It contains provider integrations and makes outbound requests from the process where you install it. Do not import it into a browser bundle or expose provider results, credentials, or internal source keys directly to untrusted clients. + +You need Node.js 18 or later and, for sources that resolve title metadata, a TMDB API key. + +## Install and initialize + +:::code-group +```bash [npm] +npm install @vyla-entertainment/sdk +``` +```bash [pnpm] +pnpm install @vyla-entertainment/sdk +``` +```bash [yarn] +yarn add @vyla-entertainment/sdk +``` +::: + +```javascript +import VylaSDK from "@vyla-entertainment/sdk"; + +const vyla = new VylaSDK({ + tmdbApiKey: process.env.TMDB_API_KEY, + penguManifest: process.env.PENGU_MANIFEST +}); +``` + +:::callout-warning +Keep `TMDB_API_KEY` and any deployment configuration in server-side environment variables. Never commit them or send them to a client. +::: + +## Resolve a movie + +First inspect active providers. Provider availability changes over time, so select a key from the running configuration instead of hard-coding a placeholder. + +```javascript +const providers = vyla.getSources(true); +console.table(providers.map(({ key, label }) => ({ key, label }))); + +const provider = providers[0]; +const result = await vyla.getStream(provider.key, 155); +const candidates = result.allUrls ?? [result]; + +console.log(candidates.map(({ url, type, quality }) => ({ url, type, quality }))); +``` + +`155` is a TMDB movie ID. For an episode, include season and episode: + +```javascript +const result = await vyla.getStream(provider.key, 1396, 1, 1); +``` + +## Make it resilient + +Provider results are inherently variable: a source can fail, time out, return more than one candidate, or be temporarily disabled. In production: + +1. Probe sources or maintain a recent availability signal before choosing one. +2. Give every provider call a request deadline in your application. +3. Treat `allUrls` as fallback candidates, not a guarantee that every URL will play. +4. Return only the fields your client needs and validate requests at your own API boundary. +5. Cache and rate-limit at your boundary to avoid repeatedly triggering upstream work. + +```javascript +const health = await vyla.probeAllSources(); +const usableKey = Object.entries(health).find(([, result]) => result.ok)?.[0]; + +if (!usableKey) throw new Error("No providers are currently available"); + +const stream = await vyla.getStream(usableKey, 155); +``` + +## Next steps + +- Read the complete [SDK reference](/sdk) for methods and return shapes. +- Learn provider flags, timeout behavior, and source keys in [Providers](/concepts/providers). +- Choose [Stream API](/stream-api/overview) only when an SSE HTTP interface better fits your client architecture. \ No newline at end of file diff --git a/self-hosting/index.html b/self-hosting/index.html deleted file mode 100644 index 1369a58..0000000 --- a/self-hosting/index.html +++ /dev/null @@ -1,280 +0,0 @@ - - - - - - - Self-host Stream API — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      Self-host Stream API

      -

      Deploy the optional Stream API when your clients need an HTTP/SSE layer.

      -

      Overview

      -

      Stream API is a self-hosted Node.js application that you run on infrastructure you control. - It does not provide a public hosted instance. The service runs as a Node.js process, uses in-memory caching, - and can fork workers through Node's built-in cluster module. No database is required by the - service itself.

      -
      -
      -

      Stream API is self-hosted only. There is no public Vyla streaming endpoint. You only need it when your - architecture requires an HTTP/SSE layer; Node.js applications can use Vyla SDK - directly.

      -
      -
      -

      Requirements

      -
        -
      • Node.js 18 or later
      • -
      • A TMDB API key if you want title validation and metadata lookups
      • -
      -

      Environment variables

      -

      Create a .env file in the project root:

      -
      TMDB_API_KEY=
      -PORT=7860
      -WORKER_COUNT=1
      -ENABLE_DEBUG_ROUTE=false
      -PROXY_STREAMS=false
      -PROXY_URL=
      -
      -# Optional telemetry configuration
      -GA_MEASUREMENT_ID=
      -GA_API_SECRET=
      -
      -
      -
      Variable reference
      -
      - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      VariableRequiredDescription
      TMDB_API_KEYYesEnables TMDB-based ID validation, metadata lookups, and anime detection. Some sources will not - work without it.
      GA_MEASUREMENT_ID / GA_API_SECRETNoEnables Google Analytics event reporting. Leave blank to disable.
      PORTNoPort the server listens on. Defaults to 7860.
      WORKER_COUNTNoNumber of cluster workers to fork. Defaults to 1 locally.
      ENABLE_DEBUG_ROUTENoSet to true to enable /api/debug/:id. Keep this false in - production — it exposes upstream request/response headers and raw source output.
      PROXY_STREAMSNoSet to true to route stream playback through your own server's /api - proxy endpoint instead of direct URLs.
      PROXY_URLNoAn external proxy base URL to use instead of the built-in /api proxy. Only used if - PROXY_STREAMS is enabled. -
      -
      -
      -

      Installing and running

      -
      git clone https://gitlab.com/vyla-entertainment/stream-api
      -cd stream-api
      -npm install
      -node server.js
      -
      -

      The server listens on:

      -
      http://localhost:7860
      -
      -

      Visit /health to confirm it is running and check which sources are currently reachable from your - host.

      -

      Running with multiple workers

      -

      Setting WORKER_COUNT above 1 forks additional worker processes via - cluster. -

      -

      The primary process holds a shared in-memory cache (capped at 1500 entries, pruned every 30 seconds) and - relays cache reads/writes between workers over IPC, allowing cached stream results to be shared rather than - duplicated.

      -
      WORKER_COUNT=4
      -node server.js
      -
      -

      Each worker independently loads source modules and handles outbound requests. Only shared cache operations - and concurrency limits are coordinated through the primary process.

      -

      Network considerations

      -

      Several sources are documented in config.js as blocked on datacenter IPs. This commonly affects - cloud hosting providers and VPS environments.

      -

      If a source's getStream consistently returns nothing from your server but works from a - residential connection, the cause is usually upstream blocking based on IP reputation. For the best - experience, run Vyla API on a residential connection or use a residential proxy.

      -

      When running on a local desktop environment, you should have minimal issues with source availability since - you're using a residential IP address.

      -

      Reverse proxy / TLS

      -

      The server runs over plain HTTP. For public deployments, place it behind a reverse proxy such as Caddy, - Nginx, or Cloudflare Tunnel to terminate TLS and forward requests to the configured PORT.

      -

      CORS is already configured at the application layer with:

      -
      Access-Control-Allow-Origin: *
      -
      -

      No additional CORS configuration is required on the proxy.

      -

      Verifying your deployment

      -

      Run these checks in order:

      -
        -
      1. -

        GET /health

        -

        Confirms the process is running and reports per-source reachability from your host.

        -
      2. -
      3. -

        GET /api/test/155?source=vidrock

        -

        Runs a real source resolution using a known TMDB movie ID and returns whether the source succeeded.

        -
      4. -
      5. -

        GET /api/debug/155?source=vidrock

        -

        Only available when ENABLE_DEBUG_ROUTE=true.

        -

        Returns request traces, headers, and raw candidate URLs for debugging source failures.

        -
      6. -
      -
      -
      -

      Leave ENABLE_DEBUG_ROUTE disabled on public deployments. The debug route exposes upstream - request information and raw stream URLs, which can leak internal details.

      -
      -
      -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/sitemap.xml b/sitemap.xml index 98c690f..208467b 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -2,92 +2,110 @@ https://vyla.cc/ - daily + 2025-02-15 + weekly 1.0 - https://vyla.cc/architecture.html - weekly + https://vyla.cc/start-here/architecture + 2025-02-15 + monthly 0.8 - https://vyla.cc/home.html + https://vyla.cc/products/home + 2025-02-15 weekly 0.9 - https://vyla.cc/go.html + https://vyla.cc/products/go + 2025-02-15 weekly 0.9 - https://vyla.cc/tv.html + https://vyla.cc/products/tv + 2025-02-15 weekly 0.9 - https://vyla.cc/player.html + https://vyla.cc/products/player + 2025-02-15 + monthly + 0.7 + + + https://vyla.cc/concepts/providers + 2025-02-15 weekly 0.8 - https://vyla.cc/sdk.html + https://vyla.cc/sdk + 2025-02-15 weekly 0.9 - https://vyla.cc/sdk-quickstart.html + https://vyla.cc/sdk/vyla-sdk/quickstart + 2025-02-15 + monthly + 0.8 + + + https://vyla.cc/stream-api/overview + 2025-02-15 weekly 0.9 - https://vyla.cc/stream-api/overview.html - weekly - 0.9 - - - https://vyla.cc/stream-api/paths.html - weekly + https://vyla.cc/stream-api/reference/paths + 2025-02-15 + monthly 0.8 - https://vyla.cc/stream-api/media-data.html - weekly + https://vyla.cc/stream-api/reference/media-data + 2025-02-15 + monthly 0.8 - https://vyla.cc/stream-api/operations.html - weekly + https://vyla.cc/stream-api/reference/operations + 2025-02-15 + monthly + 0.7 + + + https://vyla.cc/stream-api/deployment/self-hosting + 2025-02-15 + monthly 0.8 - https://vyla.cc/self-hosting.html - weekly - 0.9 - - - https://vyla.cc/concepts/providers.html - weekly + https://vyla.cc/guides/integrations + 2025-02-15 + monthly 0.8 - https://vyla.cc/guides/integrations.html - weekly - 0.8 + https://vyla.cc/guides/error-handling + 2025-02-15 + monthly + 0.7 - https://vyla.cc/guides/error-handling.html - weekly - 0.8 - - - https://vyla.cc/misc/credit.html + https://vyla.cc/misc/project/credit + 2025-02-15 monthly 0.5 - https://vyla.cc/misc/dmca.html + https://vyla.cc/misc/project/dmca + 2025-02-15 monthly - 0.5 + 0.6 - + \ No newline at end of file diff --git a/start-here/architecture.md b/start-here/architecture.md new file mode 100644 index 0000000..0ac8941 --- /dev/null +++ b/start-here/architecture.md @@ -0,0 +1,38 @@ +# Our Architecture + +

      How Vyla Home, SDK, and Stream API relate, and where each responsibility belongs.

      + +## The Vyla stack + +Vyla separates local application management, provider resolution, and client-facing delivery. That separation lets each layer be used on its own while keeping the integration points clear. + +### Vyla Home + +Home is responsible for a desktop session: local configuration, encrypted local credentials, update coordination, managed processes, and the embedded interface. It is the right layer for launching and supervising a local experience. + +### Vyla SDK + +The SDK contains the provider-facing work: source configuration, stream resolution, subtitle retrieval, download lookup, and health probes. It is the right layer when your Node.js application needs to decide how requests are authorized, cached, selected, and returned. + +### Stream API + +Stream API wraps the SDK in a self-hosted HTTP service. Its movie and TV endpoints use Server-Sent Events so metadata can arrive first and usable sources can be delivered progressively. It also provides subtitle, download, health, test, and optional debug routes. + +## Common deployment patterns + +| Pattern | Best for | Key decision | +| --- | --- | --- | +| Home only | a managed local desktop experience | Home owns the lifecycle of bundled local services | +| App + SDK | a Node.js backend with a custom product API | your app owns authentication, policy, and response design | +| Player + Stream API | browser or mobile clients that need a stable HTTP contract | deploy Stream API and place it behind TLS when exposed remotely | +| Home + Stream API | a local player backed by a local service | keep the service bound and access-controlled for the local environment | + +## Data flow for playback + +1. A client provides a TMDB media ID (plus season and episode for TV). +2. Stream API, or your own SDK-based backend, asks active providers for candidates. +3. Candidates are checked and successful sources are returned progressively. +4. The player begins with the first compatible candidate and keeps later candidates as fallbacks. +5. Subtitle and download lookups are separate operations; neither should block first playback. + +Provider availability varies by source and network environment. Build the player experience around fallbacks and empty-result states, as described in [Error handling](/guides/error-handling). \ No newline at end of file diff --git a/start-here/introduction.md b/start-here/introduction.md new file mode 100644 index 0000000..80f36e0 --- /dev/null +++ b/start-here/introduction.md @@ -0,0 +1,54 @@ +# Vyla Entertainment + +

      A community-built media project focused on freedom, privacy, and giving people control over their own experience.

      + +A collage of films and shows available through a Vyla experience + +## About Vyla + +Vyla is a community-built media project created around a simple idea: **people should have control over how they experience their media.** + +We started Vyla because we wanted something clean, private, and easy to use without relying on a single company or centralized service. What began as a small project has grown into a collection of applications and tools for desktop, Android, Android TV, and more. + +Vyla is not a streaming service, and we do not host or provide the media itself. Instead, we build the software that brings the experience together while leaving control in the hands of the person using it. + +## Where we come from + +Vyla started as a small web project focused on creating a better way to discover and watch movies and shows. Over time, it grew into a broader project with applications for different platforms alongside the tools that power them. + +We care about keeping Vyla open, accessible, and community-driven. Our project has also been shared by communities such as **FMHY** and **r/Piracy**, where people interested in alternative and self-hosted media solutions have discovered Vyla. + +We are proud to be part of a wider community of people who build, share, and explore alternatives to traditional media platforms. + +## What we make + +Vyla is made up of several projects designed for different ways of using the platform. + +| Platform | Description | Link | +| --- | --- | --- | +| Vyla Home | Desktop app | [Learn more](/products/home) | +| Vyla Go | Android app | [Learn more](/products/go) | +| Vyla TV | Android TV app | [Learn more](/products/tv) | +| Vyla SDK | Developer SDK | [Learn more](/sdk) | +| Stream API | Self-hosted API | [Learn more](/stream-api/overview) | + +## Made for people, not platforms + +We believe software should give people more control, not less. + +That means keeping Vyla local and self-hostable, avoiding unnecessary centralized dependencies, and allowing developers and users to decide how they want to use the project. + +Vyla can be as simple as a TV or mobile app, a desktop application, or the foundation for a completely custom media experience. **You decide how much of Vyla you use and where it runs.** + +## Start exploring + +- **On desktop?** See [Vyla Home](/products/home). +- **On Android?** See [Vyla Go](/products/go). +- **Using Android TV?** See [Vyla TV](/products/tv). +- **Building something with Vyla?** Start with the [SDK quickstart](/sdk/vyla-sdk/quickstart). +- **Building your own client?** See the [Stream API](/stream-api/overview). +- **Want to understand how everything fits together?** Visit [Our Architecture](/start-here/architecture). + +:::callout-info +Vyla is self-hosted software and does not host or distribute media. Please respect applicable laws, upstream terms, and rights-holder requests. See the [DMCA policy](/misc/project/dmca) for removal information. +::: \ No newline at end of file diff --git a/start-here/who-are-we.md b/start-here/who-are-we.md new file mode 100644 index 0000000..1fde1dd --- /dev/null +++ b/start-here/who-are-we.md @@ -0,0 +1,17 @@ +# Welcome to Vyla Documentation + +This is the main documentation page for Vyla. Here you can find information about the product, its features, and how to use it. + +You may wonder who **we are**? + +Vyla started from the web project which initially intended to create a clean environment where one could stream movies and TV shows free from all the unnecessary elements. Originally, it was developed mainly as a frontend which embedded other players. With time I got involved in the process of developing the infrastructure. + +Another core component of Vyla is Vyla API which dealt with searching streams from various providers, verifying their availability and playing them. Later on, it evolved into being able to cope with many kinds of streams, deal with possible issues related to their quality and reliability. + +With constant changing and breakage of providers, I had to develop Vyla SDK. The purpose of it was to isolate the source implementations from the rest of Vyla so that they could be independently managed and used in other projects. I made each source the provider modules which had the same interface. Vyla SDK remains updated as sources change. + +Among the main challenges associated with the development of Vyla was hosting. Initially I did self-hosting of frontend, API, player, Nginx, PM2 and other infrastructure on a VPS. Managing them was a struggle with constant downtime, provider issues, Cloudflare problems and, lastly the DMCA complains. + +However, after receiving a DMCA strike and experiencing problems due to frequent reports in Cloudflare, I figured out that it wouldn’t be efficient to self host the streaming infrastructure. Therefore, the choice was made to focus more on SDK and the application rather than using a remote backend. + +Thanks for Reading ❤️ \ No newline at end of file diff --git a/stream-api/deployment/self-hosting.md b/stream-api/deployment/self-hosting.md new file mode 100644 index 0000000..c57590c --- /dev/null +++ b/stream-api/deployment/self-hosting.md @@ -0,0 +1,121 @@ +# Self-host Stream API + +

      Deploy the optional Stream API when your clients need an HTTP/SSE layer.

      + +## Overview + +Stream API is a **self-hosted Node.js** application that you run on infrastructure you control. It does not provide a public hosted instance. The service runs as a Node.js process, uses in-memory caching, and can fork workers through Node's built-in `cluster` module. No database is required by the service itself. + +:::callout-warning +Stream API is self-hosted only. There is no public Vyla streaming endpoint. You only need it when your architecture requires an HTTP/SSE layer; Node.js applications can use [Vyla SDK](/sdk) directly. +::: + +## Requirements + +- Node.js 18 or later +- A TMDB API key if you want title validation and metadata lookups + +## Environment variables + +Create a `.env` file in the project root: + +```bash +TMDB_API_KEY= +PORT=7860 +WORKER_COUNT=1 +ENABLE_DEBUG_ROUTE=false +PROXY_STREAMS=false +PROXY_URL= + +GA_MEASUREMENT_ID= +GA_API_SECRET= +``` + +
      +
      Variable reference
      +
      + +| Variable | Required | Description | +| --- | --- | --- | +| `TMDB_API_KEY` | Yes | Enables TMDB-based ID validation, metadata lookups, and anime detection. Some sources will not work without it. | +| `GA_MEASUREMENT_ID` / `GA_API_SECRET` | No | Enables Google Analytics event reporting. Leave blank to disable. | +| `PORT` | No | Port the server listens on. Defaults to `7860`. | +| `WORKER_COUNT` | No | Number of cluster workers to fork. Defaults to `1` locally. | +| `ENABLE_DEBUG_ROUTE` | No | Set to `true` to enable `/api/debug/:id`. Keep this `false` in production — it exposes upstream request/response headers and raw source output. | +| `PROXY_STREAMS` | No | Set to `true` to route stream playback through your own server's `/api` proxy endpoint instead of direct URLs. | +| `PROXY_URL` | No | An external proxy base URL to use instead of the built-in `/api` proxy. Only used if `PROXY_STREAMS` is enabled. | + +
      +
      + +## Installing and running + +```bash +git clone https://gitlab.com/vyla-entertainment/stream-api +cd stream-api +npm install +node server.js +``` + +The server listens on: + +``` +http://localhost:7860 +``` + +Visit `/health` to confirm it is running and check which sources are currently reachable from your host. + +## Running with multiple workers + +Setting `WORKER_COUNT` above `1` forks additional worker processes via `cluster`. + +The primary process holds a shared in-memory cache (capped at 1500 entries, pruned every 30 seconds) and relays cache reads/writes between workers over IPC, allowing cached stream results to be shared rather than duplicated. + +```bash +WORKER_COUNT=4 +node server.js +``` + +Each worker independently loads source modules and handles outbound requests. Only shared cache operations and concurrency limits are coordinated through the primary process. + +## Network considerations + +Several sources are documented in `config.js` as blocked on datacenter IPs. This commonly affects cloud hosting providers and VPS environments. + +If a source's `getStream` consistently returns nothing from your server but works from a residential connection, the cause is usually upstream blocking based on IP reputation. For the best experience, run Vyla API on a residential connection or use a residential proxy. + +When running on a local desktop environment, you should have minimal issues with source availability since you're using a residential IP address. + +## Reverse proxy / TLS + +The server runs over plain HTTP. For public deployments, place it behind a reverse proxy such as Caddy, Nginx, or Cloudflare Tunnel to terminate TLS and forward requests to the configured `PORT`. + +CORS is already configured at the application layer with: + +``` +Access-Control-Allow-Origin: * +``` + +No additional CORS configuration is required on the proxy. + +## Verifying your deployment + +Run these checks in order: + +1. `GET /health` + + Confirms the process is running and reports per-source reachability from your host. + +2. `GET /api/test/155?source=vidrock` + + Runs a real source resolution using a known TMDB movie ID and returns whether the source succeeded. + +3. `GET /api/debug/155?source=vidrock` + + Only available when `ENABLE_DEBUG_ROUTE=true`. + + Returns request traces, headers, and raw candidate URLs for debugging source failures. + +:::callout-warning +Leave `ENABLE_DEBUG_ROUTE` disabled on public deployments. The debug route exposes upstream request information and raw stream URLs, which can leak internal details. +::: \ No newline at end of file diff --git a/stream-api/media-data/index.html b/stream-api/media-data/index.html deleted file mode 100644 index 3712415..0000000 --- a/stream-api/media-data/index.html +++ /dev/null @@ -1,199 +0,0 @@ - - - - - - - Subtitles and downloads — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      Subtitles and downloads

      -

      Unified media-data paths for movies and TV episodes.

      -

      Subtitle paths

      - - - - - - - - - - - - - - - - - -
      MediaPath
      MovieGET /subtitles/movie/:id
      TV episodeGET /subtitles/tv/:id/:season/:episode
      -

      The same routes are available under /api/subtitles. A successful response is a JSON array of - tracks; a missing result returns 404 with an error object.

      -
      [
      -  {
      -    "label": "English",
      -    "file": "https://example.invalid/subtitles/en.vtt",
      -    "type": "vtt",
      -    "source": "v1"
      -  }
      -]
      -
      -

      file is the track URL, type is normally vtt or srt, and - source identifies the subtitle backend. Prefer the tracks included in the playback - meta event when you are already opening a playback stream; use these paths when subtitles are - fetched separately. -

      -

      Download paths

      - - - - - - - - - - - - - - - - - -
      MediaPath
      MovieGET /downloads/movie/:id
      TV episodeGET /downloads/tv/:id/:season/:episode
      -

      The /api/downloads prefix is also supported. A successful response is an array; an empty array - simply means no candidates were found.

      -
      [
      -  {
      -    "url": "https://example.invalid/file.mp4",
      -    "quality": "1080p",
      -    "size": "2.14 GB",
      -    "format": "MP4"
      -  }
      -]
      -
      -

      Download URLs are provider results, not durable Vyla-hosted files. Display quality and size as informational - fields, and handle expired or unavailable links in your client.

      -

      Request and response rules

      -
        -
      • IDs are TMDB IDs; TV paths additionally require season and episode numbers.
      • -
      • Both families return JSON, unlike the SSE playback paths.
      • -
      • Handle 404 for missing subtitles and 500 for an upstream or resolver failure. -
      • -
      • Do not block first playback on either call. Fetch them in parallel or after the player has started.
      • -
      -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/stream-api/operations/index.html b/stream-api/operations/index.html deleted file mode 100644 index 56e1a0b..0000000 --- a/stream-api/operations/index.html +++ /dev/null @@ -1,167 +0,0 @@ - - - - - - - Health, testing, and safe operations — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      Health, testing, and safe operations

      -

      Observe SDK providers and diagnose a Stream API deployment without making debug data - public.

      -

      Health

      -

      GET /health (or /api/health) probes active providers and returns a JSON summary. It - includes a status, timestamp, TMDB-key presence, cache size, and a map of provider keys to - { ok, ms } results. -

      -

      Health is a signal, not a promise of playback. Provider behavior can differ by title, client network, and the - moment of the request. Use it for observability and source selection—not as a guarantee that a viewer will - receive a stream.

      -

      Test one provider

      -

      GET /test/:id?source=:providerKey resolves a single provider for a movie. Add - season and episode (or s and e) for TV. -

      -
      curl "http://localhost:7860/test/155?source=provider-key"
      -curl "http://localhost:7860/test/1396?season=1&episode=1&source=provider-key"
      -
      -

      Use source keys reported by the running deployment. A test result reports whether resolution and verification - succeeded; clients should inspect the result body rather than relying only on a transport success.

      -

      Debug route

      -

      GET /debug/:id?source=:providerKey offers detailed candidate and request traces, but it is only - available when ENABLE_DEBUG_ROUTE=true.

      -
      -
      -

      Do not enable the debug route on a public deployment. It can expose upstream request data, raw - candidates, and headers. Enable it temporarily in a controlled environment, then disable it again.

      -
      -
      -

      Production checklist

      -
        -
      • Set TMDB_API_KEY when metadata-dependent sources are required.
      • -
      • Keep ENABLE_DEBUG_ROUTE=false outside controlled troubleshooting.
      • -
      • Use TLS and enforce your own access policy before making the service remotely reachable.
      • -
      • Monitor health trends and playback failures separately.
      • -
      • Run updates and provider changes through your normal deployment process.
      • -
      • Build player fallbacks instead of assuming a specific provider is always available.
      • -
      -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/stream-api/overview/index.html b/stream-api/overview/index.html deleted file mode 100644 index 20b40e9..0000000 --- a/stream-api/overview/index.html +++ /dev/null @@ -1,194 +0,0 @@ - - - - - - - Stream API — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      Stream API

      -

      An optional self-hosted HTTP and SSE layer built on top of Vyla SDK.

      -

      Use this only when HTTP is the right boundary

      -

      Stream API packages Vyla SDK behind a small Node.js HTTP service. It is useful when a browser, mobile app, or - another process needs progressive playback candidates over HTTP. It is not required to use Vyla, and it is not - the primary integration for a Node.js application—use Vyla SDK directly in that case. -

      -

      The service accepts TMDB IDs, asks configured SDK providers for candidates, verifies results, and emits - usable sources as they arrive. A player can start with the first source while retaining later sources as - fallbacks.

      -

      What it provides

      - - - - - - - - - - - - - - - - - - - - - - - - - -
      CapabilityEndpoint familyWhy it exists
      Progressive playback/movie, /tvStream metadata and verified source candidates with SSE
      Supplemental media/subtitles, /downloadsFetch tracks or downloadable candidates separately
      Operations/health, /test, /debugCheck source status and investigate a provider when permitted
      -

      Start a local instance

      -
      git clone https://gitlab.com/vyla-entertainment/stream-api
      -cd stream-api
      -npm install
      -TMDB_API_KEY=your_key
      -node server.js
      -
      -

      The default local address is http://localhost:7860. For remote access, place the service behind - TLS and your own access controls. Full configuration is in Self-hosting.

      -

      Lifecycle of a playback request

      -
        -
      1. A client opens /movie?id=:tmdbId or - /tv?id=:tmdbId&season=:season&episode=:episode. -
      2. -
      3. The API returns an SSE response and emits a meta event with title metadata and subtitle - tracks when available.
      4. -
      5. Active SDK providers resolve in parallel; verified results arrive as individual source - events.
      6. -
      7. The server sends done after all source attempts finish or time out.
      8. -
      -

      Start the player on the first source. Never wait for done before allowing playback, and always - retain a fallback queue. See Paths and events for the contract.

      -
      -
      -

      If your application is already a Node.js service, call VylaSDK#getStream, - getSubtitles, and getDownloads directly. That removes an unnecessary network hop - and lets you define your own public API. -

      -
      -
      -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/stream-api/overview/index.md b/stream-api/overview/index.md new file mode 100644 index 0000000..dfa3098 --- /dev/null +++ b/stream-api/overview/index.md @@ -0,0 +1,36 @@ +# Stream API + +

      An optional self-hosted HTTP and SSE layer built on top of Vyla SDK.

      + +## Use this only when HTTP is the right boundary + +Stream API packages Vyla SDK behind a small Node.js HTTP service. It is useful when a browser, mobile app, or another process needs progressive playback candidates over HTTP. It is not required to use Vyla, and it is not the primary integration for a Node.js application—use [Vyla SDK](/sdk) directly in that case. + +The service accepts TMDB IDs, asks configured SDK providers for candidates, verifies results, and emits usable sources as they arrive. A player can start with the first source while retaining later sources as fallbacks. + +## What it provides + +| Capability | Endpoint family | Why it exists | +| --- | --- | --- | +| Progressive playback | `/movie`, `/tv` | Stream metadata and verified source candidates with SSE | +| Supplemental media | `/subtitles`, `/downloads` | Fetch tracks or downloadable candidates separately | +| Operations | `/health`, `/test`, `/debug` | Check source status and investigate a provider | + +## Start a local instance + +```bash +git clone https://gitlab.com/vyla-entertainment/stream-api +cd stream-api +npm install +TMDB_API_KEY=your_key +node server.js +``` + +The default local address is `http://localhost:7860`. For remote access, place the service behind TLS and your own access controls. Full configuration is in [Self-hosting](/stream-api/deployment/self-hosting). + +## Lifecycle of a playback request + +1. A client opens `/movie?id=:tmdbId` or `/tv?id=:tmdbId&season=:season&episode=:episode`. +2. The API returns an SSE response and emits a `meta` event with title metadata and subtitle tracks. +3. Active SDK providers resolve in parallel; verified results arrive as individual `source` events. +4. The server sends `done` after all source attempts finish or time out. \ No newline at end of file diff --git a/stream-api/paths/index.html b/stream-api/paths/index.html deleted file mode 100644 index 467eb94..0000000 --- a/stream-api/paths/index.html +++ /dev/null @@ -1,196 +0,0 @@ - - - - - - - Paths and playback events — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      Paths and playback events

      -

      The complete Stream API playback contract, unified for movies and TV episodes.

      -

      Playback paths

      -

      The API supports both the short paths below and /api-prefixed equivalents. A movie needs only - id; a TV episode also needs season and episode. -

      - - - - - - - - - - - - - - - - - - - - -
      MediaPathResponse
      MovieGET /movie?id=:idServer-Sent Events
      TV episodeGET /tv?id=:id&season=:season&episode=:episodeServer-Sent Events
      -

      id is a TMDB ID. For example:

      -
      curl -N "http://localhost:7860/movie?id=155"
      -curl -N "http://localhost:7860/tv?id=1396&season=1&episode=1"
      -
      -

      Event contract

      -

      Playback routes use text/event-stream, not a single JSON document. Read each data: - line as JSON.

      -

      meta

      -

      Sent before source resolution completes. It can include TMDB metadata and an array of subtitle tracks.

      -
      {
      -  "type": "meta",
      -  "meta": { "id": 155, "title": "The Dark Knight" },
      -  "subtitles": [{ "label": "English", "file": "https://…", "type": "vtt", "source": "v1" }]
      -}
      -
      -

      source

      -

      One event for each candidate that passes the service's verification pipeline.

      -
      {
      -  "type": "source",
      -  "source": {
      -    "source": "provider-key",
      -    "label": "Provider name",
      -    "url": "http://localhost:7860/api?url=…"
      -  }
      -}
      -
      -

      The url is ready for a player. HLS sources may be wrapped by Vyla's proxy so manifest segments - and keys can be fetched with the appropriate upstream headers. Some configured sources bypass proxying. Do not - assume a filename extension alone determines playback behavior.

      -

      done

      -

      Sent after the configured attempts are complete.

      -
      { "type": "done", "total": 3 }
      -
      -

      total: 0 means no playable sources were produced for that request. The SSE connection itself can - still be successful.

      -

      Client rules

      -
        -
      1. Start playback as soon as the first source arrives.
      2. -
      3. Queue every later source for manual switching or automatic fallback.
      4. -
      5. Attach subtitle tracks from meta without blocking playback.
      6. -
      7. Treat an empty result as a normal product state, not a malformed response.
      8. -
      9. Abort the request when the viewer leaves the page or chooses another title.
      10. -
      -

      The integration guide contains a practical player pattern; error handling covers fallback behavior.

      -
      - -
      -
      - - - - - \ No newline at end of file diff --git a/stream-api/reference/media-data.md b/stream-api/reference/media-data.md new file mode 100644 index 0000000..3652da4 --- /dev/null +++ b/stream-api/reference/media-data.md @@ -0,0 +1,54 @@ +# Subtitles and downloads + +

      Unified media-data paths for movies and TV episodes.

      + +## Subtitle paths + +| Media | Path | +| --- | --- | +| Movie | `GET /subtitles/movie/:id` | +| TV episode | `GET /subtitles/tv/:id/:season/:episode` | + +The same routes are available under `/api/subtitles`. A successful response is a JSON array of tracks; a missing result returns `404` with an error object. + +```json +[ + { + "label": "English", + "file": "https://example.invalid/subtitles/en.vtt", + "type": "vtt", + "source": "v1" + } +] +``` + +`file` is the track URL, `type` is normally `vtt` or `srt`, and `source` identifies the subtitle backend. Prefer the tracks included in the playback `meta` event when you are already opening a playback stream; use these paths when subtitles are fetched separately. + +## Download paths + +| Media | Path | +| --- | --- | +| Movie | `GET /downloads/movie/:id` | +| TV episode | `GET /downloads/tv/:id/:season/:episode` | + +The `/api/downloads` prefix is also supported. A successful response is an array; an empty array simply means no candidates were found. + +```json +[ + { + "url": "https://example.invalid/file.mp4", + "quality": "1080p", + "size": "2.14 GB", + "format": "MP4" + } +] +``` + +Download URLs are provider results, not durable Vyla-hosted files. Display quality and size as informational fields, and handle expired or unavailable links in your client. + +## Request and response rules + +- IDs are TMDB IDs; TV paths additionally require season and episode numbers. +- Both families return JSON, unlike the SSE playback paths. +- Handle `404` for missing subtitles and `500` for an upstream or resolver failure. +- Do not block first playback on either call. Fetch them in parallel or after the player has started. \ No newline at end of file diff --git a/stream-api/reference/operations.md b/stream-api/reference/operations.md new file mode 100644 index 0000000..2b37043 --- /dev/null +++ b/stream-api/reference/operations.md @@ -0,0 +1,37 @@ +# Health, testing, and safe operations + +

      Observe SDK providers and diagnose a Stream API deployment without making debug data public.

      + +## Health + +`GET /health` (or `/api/health`) probes active providers and returns a JSON summary. It includes a status, timestamp, TMDB-key presence, cache size, and a map of provider keys to `{ ok, ms }` results. + +Health is a signal, not a promise of playback. Provider behavior can differ by title, client network, and the moment of the request. Use it for observability and source selection—not as a guarantee that a viewer will receive a stream. + +## Test one provider + +`GET /test/:id?source=:providerKey` resolves a single provider for a movie. Add `season` and `episode` (or `s` and `e`) for TV. + +```bash +curl "http://localhost:7860/test/155?source=provider-key" +curl "http://localhost:7860/test/1396?season=1&episode=1&source=provider-key" +``` + +Use source keys reported by the running deployment. A test result reports whether resolution and verification succeeded; clients should inspect the result body rather than relying only on a transport success. + +## Debug route + +`GET /debug/:id?source=:providerKey` offers detailed candidate and request traces, but it is only available when `ENABLE_DEBUG_ROUTE=true`. + +:::callout-warning +Do not enable the debug route on a public deployment. It can expose upstream request data, raw candidates, and headers. Enable it temporarily in a controlled environment, then disable it again. +::: + +## Production checklist + +- Set `TMDB_API_KEY` when metadata-dependent sources are required. +- Keep `ENABLE_DEBUG_ROUTE=false` outside controlled troubleshooting. +- Use TLS and enforce your own access policy before making the service remotely reachable. +- Monitor health trends and playback failures separately. +- Run updates and provider changes through your normal deployment process. +- Build player fallbacks instead of assuming a specific provider is always available. \ No newline at end of file diff --git a/stream-api/reference/paths.md b/stream-api/reference/paths.md new file mode 100644 index 0000000..cbfff75 --- /dev/null +++ b/stream-api/reference/paths.md @@ -0,0 +1,72 @@ +# Paths and playback events + +

      The complete Stream API playback contract, unified for movies and TV episodes.

      + +## Playback paths + +The API supports both the short paths below and `/api`-prefixed equivalents. A movie needs only `id`; a TV episode also needs `season` and `episode`. + +| Media | Path | Response | +| --- | --- | --- | +| Movie | `GET /movie?id=:id` | Server-Sent Events | +| TV episode | `GET /tv?id=:id&season=:season&episode=:episode` | Server-Sent Events | + +`id` is a TMDB ID. For example: + +```bash +curl -N "http://localhost:7860/movie?id=155" +curl -N "http://localhost:7860/tv?id=1396&season=1&episode=1" +``` + +## Event contract + +Playback routes use `text/event-stream`, not a single JSON document. Read each `data: ` line as JSON. + +### `meta` + +Sent before source resolution completes. It can include TMDB metadata and an array of subtitle tracks. + +```json +{ + "type": "meta", + "meta": { "id": 155, "title": "The Dark Knight" }, + "subtitles": [{ "label": "English", "file": "https://…", "type": "vtt", "source": "v1" }] +} +``` + +### `source` + +One event for each candidate that passes the service's verification pipeline. + +```json +{ + "type": "source", + "source": { + "source": "provider-key", + "label": "Provider name", + "url": "http://localhost:7860/api?url=…" + } +} +``` + +The `url` is ready for a player. HLS sources may be wrapped by Vyla's proxy so manifest segments and keys can be fetched with the appropriate upstream headers. Some configured sources bypass proxying. Do not assume a filename extension alone determines playback behavior. + +### `done` + +Sent after the configured attempts are complete. + +```json +{ "type": "done", "total": 3 } +``` + +`total: 0` means no playable sources were produced for that request. The SSE connection itself can still be successful. + +## Client rules + +1. Start playback as soon as the first `source` arrives. +2. Queue every later source for manual switching or automatic fallback. +3. Attach subtitle tracks from `meta` without blocking playback. +4. Treat an empty result as a normal product state, not a malformed response. +5. Abort the request when the viewer leaves the page or chooses another title. + +The [integration guide](/guides/integrations) contains a practical player pattern; [error handling](/guides/error-handling) covers fallback behavior. \ No newline at end of file diff --git a/tv/index.html b/tv/index.html deleted file mode 100644 index ee043d8..0000000 --- a/tv/index.html +++ /dev/null @@ -1,157 +0,0 @@ - - - - - - - Vyla TV — Vyla - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      - -
      -
      -

      Vyla TV

      -

      The Android TV application for running the Vyla media experience on your television. -

      - A collage of films and shows available through a Vyla experience -

      Vyla TV

      -

      Vyla TV is an Android TV streaming app built with Jetpack Compose and ExoPlayer that lets - you browse and watch movies and TV shows from TMDB.

      -

      Downloads

      -

      Vyla TV is available for Android TV. Get the latest version below:

      - - - - - - - - - - - - - -
      PlatformDownload
      Android TVDownload .apk
      -

      Inside the releases page, you can find the latest version of Vyla TV for Android TV, find the - .apk file, and install it on your device. -

      -
      - -
      -
      - - - - - \ No newline at end of file