Compare commits

...
13 Commits
Author SHA1 Message Date
DiheChen 0dbfe26c1f ci: check readiness and the release version after deploy
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
The ops port serves GET /version, so the smoke test can confirm that the
upgraded build is the one answering.
2026-11-07 22:17:16 +08:00
DiheChen 0345d948ac feat: show the player bar volume as a read-only readout 2026-11-07 07:57:21 +08:00
DiheChen 51ff98eff1 feat: widen the player bar to the page panels and enlarge its buttons 2026-11-07 07:54:17 +08:00
DiheChen f5008b8d8e ci: add post-deploy smoke test
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
2026-11-07 01:42:27 +08:00
DiheChen cc8090d143 ci: deploy releases to k3s 2026-11-06 23:43:16 +08:00
DiheChen 48dcb1fd97 chore: slim the worker image and streamline the Docker builds 2026-11-06 00:47:47 +08:00
DiheChen 22ddb5f49c feat: raise the chat panel from the bottom 2026-11-05 12:53:55 +08:00
DiheChen 7168a97afa fix: slide the side panel tabs in their own direction 2026-11-05 12:46:00 +08:00
DiheChen 4737bb9ddf feat: float the player bar above the page 2026-11-05 12:31:45 +08:00
DiheChen baa69f74b0 feat: reveal secondary song actions on hover 2026-11-05 12:26:31 +08:00
DiheChen a4805e957d feat: mark the playing song in lists 2026-11-05 12:26:21 +08:00
DiheChen f6c925d3bd fix: keep notifications clear of the side panel 2026-11-05 12:24:45 +08:00
DiheChen d391f7e419 feat: fade covers in once they load 2026-11-05 12:24:20 +08:00
35 changed files with 384 additions and 97 deletions

No files matched your search

+10 -7
View File
@@ -1,10 +1,13 @@
.git
.codegraph
.superpowers
.worktrees
.tmp
.playwright-mcp
.env
*
!LICENSE
!cmd
!db
!docker
!go.mod
!go.sum
!internal
!protocol
!web
**/node_modules
**/dist
**/*_test.go
+91
View File
@@ -0,0 +1,91 @@
name: CD
on:
workflow_run:
workflows: [CI]
types: [completed]
concurrency:
group: cd-alisten
cancel-in-progress: false
permissions:
contents: read
packages: read
jobs:
deploy:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: ubuntu-latest
env:
TAG: ${{ github.event.workflow_run.head_branch }}
steps:
- uses: azure/setup-helm@v5
with:
version: v3.20.0
- name: Configure k3s access
shell: bash
env:
K3S_KUBECONFIG: ${{ secrets.K3S_KUBECONFIG }}
KUBECONFIG: ${{ runner.temp }}/k3s-kubeconfig
run: |
set -euo pipefail
umask 077
printf '%s\n' "$K3S_KUBECONFIG" > "$KUBECONFIG"
- name: Upgrade alisten
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KUBECONFIG: ${{ runner.temp }}/k3s-kubeconfig
run: |
set -euo pipefail
chart_version="${TAG#v}"
repository="$(printf '%s' "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
helm upgrade alisten "oci://ghcr.io/${repository}/charts/alisten" \
--version "$chart_version" \
--namespace alisten \
--reuse-values \
--set-string "serve.image.tag=${chart_version}" \
--set-string "worker.image.tag=${chart_version}" \
--set-string "web.image.tag=${chart_version}" \
--atomic \
--wait \
--timeout 6m
- name: Smoke test
shell: bash
env:
KUBECONFIG: ${{ runner.temp }}/k3s-kubeconfig
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
kubectl port-forward \
--namespace alisten \
--address 127.0.0.1 \
service/alisten-serve 18090:9090 &
pid=$!
trap 'kill "$pid" 2>/dev/null || true' EXIT
base_url="http://127.0.0.1:18090"
curl --silent --show-error --fail \
--connect-timeout 2 \
--max-time 5 \
--retry 20 \
--retry-delay 1 \
--retry-connrefused \
--retry-max-time 60 \
"${base_url}/readyz"
expected="${TAG}+${HEAD_SHA}"
actual="$(curl --silent --show-error --fail \
--connect-timeout 2 \
--max-time 5 \
"${base_url}/version")"
diff -u --label expected --label actual \
<(echo "$expected") <(echo "$actual")
+5 -9
View File
@@ -2,19 +2,15 @@ FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build
ARG TARGETOS TARGETARCH
ARG VERSION=dev
WORKDIR /src
COPY go.mod go.sum ./
COPY cmd ./cmd
COPY db ./db
COPY internal ./internal
COPY protocol ./protocol
COPY --parents go.mod go.sum cmd db internal protocol ./
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -tags grpcnotrace \
-ldflags "-s -w -X github.com/dihemoe/alisten/internal/bootstrap.version=$VERSION" \
-o /out/alisten ./cmd/alisten
-o /out/usr/local/bin/alisten ./cmd/alisten \
&& install -Dm644 /etc/ssl/certs/ca-certificates.crt /out/etc/ssl/certs/ca-certificates.crt
COPY LICENSE /out/usr/share/doc/alisten/
FROM gcr.io/distroless/static-debian13:nonroot AS alisten
COPY LICENSE /usr/share/doc/alisten/
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /out/alisten /usr/local/bin/alisten
COPY --from=build /out /
USER 65532:65532
EXPOSE 8080 9090
HEALTHCHECK --start-period=30s --start-interval=1s --interval=10s --timeout=3s --retries=3 \
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
set -eu
case "$TARGETARCH" in
amd64) arch=x86_64 triple=x86_64-linux-gnu cross_gcc=gcc-x86-64-linux-gnu ;;
arm64) arch=aarch64 triple=aarch64-linux-gnu cross_gcc=gcc-aarch64-linux-gnu ;;
esac
dpkg --add-architecture "$TARGETARCH"
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
ca-certificates gcc libc6-dev make nasm wget "$cross_gcc" \
"libc6-dev:$TARGETARCH" "libmp3lame-dev:$TARGETARCH" "zlib1g-dev:$TARGETARCH"
wget -q --tries=3 --timeout=30 -O /tmp/ffmpeg.tar.gz \
"https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n$FFMPEG_VERSION.tar.gz"
mkdir /tmp/ffmpeg
tar -xzf /tmp/ffmpeg.tar.gz -C /tmp/ffmpeg --strip-components=1
cd /tmp/ffmpeg
audio_decoders=$(sed -n '/\/\* audio codecs \*\//,/\/\* subtitles \*\//s/.*ff_\([a-z0-9_]*\)_decoder;.*/\1/p' \
libavcodec/allcodecs.c | paste -sd,)
audio_decoders_listed_as_video=comfortnoise,dvaudio,eightsvx_exp,eightsvx_fib,rka,s302m,speex
./configure \
--enable-cross-compile --arch="$arch" --target-os=linux --cross-prefix="$triple-" \
--disable-everything --disable-autodetect --disable-doc --disable-debug --disable-network \
--disable-ffplay --disable-avdevice --disable-swscale \
--extra-ldexeflags=-static \
--enable-zlib --enable-libmp3lame --enable-encoder=libmp3lame \
--enable-protocol=file --enable-demuxers --enable-parsers --enable-bsfs \
--enable-muxers --enable-filter=aresample \
--enable-decoder="$audio_decoders,$audio_decoders_listed_as_video"
make -j"$(nproc)"
install -D -t /out/usr/local/bin ffmpeg ffprobe
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
case "$TARGETARCH" in
amd64) ytdlp=yt-dlp_linux deno=x86_64 ;;
arm64) ytdlp=yt-dlp_linux_aarch64 deno=aarch64 ;;
esac
wget -q --tries=3 --timeout=30 -O /tmp/yt-dlp.zip \
"https://github.com/yt-dlp/yt-dlp/releases/download/$YTDLP_VERSION/$ytdlp.zip"
wget -q --tries=3 --timeout=30 -O /tmp/deno.zip \
"https://github.com/denoland/deno/releases/download/$DENO_VERSION/deno-$deno-unknown-linux-gnu.zip"
mkdir -p /out/opt/yt-dlp /out/usr/local/bin
unzip -q /tmp/yt-dlp.zip -d /out/opt/yt-dlp
unzip -q /tmp/deno.zip -d /out/usr/local/bin
ln -s "/opt/yt-dlp/$ytdlp" /out/usr/local/bin/yt-dlp
+4 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func NewHandler(ready func() bool, metrics prometheus.Gatherer) http.Handler {
func NewHandler(ready func() bool, metrics prometheus.Gatherer, version string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "ok\n")
@@ -20,6 +20,9 @@ func NewHandler(ready func() bool, metrics prometheus.Gatherer) http.Handler {
}
_, _ = io.WriteString(w, "ok\n")
})
mux.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, version+"\n")
})
mux.Handle("GET /metrics", promhttp.HandlerFor(metrics, promhttp.HandlerOpts{}))
return mux
}
+7 -1
View File
@@ -15,7 +15,7 @@ func TestHandler(t *testing.T) {
registry := prometheus.NewRegistry()
registry.MustRegister(prometheus.NewGauge(prometheus.GaugeOpts{Name: "test_gauge", Help: "测试用指标。"}))
var ready atomic.Bool
handler := NewHandler(ready.Load, registry)
handler := NewHandler(ready.Load, registry, "v1.2.3+abc")
get := func(path string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
@@ -28,6 +28,12 @@ func TestHandler(t *testing.T) {
t.Errorf("/healthz 状态码 = %d,期望 200", rec.Code)
}
})
t.Run("version 返回构建版本", func(t *testing.T) {
rec := get("/version")
if body := rec.Body.String(); rec.Code != http.StatusOK || body != "v1.2.3+abc\n" {
t.Errorf("/version 状态码 = %d,响应 = %q,期望 200 和 \"v1.2.3+abc\\n\"", rec.Code, body)
}
})
t.Run("metrics 输出注册的指标", func(t *testing.T) {
rec := get("/metrics")
body, _ := io.ReadAll(rec.Body)
+1 -1
View File
@@ -12,7 +12,7 @@ import (
func TestHealthcheckFollowsReadiness(t *testing.T) {
var ready atomic.Bool
server := httptest.NewServer(ops.NewHandler(ready.Load, prometheus.NewRegistry()))
server := httptest.NewServer(ops.NewHandler(ready.Load, prometheus.NewRegistry(), version))
defer server.Close()
t.Setenv("ALISTEN_OPS_LISTEN", server.Listener.Addr().String())
+1 -1
View File
@@ -182,7 +182,7 @@ func runServe(base *slog.Logger) (err error) {
opsLogger := base.With("component", "ops")
publicServer := newHTTPServer(p.handler, base.With("component", "http"))
opsServer := newHTTPServer(recoverPanics(ops.NewHandler(ready, metrics), opsLogger), opsLogger)
opsServer := newHTTPServer(recoverPanics(ops.NewHandler(ready, metrics, version), opsLogger), opsLogger)
go func() { fatal <- fmt.Errorf("HTTP 服务异常退出:%w", publicServer.Serve(publicListener)) }()
go func() { fatal <- fmt.Errorf("HTTP 服务异常退出:%w", opsServer.Serve(opsListener)) }()
logger.Info("服务已启动", "listen", publicListener.Addr().String(), "ops_listen", opsListener.Addr().String(), "instance", instance)
+1 -1
View File
@@ -275,7 +275,7 @@ func (w mediaWorker) run(ctx context.Context, listener net.Listener) error {
}),
)
opsLogger := w.base.With("component", "ops")
opsServer := newHTTPServer(recoverPanics(ops.NewHandler(ready, w.metrics), opsLogger), opsLogger)
opsServer := newHTTPServer(recoverPanics(ops.NewHandler(ready, w.metrics, version), opsLogger), opsLogger)
go func() { fatal <- fmt.Errorf("运维端口异常退出:%w", opsServer.Serve(listener)) }()
w.logger.Info("worker 已启动", "ops_listen", listener.Addr().String(), "instance", w.instance)
+15 -7
View File
@@ -5,16 +5,24 @@ COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ .
RUN npm run build \
&& find dist -type f \( -name '*.html' -o -name '*.js' -o -name '*.css' -o -name '*.svg' -o -name '*.json' -o -name '*.webmanifest' \) -exec gzip -9 -k {} + \
&& sed "s|@VERSION@|${VERSION}|" nginx/default.conf > /tmp/default.conf
&& find dist -type f ! -name '*.png' -exec gzip -9 -k {} + \
&& install -d -m 775 /out/etc/nginx /out/etc/nginx/conf.d \
&& mkdir -p /out/usr/share/nginx \
&& sed "s|@VERSION@|${VERSION}|" nginx/default.conf > /out/etc/nginx/conf.d/default.conf \
&& cp nginx/nginx.conf /out/etc/nginx/nginx.conf \
&& cp -R nginx/templates /out/etc/nginx/alisten-templates \
&& mv dist /out/usr/share/nginx/html
COPY LICENSE /out/usr/share/doc/alisten/
FROM nginxinc/nginx-unprivileged:1.30-alpine
ENV NGINX_ENTRYPOINT_QUIET_LOGS=1
COPY web/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=build /tmp/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist /usr/share/nginx/html
COPY web/nginx/templates /etc/nginx/alisten-templates
COPY LICENSE /usr/share/doc/alisten/
COPY --from=build --parents \
/out/./etc/nginx/nginx.conf \
/out/./etc/nginx/conf.d/default.conf \
/out/./etc/nginx/alisten-templates \
/out/./usr/share/nginx/html \
/out/./usr/share/doc/alisten/LICENSE \
/
EXPOSE 8080 9090
HEALTHCHECK --start-period=30s --start-interval=1s --interval=10s --timeout=3s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:9090/healthz || exit 1
+2 -2
View File
@@ -19,7 +19,7 @@ import { ErrorBoundary, RegionFailed } from './error-boundary'
import { navigationStore } from './navigation'
import { pwaStore } from './pwa'
import { UpdatePrompt } from './update-prompt'
import { withViewTransition } from './view-transition'
import { panelDirection, withViewTransition } from './view-transition'
// adminStore 不在 main.tsx 中启动:静态导入会把后台页打包进入口 chunk。
const AdminPage = lazyPage(() => import('@/features/admin').then((module) => {
@@ -219,7 +219,7 @@ export function App() {
const shownPanel = joined && panel !== page ? panel : null
const switchPanel = (next: Panel | null) => {
if (shownPanel && next && next !== shownPanel && next !== page)
withViewTransition(() => navigationStore.setPanel(next))
withViewTransition(() => navigationStore.setPanel(next), panelDirection(shownPanel, next))
else
navigationStore.setPanel(next)
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function DetailPanel({ view, children }: { view: Panel | null, children:
return (
<aside
ref={panelRef}
className={`detail-panel${open ? '' : ' detail-panel-closing'}`}
className={`detail-panel${shown === 'chat' ? ' detail-panel-sheet' : ''}${open ? '' : ' detail-panel-closing'}`}
aria-hidden={!open || undefined}
inert={!open}
onAnimationEnd={(event) => {
+25 -8
View File
@@ -36,7 +36,7 @@
:root[data-performance='low'] button { transition: opacity 160ms; }
}
.app-shell { height: calc(var(--vh, 1vh) * 100); height: 100dvh; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 104px; overflow: hidden; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); background: var(--color-frame); }
.app-shell { height: calc(var(--vh, 1vh) * 100); height: 100dvh; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; overflow: hidden; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); background: var(--color-frame); }
.workspace { grid-column: 1; grid-row: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; }
.app-shell > .player-bar { grid-column: 1; grid-row: 2; }
.workspace-header { position: relative; z-index: 30; height: 76px; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 0 24px; background: var(--color-frame); color: var(--color-foreground); border: 0; border-radius: 0; }
@@ -76,16 +76,23 @@
from { opacity: 0; }
to { opacity: 1; }
}
.detail-panel { --detail-panel-width: 480px; width: var(--detail-panel-width); flex-shrink: 0; position: relative; min-height: 0; display: flex; flex-direction: column; border: 0; border-radius: 12px; background: var(--color-panel); overflow: hidden; animation: detail-panel-open 240ms cubic-bezier(.2, .8, .2, 1) both; }
:root { --detail-panel-width: 480px; --view-in: translateX(100%); --view-out: translateX(-14%); --view-swap: translateX(10px); }
:root[data-view-direction='backward'] { --view-in: translateX(-100%); --view-out: translateX(14%); --view-swap: translateX(-10px); }
:root[data-view-direction='rise'] { --view-in: translateY(100%); --view-out: translateY(-8%); --view-swap: translateY(10px); }
:root[data-view-direction='sink'] { --view-in: translateY(-8%); --view-out: translateY(100%); --view-swap: translateY(-10px); }
.detail-panel { width: var(--detail-panel-width); flex-shrink: 0; position: relative; min-height: 0; display: flex; flex-direction: column; border: 0; border-radius: 12px; background: var(--color-panel); overflow: hidden; animation: detail-panel-open 240ms cubic-bezier(.2, .8, .2, 1) both; }
.detail-panel-content { view-transition-name: detail-panel-content; width: var(--detail-panel-width); flex: 1; min-height: 0; display: flex; flex-direction: column; }
/* 只换关键帧里的位移:换 animation-name 会让面板在切换内容时重播打开动画。 */
.detail-panel { --detail-panel-offset: translateX(16px); }
.detail-panel-sheet { --detail-panel-offset: translateY(100%); }
.detail-panel-closing { pointer-events: none; animation-name: detail-panel-close; }
@keyframes detail-panel-open {
from { width: 0; margin-left: -8px; opacity: 0; transform: translateX(16px); }
from { width: 0; margin-left: -8px; opacity: 0; transform: var(--detail-panel-offset); }
to { width: var(--detail-panel-width); margin-left: 0; opacity: 1; transform: translateX(0); }
}
@keyframes detail-panel-close {
from { width: var(--detail-panel-width); margin-left: 0; opacity: 1; transform: translateX(0); }
to { width: 0; margin-left: -8px; opacity: 0; transform: translateX(16px); }
to { width: 0; margin-left: -8px; opacity: 0; transform: var(--detail-panel-offset); }
}
/* 视图过渡只移动面板内容:它的过渡分组裁剪在面板内,根快照不做动画,顶栏、主内容和播放器保持不动。 */
::view-transition-group(root) { animation: none; overflow: clip; }
@@ -96,17 +103,20 @@
::view-transition-old(detail-panel-content), ::view-transition-new(detail-panel-content) { background: var(--color-panel); mix-blend-mode: normal; }
::view-transition-old(detail-panel-content) { animation: view-recede 300ms cubic-bezier(.2, .8, .2, 1) both; }
::view-transition-new(detail-panel-content) { animation: view-push-in 300ms cubic-bezier(.2, .8, .2, 1) both; }
/* 聊天在升起和沉下时都要压在列表上面,而新视图默认才在上层。 */
:root[data-view-direction='rise']::view-transition-new(detail-panel-content),
:root[data-view-direction='sink']::view-transition-old(detail-panel-content) { z-index: 1; border-radius: 12px 12px 0 0; box-shadow: 0 -8px 24px var(--color-shadow); }
@keyframes view-recede {
from { transform: translateX(0); }
to { transform: translateX(-14%); }
to { transform: var(--view-out); }
}
@keyframes view-push-in {
from { transform: translateX(100%); }
from { transform: var(--view-in); }
to { transform: translateX(0); }
}
.detail-panel-swap { animation: detail-panel-swap-in 180ms cubic-bezier(.2, .8, .2, 1) both; }
@keyframes detail-panel-swap-in {
from { opacity: 0; transform: translateX(10px); }
from { opacity: 0; transform: var(--view-swap); }
to { opacity: 1; transform: translateX(0); }
}
:root[data-performance='low'] .header-menu[open] .main-nav { animation: fade-in 160ms ease both; }
@@ -124,7 +134,14 @@
.detail-tab[aria-pressed='true'] { color: var(--color-foreground); border-bottom-color: var(--color-brand); }
.mobile-nav { display: none; }
@media (max-width: 1100px) {
.detail-panel { --detail-panel-width: 420px; }
:root { --detail-panel-width: 420px; }
}
@media (min-width: 768px) {
.app-shell > .player-bar { margin: 0 8px 8px; }
}
/* 通知相对窗口居中,侧栏打开时移到内容区中间,否则会盖住侧栏。窗口太窄时内容区放不下通知,留在原位。 */
@media (min-width: 1024px) {
body:has(.detail-panel:not(.detail-panel-closing)) .notifications { left: calc(50% - (var(--detail-panel-width) + 8px) / 2); }
}
@media (max-width: 767px) {
.app-shell { grid-template-rows: minmax(0, 1fr) auto auto; padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); padding-bottom: 0; }
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { panelDirection } from './view-transition'
describe('panelDirection', () => {
it.each([
['queue', 'history', 'forward'],
['history', 'queue', 'backward'],
['queue', 'chat', 'rise'],
['history', 'chat', 'rise'],
['chat', 'queue', 'sink'],
['chat', 'history', 'sink'],
] as const)('%s 到 %s', (from, to, direction) => {
expect(panelDirection(from, to)).toBe(direction)
})
})
+14 -1
View File
@@ -1,11 +1,24 @@
import type { Panel } from './route'
import { flushSync } from 'react-dom'
import { performanceStore } from '@/features/settings'
type ViewDirection = 'forward' | 'backward' | 'rise' | 'sink'
// 播放列表和播放历史是左右并排的 tab;聊天从播放栏升起,盖在它们上面。
export function panelDirection(from: Panel, to: Panel): ViewDirection {
if (to === 'chat')
return 'rise'
if (from === 'chat')
return 'sink'
return to === 'queue' ? 'backward' : 'forward'
}
export function canPushViews(): boolean {
return 'startViewTransition' in document && performanceStore.smoothMotion()
}
export function withViewTransition(update: () => void): void {
export function withViewTransition(update: () => void, direction: ViewDirection): void {
document.documentElement.dataset.viewDirection = direction
if (!canPushViews()) {
update()
return
+1
View File
@@ -1,2 +1,3 @@
export type { RoomNotice, RoomState } from './replica'
export { roomStore } from './room-store'
export { sameTrack } from './track'
+17
View File
@@ -0,0 +1,17 @@
import type { Track } from '@/core/realtime'
import { describe, expect, it } from 'vitest'
import { sameTrack } from './track'
describe('sameTrack', () => {
const track: Track = { source: 'qq', id: '1' }
it('音源和 ID 都相同才是同一首歌', () => {
expect(sameTrack(track, { source: 'qq', id: '1' })).toBe(true)
expect(sameTrack(track, { source: 'qq', id: '2' })).toBe(false)
expect(sameTrack(track, { source: 'netease', id: '1' })).toBe(false)
})
it('没有在放的歌时不相同', () => {
expect(sameTrack(track, undefined)).toBe(false)
})
})
+5
View File
@@ -0,0 +1,5 @@
import type { Track } from '@/core/realtime'
export function sameTrack(a: Track, b: Track | undefined): boolean {
return a.source === b?.source && a.id === b.id
}
+11 -7
View File
@@ -1,12 +1,12 @@
import type { PlayRecord } from './records'
import { CirclePlus, Download, History, Search, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { roomStore } from '@/core/room'
import { roomStore, sameTrack } from '@/core/room'
import { enqueue, sourceName } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { formatClock } from '@/shared/lib/time'
import { useStore } from '@/shared/lib/use-store'
import { AsyncIconButton, EmptyState, IconButton, Modal, notificationStore, SongArtwork } from '@/shared/ui'
import { AsyncIconButton, EmptyState, IconButton, Modal, notificationStore, PlayingBars, SongArtwork } from '@/shared/ui'
import { historyStore } from './history-store'
import './history.css'
@@ -22,15 +22,18 @@ function playedOn(ms: number): string {
return new Date(ms).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
}
function HistoryRow({ record, compact, connected }: { record: PlayRecord, compact: boolean, connected: boolean }) {
function HistoryRow({ record, compact, connected, playing }: { record: PlayRecord, compact: boolean, connected: boolean, playing: boolean }) {
const { title, artist, album, coverUrl, pageUrl } = record.meta
const performer = artist || '未知艺术家'
const source = sourceName(record.track)
return (
<div className="song-row">
<div className="song-row" data-playing={playing || undefined}>
<SongArtwork coverUrl={coverUrl} title={title} href={pageUrl} sourceName={source} />
<div className="song-copy">
<strong className="text-ui-base truncate">{title}</strong>
<span className="song-title">
{playing && <PlayingBars />}
<strong className="text-ui-base truncate">{title}</strong>
</span>
<span className="text-ui-sm muted truncate">{[performer, source, album].filter(Boolean).join(' · ')}</span>
</div>
{!compact && <time className="history-time text-ui-sm muted" dateTime={new Date(record.playedAtMs).toISOString()}>{formatClock(record.playedAtMs)}</time>}
@@ -45,7 +48,7 @@ function HistoryRow({ record, compact, connected }: { record: PlayRecord, compac
/>
<IconButton
icon={Trash2}
className="song-action"
className="song-action song-action-secondary"
label={`删除${bookTitle(title)}的播放记录`}
onClick={() => {
historyStore.removePlay(record)
@@ -59,6 +62,7 @@ function HistoryRow({ record, compact, connected }: { record: PlayRecord, compac
export function HistoryPage({ compact = false, filter = '', onFilter }: { compact?: boolean, filter?: string, onFilter?: (filter: string) => void }) {
const plays = useStore(historyStore, s => s.plays)
const connected = useStore(roomStore, s => s.status === 'open')
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
const [limit, setLimit] = useState(batchSize)
const [confirming, setConfirming] = useState(false)
const visible = plays.filter(record => matches(record, filter))
@@ -100,7 +104,7 @@ export function HistoryPage({ compact = false, filter = '', onFilter }: { compac
return (
<li key={`${record.track.source}:${record.track.id}`}>
{(index === 0 || playedOn(shown[index - 1].playedAtMs) !== day) && <h2 className="history-date text-ui-sm muted">{day}</h2>}
<HistoryRow record={record} compact={compact} connected={connected} />
<HistoryRow record={record} compact={compact} connected={connected} playing={sameTrack(record.track, playing)} />
</li>
)
})}
+9 -3
View File
@@ -15,12 +15,18 @@
.player-tools { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
.player-tools .volume-control { display: flex; align-items: center; gap: 6px; margin-right: 16px; }
.player-tools .volume-slider { width: 90px; accent-color: var(--color-foreground); }
.player-tools .volume-input { box-sizing: border-box; width: 4.5ch; min-width: 0; padding: 4px 3px; text-align: center; font-variant-numeric: tabular-nums; -moz-appearance: textfield; }
.player-tools .volume-input::-webkit-inner-spin-button, .player-tools .volume-input::-webkit-outer-spin-button { margin: 0; -webkit-appearance: none; }
.player-tools .volume-unit { color: var(--color-foreground-subtle); font-size: var(--text-ui-xs); }
.player-tools .volume-readout { min-width: 5ch; color: var(--color-foreground-subtle); font-size: var(--text-ui-sm); font-variant-numeric: tabular-nums; }
.player-panel-toggle { color: var(--color-foreground-subtle); }
.player-panel-toggle:hover:not(:disabled), .player-panel-toggle:focus-visible:not(:disabled), .player-panel-toggle[aria-pressed='true'] { color: var(--color-brand); background: transparent; }
.player-panel-toggle:active:not(:disabled) { color: var(--color-brand); background: transparent; transform: none; }
/* 播放栏在布局里占一行,不压在页面上,所以各页面不用留出底部空间。 */
@media (min-width: 768px) {
.player-bar { --color-hover: var(--color-surface-hover); padding: 10px 14px; border-radius: 12px; background: var(--color-panel); }
.player-artwork .artwork { width: 56px; background: var(--color-surface); }
.player-right .icon-button { width: 40px; height: 40px; }
.player-right .icon-button svg { width: 22px; height: 22px; }
.player-center .icon-button svg { width: 24px; height: 24px; }
}
@media (max-width: 1100px) {
.player-bar { gap: 12px; }
.player-tools .volume-control { margin-right: 4px; }
+19 -10
View File
@@ -4,7 +4,7 @@ import { roomStore } from '@/core/room'
import { sourceName } from '@/features/queue'
import { formatDuration } from '@/shared/lib/time'
import { useStore } from '@/shared/lib/use-store'
import { Artwork, AsyncIconButton, IconButton, VolumeInput } from '@/shared/ui'
import { Artwork, AsyncIconButton, IconButton } from '@/shared/ui'
import { playerStore } from './player-store'
import { volumeStore } from './volume-store'
import './playback.css'
@@ -64,6 +64,7 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
const connected = useStore(roomStore, s => s.status === 'open')
const volume = useStore(volumeStore, s => s.volume)
const muted = useStore(volumeStore, s => s.muted)
const audibleVolume = muted ? 0 : volume
const meta = nowPlaying?.entry.meta
const artist = meta?.artist || '未知艺术家'
const source = nowPlaying ? sourceName(nowPlaying.entry.track) : undefined
@@ -90,15 +91,23 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
</div>
<div className="player-tools">
<IconButton icon={muted ? VolumeX : Volume2} label={muted ? '取消静音' : '静音'} onClick={() => volumeStore.setMuted(!muted)} />
<VolumeInput
volume={volume}
sliderValue={muted ? 0 : volume}
onCommit={(next) => {
volumeStore.setVolume(next)
if (muted && next > 0)
volumeStore.setMuted(false)
}}
/>
<div className="volume-control">
<input
className="volume-slider"
type="range"
min="0"
max="100"
value={audibleVolume}
aria-label="音量"
onChange={(event) => {
const next = Number(event.target.value)
volumeStore.setVolume(next)
if (muted && next > 0)
volumeStore.setMuted(false)
}}
/>
<span className="volume-readout" aria-hidden="true">{`${audibleVolume}%`}</span>
</div>
<IconButton icon={MessageCircle} label="房间聊天" className="player-panel-toggle" disabled={!canOpenPanels} aria-pressed={chatOpen} onClick={onChat} />
<IconButton icon={ListMusic} label="播放列表" className="player-panel-toggle" disabled={!canOpenPanels} aria-pressed={queueOpen} onClick={onQueue} />
</div>
+6 -5
View File
@@ -4,7 +4,7 @@ import { Heart, ListMusic, Trash2 } from 'lucide-react'
import { roomStore } from '@/core/room'
import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
import { EmptyState, Select, SongArtwork, SongList } from '@/shared/ui'
import { EmptyState, PlayingBars, Select, SongArtwork, SongList } from '@/shared/ui'
import { likeEntry, removeEntry, setPlayMode } from './actions'
import { sourceName } from './source'
import './queue.css'
@@ -26,7 +26,7 @@ function songItem(entry: Entry): SongItem {
}
}
function QueueCard({ label, entry, className = '' }: { label: string, entry: Entry, className?: string }) {
function QueueCard({ label, entry, className = '', playing = false }: { label: string, entry: Entry, className?: string, playing?: boolean }) {
const song = songItem(entry)
return (
<div className={`queue-current ${className}`}>
@@ -36,6 +36,7 @@ function QueueCard({ label, entry, className = '' }: { label: string, entry: Ent
<strong className="text-ui-base truncate">{song.title}</strong>
<span className="text-ui-sm muted truncate">{[song.artist, song.sourceName, song.note].filter(Boolean).join(' · ')}</span>
</div>
{playing && <PlayingBars />}
</div>
)
}
@@ -66,15 +67,15 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
]}
/>
</div>
{nowPlaying && <QueueCard label="正在播放" entry={nowPlaying.entry} />}
{nowPlaying && <QueueCard label="正在播放" entry={nowPlaying.entry} playing />}
{pending && <QueueCard label="下一首" entry={pending} className="queue-pending" />}
{items.length
? (
<SongList
songs={items.map(songItem)}
actions={song => [
{ icon: Heart, label: `点赞${bookTitle(song.title)}`, disabled: !connected, run: () => likeEntry(song.key, song.title) },
{ icon: Trash2, label: `移除${bookTitle(song.title)}`, disabled: !connected, run: () => removeEntry(song.key, song.title) },
{ icon: Heart, label: `点赞${bookTitle(song.title)}`, disabled: !connected, secondary: true, run: () => likeEntry(song.key, song.title) },
{ icon: Trash2, label: `移除${bookTitle(song.title)}`, disabled: !connected, secondary: true, run: () => removeEntry(song.key, song.title) },
]}
/>
)
+9 -4
View File
@@ -3,12 +3,12 @@ import type { PlaylistSummary, TrackSummary } from '@/core/realtime'
import type { SearchCategory, SearchSource } from '@/features/history'
import type { SongItem } from '@/shared/ui'
import { ArrowLeft, ArrowRight, CirclePlus, Clock3, Search, Sparkles } from 'lucide-react'
import { roomStore } from '@/core/room'
import { roomStore, sameTrack } from '@/core/room'
import { historyStore } from '@/features/history'
import { enqueue } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
import { Artwork, AsyncIconButton, EmptyState, IconButton, Select, Skeleton, SongArtwork, SongList } from '@/shared/ui'
import { Artwork, AsyncIconButton, EmptyState, IconButton, PlayingBars, Select, Skeleton, SongArtwork, SongList } from '@/shared/ui'
import { SearchBox } from './search-box'
import { canOpenPlaylists, keywordSearch, pageSize, recordSearch, searchStore } from './search-store'
import './search.css'
@@ -38,9 +38,10 @@ function songItem({ track, meta }: TrackSummary): SongItem {
}
function TrackRows({ items, connected }: { items: TrackSummary[], connected: boolean }) {
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
return (
<SongList
songs={items.map(songItem)}
songs={items.map(item => ({ ...songItem(item), playing: sameTrack(item.track, playing) }))}
actions={(song, index) => [{
icon: CirclePlus,
label: `点歌${bookTitle(song.title)}`,
@@ -110,6 +111,7 @@ function Overview({ tracks, playlists, connected, onCategory, onOpenPlaylist }:
onOpenPlaylist: (playlist: PlaylistSummary) => void
}) {
const featured = tracks.at(0)
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
return (
<>
{featured && (
@@ -121,7 +123,10 @@ function Overview({ tracks, playlists, connected, onCategory, onOpenPlaylist }:
<h3 className="text-ui-xl truncate" title={featured.meta.title}>{featured.meta.title}</h3>
<p className="text-ui-caption muted truncate">{featured.meta.artist || '未知艺术家'}</p>
<div className="search-feature-footer">
<span className="text-ui-caption">歌曲</span>
<span className="search-feature-kind text-ui-caption">
{sameTrack(featured.track, playing) && <PlayingBars />}
歌曲
</span>
<AsyncIconButton
icon={CirclePlus}
label={`点歌${bookTitle(featured.meta.title)}`}
+1
View File
@@ -39,6 +39,7 @@
.search-featured-song .artwork { width: 88px; background: var(--color-card-hover); }
.search-featured-song h3 { margin-bottom: 4px; }
.search-feature-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 6px; }
.search-feature-kind { display: inline-flex; align-items: center; gap: 8px; }
.search-feature-footer .icon-button { width: 36px; height: 36px; }
.search-feature-footer .icon-button svg { width: 26px; height: 26px; }
.search-preview .song-row { display: grid; grid-template-columns: 42px minmax(0, 1fr) 48px 36px; min-height: 64px; padding: 8px; gap: 12px; }
+2 -1
View File
@@ -6,10 +6,11 @@ import { errorMessage } from '@/core/errors'
import { historyStore, searchLimits } from '@/features/history'
import { reminderStore, requestNotificationPermission, systemNotificationsSupported, volumeStore } from '@/features/playback'
import { useStore } from '@/shared/lib/use-store'
import { notificationStore, Select, VolumeInput } from '@/shared/ui'
import { notificationStore, Select } from '@/shared/ui'
import { appearanceStore, fontSizes, themes } from './appearance-store'
import { performanceLevels, performanceStore } from './performance-store'
import { profileStore } from './profile-store'
import { VolumeInput } from './volume-input'
import './settings.css'
interface SettingsDraft {
@@ -4,13 +4,12 @@ import { beginVolumeEdit, changeVolumeDraft, commitVolumeDraft, initialVolumeEdi
interface VolumeInputProps {
volume: number
onCommit: (volume: number) => void
sliderValue?: number
id?: string
className?: string
disabled?: boolean
}
export function VolumeInput({ volume, onCommit, sliderValue, id, className = '', disabled = false }: VolumeInputProps) {
export function VolumeInput({ volume, onCommit, id, className = '', disabled = false }: VolumeInputProps) {
const [editor, setEditor] = useState(() => initialVolumeEditor(volume))
const syncedEditor = syncVolumeDraft(editor, volume)
const commit = () => {
@@ -27,7 +26,7 @@ export function VolumeInput({ volume, onCommit, sliderValue, id, className = '',
type="range"
min="0"
max="100"
value={sliderValue ?? volume}
value={volume}
aria-label="音量"
disabled={disabled}
onChange={(event) => {
+2 -1
View File
@@ -3,10 +3,11 @@ import { useState } from 'react'
export function Artwork({ src, title, className = '' }: { src?: string, title: string, className?: string }) {
const [failedSrc, setFailedSrc] = useState<string>()
const [loadedSrc, setLoadedSrc] = useState<string>()
return (
<div className={`artwork ${className}`}>
{src && src !== failedSrc
? <img src={src} alt={title} loading="lazy" decoding="async" onError={() => setFailedSrc(src)} />
? <img src={src} alt={title} loading="lazy" decoding="async" data-loaded={src === loadedSrc || undefined} onLoad={() => setLoadedSrc(src)} onError={() => setFailedSrc(src)} />
: <Music2 size={40} strokeWidth={1.2} aria-hidden="true" />}
</div>
)
+1 -1
View File
@@ -9,10 +9,10 @@ export { lazyPage } from './lazy-page'
export { Modal } from './modal'
export { notificationStore } from './notification-store'
export { Notifications } from './notifications'
export { PlayingBars } from './playing-bars'
export { Select } from './select'
export type { SelectOption } from './select'
export { Skeleton } from './skeleton'
export { SongArtwork } from './song-artwork'
export { SongList } from './song-list'
export type { SongAction, SongItem } from './song-list'
export { VolumeInput } from './volume-input'
+9
View File
@@ -0,0 +1,9 @@
export function PlayingBars() {
return (
<span className="playing-bars" role="img" aria-label="正在播放">
<i />
<i />
<i />
</span>
)
}
+7 -2
View File
@@ -1,6 +1,7 @@
import type { LucideIcon } from 'lucide-react'
import { formatDuration } from '@/shared/lib/time'
import { AsyncIconButton } from './icon-button'
import { PlayingBars } from './playing-bars'
import { SongArtwork } from './song-artwork'
export interface SongItem {
@@ -16,12 +17,15 @@ export interface SongItem {
sourceName?: string
note?: string
badge?: string
playing?: boolean
}
export interface SongAction {
icon: LucideIcon
label: string
disabled?: boolean
// 次要操作只在鼠标移到这一行时出现。
secondary?: boolean
run: () => Promise<void>
}
@@ -29,11 +33,12 @@ export function SongList({ songs, actions }: { songs: SongItem[], actions: (song
return (
<ol className="song-list">
{songs.map((song, index) => (
<li className="song-row" key={song.key}>
<li className="song-row" data-playing={song.playing || undefined} key={song.key}>
<span className="song-index text-ui-sm muted">{String(index + 1).padStart(2, '0')}</span>
<SongArtwork coverUrl={song.coverUrl} title={song.title} href={song.sourceUrl} sourceName={song.sourceName} />
<div className="song-copy">
<span className="song-title">
{song.playing && <PlayingBars />}
<strong className="text-ui-base truncate">{song.title}</strong>
{song.badge && <span className="song-badge text-ui-xs">{song.badge}</span>}
</span>
@@ -44,7 +49,7 @@ export function SongList({ songs, actions }: { songs: SongItem[], actions: (song
<span className="song-album text-ui-sm muted truncate">{song.album}</span>
<span className="song-duration text-ui-sm muted">{song.durationMs > 0 ? formatDuration(song.durationMs) : ''}</span>
{actions(song, index).map(action => (
<AsyncIconButton key={action.label} icon={action.icon} className="song-action" label={action.label} disabled={action.disabled} onAction={action.run} />
<AsyncIconButton key={action.label} icon={action.icon} className={action.secondary ? 'song-action song-action-secondary' : 'song-action'} label={action.label} disabled={action.disabled} onAction={action.run} />
))}
</li>
))}
+19 -1
View File
@@ -33,7 +33,8 @@
.skeleton-row > div > span { display: block; width: 55%; height: 12px; border-radius: 4px; background: var(--color-surface-hover); }
.skeleton-row > div > span + span { width: 35%; height: 10px; margin-top: 10px; background: var(--color-surface); }
.artwork { flex-shrink: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; background: var(--color-surface); color: var(--color-foreground-subtlest); border-radius: 8px; aspect-ratio: 1; }
.artwork img { width: 100%; height: 100%; object-fit: cover; }
.artwork img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 240ms ease; }
.artwork img[data-loaded] { opacity: 1; }
.avatar { display: inline-grid; place-items: center; flex-shrink: 0; width: 32px; aspect-ratio: 1; overflow: hidden; border-radius: 50%; background: var(--color-accent); color: var(--color-brand); font-weight: 600; line-height: 1; }
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.song-artwork { display: block; position: relative; flex-shrink: 0; border-radius: 8px; }
@@ -43,12 +44,29 @@
.song-list { list-style: none; padding: 0; margin: 0; }
.song-row { display: flex; align-items: center; gap: 12px; padding: 10px 8px; border-radius: 8px; min-height: 68px; content-visibility: auto; contain-intrinsic-size: auto 68px; }
.song-row:hover { background: var(--color-surface-hover); }
.song-row[data-playing] { background: color-mix(in srgb, var(--color-brand) 10%, transparent); }
.song-row[data-playing]:hover { background: color-mix(in srgb, var(--color-brand) 14%, transparent); }
.song-row[data-playing] strong { color: var(--color-brand); }
.playing-bars { display: inline-flex; align-items: flex-end; flex-shrink: 0; gap: 2px; width: 14px; height: 12px; }
.playing-bars i { flex: 1; height: 100%; border-radius: 1px; background: var(--color-brand); transform: scaleY(.6); transform-origin: bottom; animation: playing-bar 720ms ease-in-out infinite alternate; }
.playing-bars i:nth-child(2) { transform: scaleY(1); animation-delay: -420ms; }
.playing-bars i:nth-child(3) { transform: scaleY(.8); animation-delay: -220ms; }
:root[data-performance='low'] .playing-bars i { animation: none; }
@keyframes playing-bar {
from { transform: scaleY(.3); }
to { transform: scaleY(1); }
}
.song-row .artwork { width: 42px; }
.song-row .artwork > svg { width: 22px; }
.song-action { color: var(--color-foreground-subtle); }
.song-action:hover:not(:disabled), .song-action:focus-visible:not(:disabled) { color: var(--color-brand); background: transparent; }
.song-action:active:not(:disabled) { color: var(--color-brand); background: transparent; transform: none; }
.song-action[aria-busy='true'] { cursor: progress; opacity: .7; color: var(--color-brand); }
/* 触屏没有悬停,按钮要一直显示。占位不变,出现时这一行的文字不会跳动。 */
@media (hover: hover) {
.song-action-secondary { opacity: 0; transition: opacity 120ms ease; }
.song-row:hover .song-action-secondary, .song-row:focus-within .song-action-secondary, .song-action-secondary[aria-busy='true'] { opacity: 1; }
}
.song-index { width: 22px; flex-shrink: 0; }
.song-copy { display: flex; flex-direction: column; min-width: 0; gap: 3px; flex: 1; }
.song-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
+20 -19
View File
@@ -2,37 +2,38 @@ FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build
ARG TARGETOS TARGETARCH
ARG VERSION=dev
WORKDIR /src
COPY go.mod go.sum ./
COPY cmd ./cmd
COPY db ./db
COPY internal ./internal
COPY protocol ./protocol
COPY --parents go.mod go.sum cmd db internal protocol ./
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -tags grpcnotrace \
-ldflags "-s -w -X github.com/dihemoe/alisten/internal/bootstrap.version=$VERSION" \
-o /out/alisten ./cmd/alisten
-o /out/usr/local/bin/alisten ./cmd/alisten
COPY LICENSE /out/usr/share/doc/alisten/
FROM --platform=$BUILDPLATFORM debian:trixie-slim AS ffmpeg
ARG TARGETARCH
ARG FFMPEG_VERSION=7.1.5
COPY docker/build-ffmpeg.sh /tmp/
RUN sh /tmp/build-ffmpeg.sh
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS tools
ARG TARGETARCH
ARG YTDLP_VERSION=2026.10.21
ARG DENO_VERSION=v2.9.8
RUN case "$TARGETARCH" in amd64) ytdlp=yt-dlp_linux deno=x86_64 ;; arm64) ytdlp=yt-dlp_linux_aarch64 deno=aarch64 ;; esac \
&& wget -q --tries=3 --timeout=30 -O /tmp/yt-dlp.zip "https://github.com/yt-dlp/yt-dlp/releases/download/$YTDLP_VERSION/$ytdlp.zip" \
&& wget -q --tries=3 --timeout=30 -O /tmp/deno.zip "https://github.com/denoland/deno/releases/download/$DENO_VERSION/deno-$deno-unknown-linux-gnu.zip" \
&& mkdir -p /out/opt/yt-dlp /out/usr/local/bin \
&& unzip -q /tmp/yt-dlp.zip -d /out/opt/yt-dlp \
&& unzip -q /tmp/deno.zip -d /out/usr/local/bin \
&& ln -s "/opt/yt-dlp/$ytdlp" /out/usr/local/bin/yt-dlp
COPY --from=ffmpeg /out /out
COPY docker/fetch-tools.sh /tmp/
RUN sh /tmp/fetch-tools.sh
FROM debian:trixie-slim AS alisten-worker
COPY --from=tools /out /
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends ca-certificates ffmpeg \
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 65532 alisten \
&& useradd --system --uid 65532 --gid alisten --home-dir /var/cache/alisten --no-create-home --shell /usr/sbin/nologin alisten \
&& install -d -o alisten -g alisten /var/cache/alisten
COPY LICENSE /usr/share/doc/alisten/
COPY --from=tools /out /
COPY --from=build /out/alisten /usr/local/bin/alisten
&& useradd --system --uid 65532 --gid alisten --home-dir /var/cache/alisten --no-create-home \
--shell /usr/sbin/nologin alisten \
&& install -d -o alisten -g alisten /var/cache/alisten \
&& ffmpeg -hide_banner -encoders | grep -q libmp3lame \
&& ffprobe -hide_banner -version >/dev/null
COPY --from=build /out /
ENV XDG_CACHE_HOME=/var/cache/alisten
USER 65532:65532
EXPOSE 9090