Deploy local stack fixes and search fallback
This commit is contained in:
+2
-2
@@ -3,7 +3,7 @@
|
|||||||
# 2) nginx stage to serve frontend assets
|
# 2) nginx stage to serve frontend assets
|
||||||
|
|
||||||
# Name the node stage "builder"
|
# Name the node stage "builder"
|
||||||
FROM docker.io/node:16 AS builder
|
FROM docker.io/node:22 AS builder
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ COPY ./ .
|
|||||||
|
|
||||||
RUN ls
|
RUN ls
|
||||||
# install node modules and build assets
|
# install node modules and build assets
|
||||||
RUN npm i && npm run build
|
RUN npm i && npm run build-only
|
||||||
|
|
||||||
# nginx state for serving content
|
# nginx state for serving content
|
||||||
FROM docker.io/nginx:alpine
|
FROM docker.io/nginx:alpine
|
||||||
|
|||||||
+103
-10
@@ -1,19 +1,49 @@
|
|||||||
version: "3.7"
|
name: safetwitch
|
||||||
|
|
||||||
services:
|
services:
|
||||||
safetwitch-frontend:
|
safetwitch-frontend:
|
||||||
container_name: safetwitch-frontend
|
container_name: safetwitch-frontend
|
||||||
hostname: safetwitch-frontend
|
hostname: safetwitch-frontend
|
||||||
restart: always
|
restart: always
|
||||||
image: codeberg.org/safetwitch/safetwitch:latest
|
image: safetwitch-local:latest
|
||||||
|
build:
|
||||||
|
context: "../"
|
||||||
|
dockerfile: "./docker/Dockerfile"
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8280:8280"
|
- "127.0.0.1:${SAFETWITCH_FRONTEND_PORT:-8280}:8280"
|
||||||
environment:
|
environment:
|
||||||
- SAFETWITCH_BACKEND_DOMAIN=changeme
|
- SAFETWITCH_BACKEND_DOMAIN=${SAFETWITCH_BACKEND_DOMAIN:-api.safetwitch.darkness.services}
|
||||||
- SAFETWITCH_INSTANCE_DOMAIN=changeme
|
- SAFETWITCH_INSTANCE_DOMAIN=${SAFETWITCH_INSTANCE_DOMAIN:-safetwitch.darkness.services}
|
||||||
- SAFETWITCH_HTTPS=true
|
- SAFETWITCH_HTTPS=${SAFETWITCH_HTTPS:-true}
|
||||||
- SAFETWITCH_DEFAULT_LOCALE=en
|
- SAFETWITCH_DEFAULT_LOCALE=${SAFETWITCH_DEFAULT_LOCALE:-en}
|
||||||
- SAFETWITCH_FALLBACK_LOCALE=en
|
- SAFETWITCH_FALLBACK_LOCALE=${SAFETWITCH_FALLBACK_LOCALE:-en}
|
||||||
|
- SAFETWITCH_DEFAULT_THEME=${SAFETWITCH_DEFAULT_THEME:-dark}
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.safetwitch.rule=Host(`safetwitch.darkness.services`)"
|
||||||
|
- "traefik.http.routers.safetwitch.entrypoints=https"
|
||||||
|
- "traefik.http.routers.safetwitch.tls.certresolver=myresolver"
|
||||||
|
- "traefik.http.services.safetwitch.loadbalancer.server.port=8280"
|
||||||
|
|
||||||
|
safetwitch-frontend-tor:
|
||||||
|
container_name: safetwitch-frontend-tor
|
||||||
|
hostname: safetwitch-frontend-tor
|
||||||
|
restart: always
|
||||||
|
image: safetwitch-local:latest
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${SAFETWITCH_FRONTEND_TOR_PORT:-8281}:8280"
|
||||||
|
environment:
|
||||||
|
- SAFETWITCH_BACKEND_DOMAIN=api.safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion
|
||||||
|
- SAFETWITCH_INSTANCE_DOMAIN=safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion
|
||||||
|
- SAFETWITCH_HTTPS=false
|
||||||
|
- SAFETWITCH_DEFAULT_LOCALE=${SAFETWITCH_DEFAULT_LOCALE:-en}
|
||||||
|
- SAFETWITCH_FALLBACK_LOCALE=${SAFETWITCH_FALLBACK_LOCALE:-en}
|
||||||
|
- SAFETWITCH_DEFAULT_THEME=${SAFETWITCH_DEFAULT_THEME:-dark}
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.services.safetwitch-tor.loadbalancer.server.port=8280"
|
||||||
|
- "traefik.http.routers.safetwitch-tor.entrypoints=tor"
|
||||||
|
- "traefik.http.routers.safetwitch-tor.rule=Host(`safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion`)"
|
||||||
|
|
||||||
safetwitch-backend:
|
safetwitch-backend:
|
||||||
container_name: safetwitch-backend
|
container_name: safetwitch-backend
|
||||||
@@ -26,7 +56,70 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
image: codeberg.org/safetwitch/safetwitch-backend:latest
|
image: codeberg.org/safetwitch/safetwitch-backend:latest
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:7100:7000"
|
- "127.0.0.1:${SAFETWITCH_BACKEND_PORT:-7100}:7000"
|
||||||
environment:
|
environment:
|
||||||
- PORT=7000
|
- PORT=7000
|
||||||
- URL=changeme
|
- URL=${SAFETWITCH_BACKEND_URL:-https://api.safetwitch.darkness.services}
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.apisafetwitch.rule=Host(`api.safetwitch.darkness.services`)"
|
||||||
|
- "traefik.http.routers.apisafetwitch.entrypoints=https"
|
||||||
|
- "traefik.http.routers.apisafetwitch.tls.certresolver=myresolver"
|
||||||
|
- "traefik.http.services.apisafetwitch.loadbalancer.server.port=7000"
|
||||||
|
|
||||||
|
safetwitch-backend-tor:
|
||||||
|
container_name: safetwitch-backend-tor
|
||||||
|
hostname: safetwitch-backend-tor
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
restart: always
|
||||||
|
image: codeberg.org/safetwitch/safetwitch-backend:latest
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${SAFETWITCH_BACKEND_TOR_PORT:-7101}:7000"
|
||||||
|
environment:
|
||||||
|
- PORT=7000
|
||||||
|
- URL=http://api.safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.apisafetwitch-tor.rule=Host(`api.safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion`)"
|
||||||
|
- "traefik.http.routers.apisafetwitch-tor.entrypoints=tor"
|
||||||
|
- "traefik.http.services.apisafetwitch-tor.loadbalancer.server.port=7000"
|
||||||
|
|
||||||
|
safetwitch-search-fallback:
|
||||||
|
container_name: safetwitch-search-fallback
|
||||||
|
hostname: safetwitch-search-fallback
|
||||||
|
restart: always
|
||||||
|
image: docker.io/node:22-alpine
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
command: ["node", "/app/server.mjs"]
|
||||||
|
volumes:
|
||||||
|
- "./search-fallback/server.mjs:/app/server.mjs:ro"
|
||||||
|
environment:
|
||||||
|
- PORT=7171
|
||||||
|
- BACKEND_BASE_URL=http://safetwitch-backend:7000
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.apisafetwitch-search.rule=Host(`api.safetwitch.darkness.services`) && PathPrefix(`/api/search`)"
|
||||||
|
- "traefik.http.routers.apisafetwitch-search.entrypoints=https"
|
||||||
|
- "traefik.http.routers.apisafetwitch-search.tls.certresolver=myresolver"
|
||||||
|
- "traefik.http.routers.apisafetwitch-search.priority=100"
|
||||||
|
- "traefik.http.routers.apisafetwitch-search.service=apisafetwitch-search"
|
||||||
|
- "traefik.http.routers.apisafetwitch-search-tor.rule=Host(`api.safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion`) && PathPrefix(`/api/search`)"
|
||||||
|
- "traefik.http.routers.apisafetwitch-search-tor.entrypoints=tor"
|
||||||
|
- "traefik.http.routers.apisafetwitch-search-tor.priority=100"
|
||||||
|
- "traefik.http.routers.apisafetwitch-search-tor.service=apisafetwitch-search"
|
||||||
|
- "traefik.http.services.apisafetwitch-search.loadbalancer.server.port=7171"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: ${SAFETWITCH_DOCKER_NETWORK:-npm_default}
|
||||||
|
external: ${SAFETWITCH_DOCKER_NETWORK_EXTERNAL:-true}
|
||||||
|
|||||||
+18
-1
@@ -4,9 +4,26 @@ http {
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
listen 8280;
|
listen 8280;
|
||||||
access_log /dev/null;
|
access_log /dev/null;
|
||||||
error_log /dev/null;
|
error_log /dev/null;
|
||||||
|
|
||||||
|
# Forward backend API/proxy calls so absolute frontend-host URLs in
|
||||||
|
# manifests (e.g. /proxy/stream/sub/...) continue to work.
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://safetwitch-backend:7000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /proxy/ {
|
||||||
|
proxy_pass http://safetwitch-backend:7000/proxy/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
root /app;
|
root /app;
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import http from 'node:http'
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT || 7171)
|
||||||
|
const backendBaseUrl = (process.env.BACKEND_BASE_URL || 'http://safetwitch-backend:7000').replace(
|
||||||
|
/\/+$/,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
|
||||||
|
const jsonHeaders = {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Access-Control-Allow-Origin': '*'
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyResult = () => ({
|
||||||
|
channels: [],
|
||||||
|
categories: [],
|
||||||
|
relatedChannels: [],
|
||||||
|
channelsWithTag: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasAnyResults = (data) =>
|
||||||
|
!!data &&
|
||||||
|
(data.channels?.length ||
|
||||||
|
data.categories?.length ||
|
||||||
|
data.relatedChannels?.length ||
|
||||||
|
data.channelsWithTag?.length)
|
||||||
|
|
||||||
|
const normalize = (value) =>
|
||||||
|
String(value || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9\s]/g, ' ')
|
||||||
|
.replace(/\biv\b/g, '4')
|
||||||
|
.replace(/\biii\b/g, '3')
|
||||||
|
.replace(/\bii\b/g, '2')
|
||||||
|
.replace(/\bi\b/g, '1')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
const withRomanNumerals = (value) =>
|
||||||
|
String(value || '')
|
||||||
|
.replace(/\b4\b/gi, 'IV')
|
||||||
|
.replace(/\b3\b/gi, 'III')
|
||||||
|
.replace(/\b2\b/gi, 'II')
|
||||||
|
.replace(/\b1\b/gi, 'I')
|
||||||
|
|
||||||
|
const withArabicNumerals = (value) =>
|
||||||
|
String(value || '')
|
||||||
|
.replace(/\biv\b/gi, '4')
|
||||||
|
.replace(/\biii\b/gi, '3')
|
||||||
|
.replace(/\bii\b/gi, '2')
|
||||||
|
.replace(/\bi\b/gi, '1')
|
||||||
|
|
||||||
|
const mapDiscoverCategory = (category) => ({
|
||||||
|
name: category?.name || '',
|
||||||
|
displayName: category?.displayName || category?.name || '',
|
||||||
|
viewers: Number(category?.viewers || 0),
|
||||||
|
tags: Array.isArray(category?.tags) ? category.tags : [],
|
||||||
|
createdAt: category?.createdAt,
|
||||||
|
image: category?.image || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchBackend = async (path) => {
|
||||||
|
const response = await fetch(`${backendBaseUrl}${path}`, {
|
||||||
|
redirect: 'follow',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Backend request failed (${response.status}) for ${path}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildFallback = async (query) => {
|
||||||
|
const result = emptyResult()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channelResp = await fetchBackend(`/api/users/${encodeURIComponent(query)}`)
|
||||||
|
if (channelResp?.status === 'ok' && channelResp.data) {
|
||||||
|
result.channels.push(channelResp.data)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore fallback channel lookup failures.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const discoverResp = await fetchBackend('/api/discover')
|
||||||
|
const categories = Array.isArray(discoverResp?.data) ? discoverResp.data : []
|
||||||
|
const normalizedQuery = normalize(query)
|
||||||
|
|
||||||
|
result.categories = categories
|
||||||
|
.filter((category) => {
|
||||||
|
const displayName = normalize(category?.displayName)
|
||||||
|
const name = normalize(category?.name)
|
||||||
|
return (
|
||||||
|
displayName.includes(normalizedQuery) ||
|
||||||
|
normalizedQuery.includes(displayName) ||
|
||||||
|
name.includes(normalizedQuery) ||
|
||||||
|
normalizedQuery.includes(name)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.slice(0, 24)
|
||||||
|
} catch {
|
||||||
|
// Ignore fallback category lookup failures.
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryCandidates = [...new Set([query, withRomanNumerals(query), withArabicNumerals(query)])]
|
||||||
|
for (const candidate of categoryCandidates) {
|
||||||
|
const trimmed = candidate.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
const categoryResp = await fetchBackend(`/api/discover/${encodeURIComponent(trimmed)}`)
|
||||||
|
if (categoryResp?.status !== 'ok' || !categoryResp?.data) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = mapDiscoverCategory(categoryResp.data)
|
||||||
|
if (!mapped.name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyIncluded = result.categories.some(
|
||||||
|
(existing) => normalize(existing.name) === normalize(mapped.name)
|
||||||
|
)
|
||||||
|
if (!alreadyIncluded) {
|
||||||
|
result.categories.unshift(mapped)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore exact category lookup failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = async (query) => {
|
||||||
|
if (!query) {
|
||||||
|
return { status: 'ok', data: emptyResult() }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backendSearch = await fetchBackend(`/api/search/?query=${encodeURIComponent(query)}`)
|
||||||
|
if (backendSearch?.status === 'ok' && hasAnyResults(backendSearch.data)) {
|
||||||
|
return backendSearch
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback path below handles backend search failures.
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackData = await buildFallback(query)
|
||||||
|
return { status: 'ok', data: fallbackData }
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
if (!req.url) {
|
||||||
|
res.writeHead(400, jsonHeaders)
|
||||||
|
res.end(JSON.stringify({ status: 'error', message: 'Missing request URL' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(204, {
|
||||||
|
...jsonHeaders,
|
||||||
|
'Access-Control-Allow-Methods': 'GET,OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type,Accept-Language'
|
||||||
|
})
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url, 'http://localhost')
|
||||||
|
const query = (url.searchParams.get('query') || '').trim()
|
||||||
|
|
||||||
|
if (!url.pathname.startsWith('/api/search')) {
|
||||||
|
res.writeHead(404, jsonHeaders)
|
||||||
|
res.end(JSON.stringify({ status: 'error', message: 'Not found' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await handleSearch(query)
|
||||||
|
res.writeHead(200, jsonHeaders)
|
||||||
|
res.end(JSON.stringify(payload))
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, jsonHeaders)
|
||||||
|
res.end(JSON.stringify({ status: 'error', message: String(error) }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(port, '0.0.0.0')
|
||||||
@@ -10,22 +10,55 @@ export const createQualitySelector = (player: any) => {
|
|||||||
const qualityLevels: QualityLevelList = player.qualityLevels()
|
const qualityLevels: QualityLevelList = player.qualityLevels()
|
||||||
const MenuButton = videojs.getComponent('MenuButton')
|
const MenuButton = videojs.getComponent('MenuButton')
|
||||||
const MenuItem = videojs.getComponent('MenuItem')
|
const MenuItem = videojs.getComponent('MenuItem')
|
||||||
let formatedQualities: { name: string; index: number; id: string }[]
|
let formatedQualities: { name: string; index: number; id: string; height: number }[] = []
|
||||||
|
let startupQualityApplied = false
|
||||||
|
|
||||||
const t = i18n.global.t
|
const t = i18n.global.t
|
||||||
|
const translatedAuto = t('player.auto')
|
||||||
|
const autoLabel = translatedAuto === 'player.auto' ? 'Auto' : translatedAuto
|
||||||
|
|
||||||
const setQuality = (id: string) => {
|
const setAutoQuality = () => {
|
||||||
const found = formatedQualities.find((i) => i.id === id)
|
|
||||||
for (const quality of qualityLevels.levels_) {
|
for (const quality of qualityLevels.levels_) {
|
||||||
if (quality.id !== id) {
|
// Keep video renditions enabled and skip the audio-only pseudo-level.
|
||||||
|
quality.enabled = quality.height !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
qualityLevels.selectedIndex_ = -1
|
||||||
|
qualityLevels.trigger({ type: 'change', selectedIndex: -1 })
|
||||||
|
|
||||||
|
player.play().catch(() => {
|
||||||
|
// Ignore autoplay restrictions; user interaction can resume.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setQuality = (id?: string) => {
|
||||||
|
if (!id) {
|
||||||
|
setAutoQuality()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = formatedQualities.find((i) => i.id === id)
|
||||||
|
if (!found) {
|
||||||
|
setAutoQuality()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const quality of qualityLevels.levels_) {
|
||||||
|
if (quality.height === undefined) {
|
||||||
quality.enabled = false
|
quality.enabled = false
|
||||||
} else {
|
continue
|
||||||
quality.enabled = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep adaptive bitrate enabled up to the selected quality.
|
||||||
|
quality.enabled = quality.height <= found.height
|
||||||
}
|
}
|
||||||
|
|
||||||
qualityLevels.selectedIndex_ = found?.index
|
qualityLevels.selectedIndex_ = found?.index
|
||||||
qualityLevels.trigger({ type: 'change', selectedIndex: found?.index })
|
qualityLevels.trigger({ type: 'change', selectedIndex: found?.index })
|
||||||
|
|
||||||
|
player.play().catch(() => {
|
||||||
|
// Ignore autoplay restrictions; user interaction can resume.
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
class CustomMenuButton extends MenuButton {
|
class CustomMenuButton extends MenuButton {
|
||||||
@@ -39,6 +72,11 @@ export const createQualitySelector = (player: any) => {
|
|||||||
|
|
||||||
qualitySelectorButton.handleClick = (data) => {
|
qualitySelectorButton.handleClick = (data) => {
|
||||||
const qualityClicked = data.currentTarget.innerText
|
const qualityClicked = data.currentTarget.innerText
|
||||||
|
if (qualityClicked === autoLabel) {
|
||||||
|
setAutoQuality()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const id = formatedQualities.find((i) => i.name === qualityClicked)?.id
|
const id = formatedQualities.find((i) => i.name === qualityClicked)?.id
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -46,11 +84,6 @@ export const createQualitySelector = (player: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// set quality on startup
|
|
||||||
const startingQuality = getSetting('defaultQuality')
|
|
||||||
const id = formatedQualities.find((i) => i.name.includes(startingQuality))?.id
|
|
||||||
setQuality(id)
|
|
||||||
|
|
||||||
return qualitySelectorButton
|
return qualitySelectorButton
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -66,12 +99,27 @@ export const createQualitySelector = (player: any) => {
|
|||||||
player.controlBar.removeChild('CustomMenuButton')
|
player.controlBar.removeChild('CustomMenuButton')
|
||||||
player.controlBar.addChild('CustomMenuButton', {
|
player.controlBar.addChild('CustomMenuButton', {
|
||||||
title: t('player.quality'),
|
title: t('player.quality'),
|
||||||
items: formatedQualities
|
items: [{ name: autoLabel }, ...formatedQualities]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!startupQualityApplied) {
|
||||||
|
startupQualityApplied = true
|
||||||
|
const startingQuality = String(getSetting('defaultQuality'))
|
||||||
|
const targetHeight = Number(startingQuality.replace(/[^0-9]/g, ''))
|
||||||
|
|
||||||
|
if (!Number.isFinite(targetHeight) || targetHeight <= 0) {
|
||||||
|
setAutoQuality()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer exact/default match; otherwise keep auto mode so ABR can adapt.
|
||||||
|
const id = formatedQualities.find((i) => i.height === targetHeight)?.id
|
||||||
|
setQuality(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
qualityLevels.on('addqualitylevel', () => {
|
qualityLevels.on('addqualitylevel', () => {
|
||||||
formatedQualities = qualityLevels.levels_.map((quality: QualityLevel) => {
|
formatedQualities = qualityLevels.levels_.map((quality: QualityLevel, index: number) => {
|
||||||
let qualityFramerate = ''
|
let qualityFramerate = ''
|
||||||
|
|
||||||
if (quality.frameRate > 30) {
|
if (quality.frameRate > 30) {
|
||||||
@@ -80,8 +128,9 @@ export const createQualitySelector = (player: any) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
name: quality.height?.toString() + 'p' + qualityFramerate,
|
name: quality.height?.toString() + 'p' + qualityFramerate,
|
||||||
index: quality.id[0],
|
index,
|
||||||
id: quality.id
|
id: quality.id,
|
||||||
|
height: quality.height || 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { inject } from 'vue'
|
import { inject } from 'vue'
|
||||||
import { getEndpoint, abbreviate } from '@/mixins'
|
import { getEndpoint, postEndpoint, abbreviate } from '@/mixins'
|
||||||
import type { StreamerData, CategoryMinifiedStream } from '@/types'
|
import type { StreamerData, CategoryMinifiedStream } from '@/types'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -41,6 +41,29 @@ export default {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
data = await getEndpoint('api/users/' + props.name)
|
data = await getEndpoint('api/users/' + props.name)
|
||||||
|
|
||||||
|
if (!data.stream && props.name) {
|
||||||
|
// Fallback for backend variants where api/users marks everyone offline.
|
||||||
|
const liveUsers: string[] = await postEndpoint('api/users/isLive/bulk', {
|
||||||
|
streamers: [props.name]
|
||||||
|
}).catch(() => [])
|
||||||
|
|
||||||
|
const isActuallyLive = liveUsers.some(
|
||||||
|
(name) => name.toLowerCase() === props.name!.toLowerCase()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isActuallyLive) {
|
||||||
|
data.stream = {
|
||||||
|
title: 'Live now',
|
||||||
|
tags: [],
|
||||||
|
startedAt: 0,
|
||||||
|
topic: '',
|
||||||
|
viewers: 0,
|
||||||
|
preview: data.pfp
|
||||||
|
}
|
||||||
|
data.isLive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const frontend_url = protocol + import.meta.env.SAFETWITCH_INSTANCE_DOMAIN
|
const frontend_url = protocol + import.meta.env.SAFETWITCH_INSTANCE_DOMAIN
|
||||||
|
|||||||
@@ -25,15 +25,77 @@ export default {
|
|||||||
emits: ['PlayerTimeUpdate'],
|
emits: ['PlayerTimeUpdate'],
|
||||||
setup() {
|
setup() {
|
||||||
let player: any
|
let player: any
|
||||||
|
let lastProgressAt = 0
|
||||||
|
let lastKnownTime = 0
|
||||||
|
let recoverTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let lastRecoverAt = 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
player
|
player,
|
||||||
|
lastProgressAt,
|
||||||
|
lastKnownTime,
|
||||||
|
recoverTimer,
|
||||||
|
lastRecoverAt
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// initializing the video player
|
// initializing the video player
|
||||||
// when the component is being mounted
|
// when the component is being mounted
|
||||||
mounted() {
|
mounted() {
|
||||||
const emit = this.$emit
|
const emit = this.$emit
|
||||||
this.player = videojs('video-player', this.options, () => {
|
const isLiveStreamSource = () =>
|
||||||
|
String(this.player?.currentSource()?.src || '').includes('/proxy/stream/')
|
||||||
|
|
||||||
|
const tryResumePlayback = () => {
|
||||||
|
if (!this.player || this.player.isDisposed()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - this.lastRecoverAt < 2500) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.lastRecoverAt = now
|
||||||
|
|
||||||
|
const source = this.player.currentSource()
|
||||||
|
if (!source?.src) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLive = isLiveStreamSource()
|
||||||
|
const savedTime = Number(this.player.currentTime() || 0)
|
||||||
|
|
||||||
|
// Soft recovery first: if playback is paused/stuck, request play.
|
||||||
|
this.player.play().catch(() => {
|
||||||
|
// Hard recovery: reload source and restore position (for VOD/clip).
|
||||||
|
this.player.src(source)
|
||||||
|
this.player.one('loadedmetadata', () => {
|
||||||
|
if (!isLive && savedTime > 0) {
|
||||||
|
this.player.currentTime(savedTime)
|
||||||
|
} else if (isLive && this.player.liveTracker?.seekToLiveEdge) {
|
||||||
|
this.player.liveTracker.seekToLiveEdge()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.player.play().catch(() => {
|
||||||
|
// If autoplay is blocked, keep player ready; user can click play.
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerOptions = {
|
||||||
|
...this.options,
|
||||||
|
html5: {
|
||||||
|
...(this.options.html5 || {}),
|
||||||
|
vhs: {
|
||||||
|
// Lower initial bitrate pressure and let ABR adapt quickly.
|
||||||
|
enableLowInitialPlaylist: true,
|
||||||
|
limitRenditionByPlayerDimensions: true,
|
||||||
|
...(this.options.html5?.vhs || {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.player = videojs('video-player', playerOptions, () => {
|
||||||
createQualitySelector(this.player)
|
createQualitySelector(this.player)
|
||||||
|
|
||||||
if (this.$route.query['t']) {
|
if (this.$route.query['t']) {
|
||||||
@@ -43,11 +105,50 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.player.on('timeupdate', () => {
|
this.player.on('timeupdate', () => {
|
||||||
emit('PlayerTimeUpdate', this.player.currentTime())
|
const currentTime = Number(this.player.currentTime() || 0)
|
||||||
|
if (currentTime !== this.lastKnownTime) {
|
||||||
|
this.lastKnownTime = currentTime
|
||||||
|
this.lastProgressAt = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('PlayerTimeUpdate', currentTime)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.player.on('waiting', tryResumePlayback)
|
||||||
|
this.player.on('stalled', tryResumePlayback)
|
||||||
|
this.player.on('error', tryResumePlayback)
|
||||||
|
|
||||||
|
// On live streams, ad transitions/discontinuities can fire ended.
|
||||||
|
this.player.on('ended', () => {
|
||||||
|
if (isLiveStreamSource()) {
|
||||||
|
tryResumePlayback()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watchdog: if live playback stops progressing for several seconds,
|
||||||
|
// trigger recovery even when no explicit media event fires.
|
||||||
|
this.lastProgressAt = Date.now()
|
||||||
|
this.recoverTimer = setInterval(() => {
|
||||||
|
if (!this.player || this.player.isDisposed()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLiveStreamSource()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stalledFor = Date.now() - this.lastProgressAt
|
||||||
|
if (stalledFor > 8000) {
|
||||||
|
tryResumePlayback()
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
unmounted() {
|
unmounted() {
|
||||||
|
if (this.recoverTimer) {
|
||||||
|
clearInterval(this.recoverTimer)
|
||||||
|
this.recoverTimer = null
|
||||||
|
}
|
||||||
this.player.dispose()
|
this.player.dispose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ export function getDefaultSettings(): Settings {
|
|||||||
},
|
},
|
||||||
defaultQuality: {
|
defaultQuality: {
|
||||||
name: 'defaultQuality',
|
name: 'defaultQuality',
|
||||||
options: ['160p', '360p', '480p', '720p', '1080p'],
|
options: ['Auto', '160p', '360p', '480p', '720p', '1080p'],
|
||||||
selected: '480p',
|
selected: 'Auto',
|
||||||
type: 'option'
|
type: 'option'
|
||||||
},
|
},
|
||||||
language: {
|
language: {
|
||||||
|
|||||||
@@ -20,16 +20,94 @@ export default {
|
|||||||
status
|
status
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
watch: {
|
||||||
await getEndpoint('api/search/?query=' + this.$route.query.query)
|
'$route.query.query': {
|
||||||
.catch(() => {
|
immediate: true,
|
||||||
this.status = 'error'
|
handler() {
|
||||||
})
|
this.runSearch()
|
||||||
.then((data) => {
|
}
|
||||||
this.data = data as SearchResult
|
}
|
||||||
})
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
hasAnyResults(data: SearchResult) {
|
||||||
|
return (
|
||||||
|
data.channels.length > 0 ||
|
||||||
|
data.categories.length > 0 ||
|
||||||
|
data.relatedChannels.length > 0 ||
|
||||||
|
data.channelsWithTag.length > 0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
async buildFallbackSearch(query: string): Promise<SearchResult> {
|
||||||
|
const fallback: SearchResult = {
|
||||||
|
channels: [],
|
||||||
|
categories: [],
|
||||||
|
relatedChannels: [],
|
||||||
|
channelsWithTag: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedQuery = encodeURIComponent(query)
|
||||||
|
await getEndpoint('api/users/' + encodedQuery)
|
||||||
|
.then((channel) => {
|
||||||
|
if (channel) {
|
||||||
|
fallback.channels.push(channel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Ignore fallback channel lookup errors.
|
||||||
|
})
|
||||||
|
|
||||||
|
await getEndpoint('api/discover')
|
||||||
|
.then((categories) => {
|
||||||
|
const loweredQuery = query.toLowerCase()
|
||||||
|
fallback.categories = categories
|
||||||
|
.filter((category: { name: string; displayName?: string }) => {
|
||||||
|
const name = String(category.name || '').toLowerCase()
|
||||||
|
const displayName = String(category.displayName || '').toLowerCase()
|
||||||
|
return name.includes(loweredQuery) || displayName.includes(loweredQuery)
|
||||||
|
})
|
||||||
|
.slice(0, 24)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Ignore fallback category lookup errors.
|
||||||
|
})
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
},
|
||||||
|
async runSearch() {
|
||||||
|
const query = String(this.$route.query.query || '').trim()
|
||||||
|
if (!query) {
|
||||||
|
this.data = {
|
||||||
|
channels: [],
|
||||||
|
categories: [],
|
||||||
|
relatedChannels: [],
|
||||||
|
channelsWithTag: []
|
||||||
|
}
|
||||||
|
this.status = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data = undefined
|
||||||
|
this.status = undefined
|
||||||
|
|
||||||
|
const encodedQuery = encodeURIComponent(query)
|
||||||
|
await getEndpoint('api/search/?query=' + encodedQuery)
|
||||||
|
.then(async (data) => {
|
||||||
|
const parsed = data as SearchResult
|
||||||
|
if (this.hasAnyResults(parsed)) {
|
||||||
|
this.data = parsed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data = await this.buildFallbackSearch(query)
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
// If primary search errors, still attempt a best-effort fallback.
|
||||||
|
this.data = await this.buildFallbackSearch(query)
|
||||||
|
if (!this.hasAnyResults(this.data)) {
|
||||||
|
this.status = 'error'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
getStream(channel: StreamerData) {
|
getStream(channel: StreamerData) {
|
||||||
return {
|
return {
|
||||||
...channel.stream,
|
...channel.stream,
|
||||||
|
|||||||
+31
-1
@@ -15,7 +15,7 @@ import VueTitle from '@/components/VueTitle.vue'
|
|||||||
import ActionButtons from '@/components/ActionButtons.vue'
|
import ActionButtons from '@/components/ActionButtons.vue'
|
||||||
|
|
||||||
import type { StreamerData } from '@/types'
|
import type { StreamerData } from '@/types'
|
||||||
import { truncate, abbreviate, getEndpoint } from '@/mixins'
|
import { truncate, abbreviate, getEndpoint, postEndpoint } from '@/mixins'
|
||||||
import { getSetting } from '@/settingsManager'
|
import { getSetting } from '@/settingsManager'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -65,6 +65,36 @@ export default {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
this.status = 'error'
|
this.status = 'error'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Some backend builds return stale isLive/stream in api/users while
|
||||||
|
// api/users/isLive/bulk is still accurate. Use it as a fallback.
|
||||||
|
if (this.data && (this.data.isLive === false || !this.data.stream)) {
|
||||||
|
await postEndpoint('api/users/isLive/bulk', {
|
||||||
|
streamers: [username]
|
||||||
|
})
|
||||||
|
.then((liveUsers: string[]) => {
|
||||||
|
const isActuallyLive = liveUsers.some(
|
||||||
|
(name) => name.toLowerCase() === String(username).toLowerCase()
|
||||||
|
)
|
||||||
|
if (isActuallyLive) {
|
||||||
|
this.data!.isLive = true
|
||||||
|
if (!this.data!.stream) {
|
||||||
|
// Provide minimal stream metadata so live UI paths do not crash.
|
||||||
|
this.data!.stream = {
|
||||||
|
title: 'Live now',
|
||||||
|
tags: [],
|
||||||
|
topic: '',
|
||||||
|
startedAt: 0,
|
||||||
|
viewers: 0,
|
||||||
|
preview: this.data!.pfp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Keep original data when fallback lookup fails.
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
VideoPlayer,
|
VideoPlayer,
|
||||||
|
|||||||
Reference in New Issue
Block a user