Deploy local stack fixes and search fallback

This commit is contained in:
2026-03-10 23:12:32 -04:00
parent ddee63ebbe
commit 8e5a1126a6
10 changed files with 630 additions and 43 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+196
View File
@@ -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')
+64 -15
View File
@@ -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
} }
}) })
+24 -1
View File
@@ -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
+104 -3
View File
@@ -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()
} }
} }
+2 -2
View File
@@ -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: {
+86 -8
View File
@@ -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
View File
@@ -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,