diff --git a/docker/Dockerfile b/docker/Dockerfile index 8eadfd6..a692500 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -3,7 +3,7 @@ # 2) nginx stage to serve frontend assets # Name the node stage "builder" -FROM docker.io/node:16 AS builder +FROM docker.io/node:22 AS builder # Set working directory WORKDIR /app @@ -23,7 +23,7 @@ COPY ./ . RUN ls # install node modules and build assets -RUN npm i && npm run build +RUN npm i && npm run build-only # nginx state for serving content FROM docker.io/nginx:alpine diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index af0c91f..e20018b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,19 +1,49 @@ -version: "3.7" +name: safetwitch services: safetwitch-frontend: container_name: safetwitch-frontend hostname: safetwitch-frontend restart: always - image: codeberg.org/safetwitch/safetwitch:latest + image: safetwitch-local:latest + build: + context: "../" + dockerfile: "./docker/Dockerfile" ports: - - "127.0.0.1:8280:8280" + - "127.0.0.1:${SAFETWITCH_FRONTEND_PORT:-8280}:8280" environment: - - SAFETWITCH_BACKEND_DOMAIN=changeme - - SAFETWITCH_INSTANCE_DOMAIN=changeme - - SAFETWITCH_HTTPS=true - - SAFETWITCH_DEFAULT_LOCALE=en - - SAFETWITCH_FALLBACK_LOCALE=en + - SAFETWITCH_BACKEND_DOMAIN=${SAFETWITCH_BACKEND_DOMAIN:-api.safetwitch.darkness.services} + - SAFETWITCH_INSTANCE_DOMAIN=${SAFETWITCH_INSTANCE_DOMAIN:-safetwitch.darkness.services} + - SAFETWITCH_HTTPS=${SAFETWITCH_HTTPS:-true} + - 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.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: container_name: safetwitch-backend @@ -26,7 +56,70 @@ services: restart: always image: codeberg.org/safetwitch/safetwitch-backend:latest ports: - - "127.0.0.1:7100:7000" + - "127.0.0.1:${SAFETWITCH_BACKEND_PORT:-7100}:7000" environment: - PORT=7000 - - URL=changeme \ No newline at end of file + - 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} diff --git a/docker/nginx.conf b/docker/nginx.conf index b0d588c..a284f10 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -4,9 +4,26 @@ http { server { listen 8280; - access_log /dev/null; + access_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 / { root /app; diff --git a/docker/search-fallback/server.mjs b/docker/search-fallback/server.mjs new file mode 100644 index 0000000..2a4fdb2 --- /dev/null +++ b/docker/search-fallback/server.mjs @@ -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') diff --git a/src/assets/qualitySelector.ts b/src/assets/qualitySelector.ts index 6d4c8e9..f335406 100644 --- a/src/assets/qualitySelector.ts +++ b/src/assets/qualitySelector.ts @@ -10,22 +10,55 @@ export const createQualitySelector = (player: any) => { const qualityLevels: QualityLevelList = player.qualityLevels() const MenuButton = videojs.getComponent('MenuButton') 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 translatedAuto = t('player.auto') + const autoLabel = translatedAuto === 'player.auto' ? 'Auto' : translatedAuto - const setQuality = (id: string) => { - const found = formatedQualities.find((i) => i.id === id) + const setAutoQuality = () => { 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 - } else { - quality.enabled = true + continue } + + // Keep adaptive bitrate enabled up to the selected quality. + quality.enabled = quality.height <= found.height } qualityLevels.selectedIndex_ = found?.index qualityLevels.trigger({ type: 'change', selectedIndex: found?.index }) + + player.play().catch(() => { + // Ignore autoplay restrictions; user interaction can resume. + }) } class CustomMenuButton extends MenuButton { @@ -39,6 +72,11 @@ export const createQualitySelector = (player: any) => { qualitySelectorButton.handleClick = (data) => { const qualityClicked = data.currentTarget.innerText + if (qualityClicked === autoLabel) { + setAutoQuality() + return + } + const id = formatedQualities.find((i) => i.name === qualityClicked)?.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 }) } @@ -66,12 +99,27 @@ export const createQualitySelector = (player: any) => { player.controlBar.removeChild('CustomMenuButton') player.controlBar.addChild('CustomMenuButton', { 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', () => { - formatedQualities = qualityLevels.levels_.map((quality: QualityLevel) => { + formatedQualities = qualityLevels.levels_.map((quality: QualityLevel, index: number) => { let qualityFramerate = '' if (quality.frameRate > 30) { @@ -80,8 +128,9 @@ export const createQualitySelector = (player: any) => { return { name: quality.height?.toString() + 'p' + qualityFramerate, - index: quality.id[0], - id: quality.id + index, + id: quality.id, + height: quality.height || 0 } }) diff --git a/src/components/StreamPreview.vue b/src/components/StreamPreview.vue index 19c55be..ba5a25c 100644 --- a/src/components/StreamPreview.vue +++ b/src/components/StreamPreview.vue @@ -1,6 +1,6 @@