Stage 3 of the ABS-compat port. handleFileStream serves a single audio file by ino (which we map to silo's media_files.id); handlePlayStart issues a play-session manifest pointing at silo's existing /api/v1/direct-download stream URL. Plugin's bookref/ streaming/mediatoken abstractions are replaced with direct MediaStore lookups against silo's tables. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
138 lines
4.6 KiB
Go
138 lines
4.6 KiB
Go
package abs
|
|
|
|
import (
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/playback"
|
|
)
|
|
|
|
// trackInoFor derives a stable, ABS-compatible inode string from a content ID
|
|
// and a 0-based file index. Real ABS uses the filesystem inode (a large
|
|
// positive BigInt-shaped string); we hash to a 12-hex-digit prefix and parse
|
|
// it as a decimal so the client sees an identifier of the same shape.
|
|
//
|
|
// Stability matters: the mobile app keys offline downloads by ino. This
|
|
// implementation must remain bit-for-bit identical to the ino that handlePlayStart
|
|
// embeds in the track list, otherwise file lookups will fail.
|
|
func trackInoFor(contentID string, fileIdx int) string {
|
|
sum := md5.Sum([]byte(contentID + "/" + strconv.Itoa(fileIdx)))
|
|
hexStr := hex.EncodeToString(sum[:6])
|
|
n, _ := strconv.ParseUint(hexStr, 16, 64)
|
|
return strconv.FormatUint(n, 10)
|
|
}
|
|
|
|
// handleFileStream serves the audio bytes for one file of an audiobook library
|
|
// item. Real ABS uses /api/items/{id}/file/{ino}/download for offline-save
|
|
// and /api/items/{id}/file/{ino}?token=<jwt> for iOS streaming; both URL
|
|
// patterns share this handler.
|
|
//
|
|
// Auth is via the bearerAuth middleware, which accepts the Authorization header
|
|
// and a ?token= query-param fallback (the iOS streaming variant uses ?token=
|
|
// because AVPlayer doesn't add Authorization on its own subrequests).
|
|
//
|
|
// "ino" in real ABS is the file's filesystem inode. We synthesise an
|
|
// MD5-derived inode-shaped string per trackInoFor — the same value emitted by
|
|
// handlePlayStart. To reverse: call GetMediaFiles for the item, then find the
|
|
// file whose index matches the ino. As a fallback we also accept a bare
|
|
// 0-based integer index.
|
|
//
|
|
// Behaviour:
|
|
// - Validate ABS bearer token (bearerAuth middleware has already done this).
|
|
// - Look up the requested file in silo's media_files table.
|
|
// - Serve the bytes directly with Range-request support via playback.ServeDirectPlay.
|
|
// - Set Content-Disposition: attachment on /download paths to encourage
|
|
// browser save-to-disk / mobile offline-save behaviour.
|
|
func (h *Handler) handleFileStream(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := absAuthFrom(r)
|
|
if !ok || a.UserID == "" {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
contentID := chi.URLParam(r, "libraryItemId")
|
|
inoStr := chi.URLParam(r, "ino")
|
|
|
|
files, err := h.deps.MediaStore.GetMediaFiles(r.Context(), contentID)
|
|
if err != nil || len(files) == 0 {
|
|
http.Error(w, "item not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Resolve the ino back to a file by recomputing trackInoFor for each file
|
|
// at its 0-based position in the sorted slice. Keeping the resolution logic
|
|
// here (symmetric with handlePlayStart's generation) means both paths stay
|
|
// in sync whenever the sort order or ino derivation changes.
|
|
fileIdx := -1
|
|
for i, f := range files {
|
|
if trackInoFor(contentID, i) == inoStr {
|
|
fileIdx = i
|
|
_ = f
|
|
break
|
|
}
|
|
}
|
|
|
|
// Fallback: legacy or third-party callers sometimes pass the raw 0-based
|
|
// file index directly. Accept it when it resolves to a real position.
|
|
if fileIdx < 0 {
|
|
if n, err := strconv.Atoi(inoStr); err == nil && n >= 0 && n < len(files) {
|
|
fileIdx = n
|
|
}
|
|
}
|
|
|
|
if fileIdx < 0 {
|
|
http.Error(w, "file not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
mediaFile := files[fileIdx]
|
|
|
|
// /download variant: hint the client to save rather than stream.
|
|
if strings.HasSuffix(r.URL.Path, "/download") {
|
|
filename := filepath.Base(mediaFile.FilePath)
|
|
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
|
}
|
|
|
|
// Set Content-Type for audio files. ServeDirectPlay uses MimeFromExtension
|
|
// which covers video containers; we override with audio-specific MIME
|
|
// types because ABS clients pattern-match on Content-Type.
|
|
ext := strings.ToLower(filepath.Ext(mediaFile.FilePath))
|
|
if ct := audioContentType(ext); ct != "" {
|
|
w.Header().Set("Content-Type", ct)
|
|
}
|
|
|
|
if err := playback.ServeDirectPlay(w, r, mediaFile.FilePath); err != nil {
|
|
// ServeDirectPlay has already written an error response; just log.
|
|
return
|
|
}
|
|
}
|
|
|
|
// audioContentType returns an audio MIME type for the given file extension
|
|
// (including the dot). Returns empty string for unknown extensions, letting
|
|
// ServeDirectPlay fall back to its own MIME detection.
|
|
func audioContentType(ext string) string {
|
|
switch ext {
|
|
case ".mp3":
|
|
return "audio/mpeg"
|
|
case ".m4b", ".m4a":
|
|
return "audio/mp4"
|
|
case ".flac":
|
|
return "audio/flac"
|
|
case ".ogg":
|
|
return "audio/ogg"
|
|
case ".opus":
|
|
return "audio/opus"
|
|
case ".wav":
|
|
return "audio/wav"
|
|
case ".aac":
|
|
return "audio/aac"
|
|
}
|
|
return ""
|
|
}
|