Files
silo-server/internal/audiobooks/abs/file_handler.go

232 lines
7.7 KiB
Go

package abs
import (
"crypto/md5"
"encoding/hex"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/playback"
)
const publicTrackSessionTTL = 24 * time.Hour
// 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")
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
files, err := h.deps.MediaStore.GetMediaFiles(r.Context(), contentID, access)
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
}
}
// handlePublicTrack serves audio bytes for ONE track of a playback session.
//
// Real ABS Android client (v2.22.0+) builds the streaming URL as
//
// $serverAddress/public/session/{sessionId}/track/{audioTrack.index}
//
// WITHOUT appending any ?token=. The session ID itself is the capability:
// it's a 128-bit ULID, only known to the client that received it from
// /play, and tied server-side to (userID, contentID). This matches both
// the canonical continuum-plugin handler and booklore-ng's implementation.
//
// See android: PlaybackSession.kt:getContentUri (gte 2.22.0 + DirectPlay branch).
//
// Resolution:
// 1. Look up the session by sid via PlaybackSessionStore.
// 2. Load the ordered media-files list for the session's contentID.
// 3. files[idx-1] is the requested track (silo emits 1-based wireIndex).
// 4. Stream via playback.ServeDirectPlay (handles Range + HEAD).
//
// Mounted OUTSIDE bearerAuth: the client sends no Authorization header on
// this endpoint, and the session ID alone authorises access.
func (h *Handler) handlePublicTrack(w http.ResponseWriter, r *http.Request) {
sid := chi.URLParam(r, "sid")
idxStr := chi.URLParam(r, "idx")
if sid == "" || idxStr == "" {
http.Error(w, "sid and idx required", http.StatusBadRequest)
return
}
idx, err := strconv.Atoi(idxStr)
if err != nil || idx < 1 {
http.Error(w, "idx must be a positive integer", http.StatusBadRequest)
return
}
if h.deps.PlaybackSessionStore == nil {
http.Error(w, "session store not configured", http.StatusServiceUnavailable)
return
}
sess, err := h.deps.PlaybackSessionStore.GetPlaybackSession(r.Context(), sid)
if err != nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
if sess.ClosedAt != nil {
http.Error(w, "session closed", http.StatusGone)
return
}
if publicTrackSessionExpired(sess, time.Now()) {
http.Error(w, "session expired", http.StatusGone)
return
}
if h.beginNativePlaybackTransport(sid) {
defer h.endNativePlaybackTransport(sid)
}
access, err := h.accessFilterForAuth(r.Context(), ctxAuth{UserID: sess.UserID, ProfileID: sess.ProfileID})
if err != nil {
http.Error(w, "session access denied", http.StatusForbidden)
return
}
files, err := h.deps.MediaStore.GetMediaFiles(r.Context(), sess.ContentID, access)
if err != nil || len(files) == 0 {
http.Error(w, "item files not found", http.StatusNotFound)
return
}
if idx > len(files) {
http.Error(w, "track index out of range", http.StatusNotFound)
return
}
mediaFile := files[idx-1]
ext := strings.ToLower(filepath.Ext(mediaFile.FilePath))
if ct := audioContentType(ext); ct != "" {
w.Header().Set("Content-Type", ct)
}
_ = playback.ServeDirectPlay(w, r, mediaFile.FilePath)
}
func publicTrackSessionExpired(sess ABSPlaybackSession, now time.Time) bool {
anchor := sess.StartedAt
if sess.LastSyncAt.After(anchor) {
anchor = sess.LastSyncAt
}
return !anchor.IsZero() && now.After(anchor.Add(publicTrackSessionTTL))
}
// 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 ""
}