// Package service — direct-play / HLS streaming. // // StreamService exposes two flavours of playback: // // - Direct play: the original file is served with HTTP Range support. // Works for browser-friendly containers (mp4 / webm / m4v), no ffmpeg // involved, zero CPU overhead. // - HLS: when the client opts in (or the source codec / container is // not browser-friendly), the TranscoderService runs ffmpeg in the // background and we serve the resulting .m3u8 + .ts files directly. // // The HTTP layer decides which mode to use based on the request path: // // GET /api/stream/:id → direct play // GET /api/hls/:id/index.m3u8 → HLS playlist // GET /api/hls/:id/seg_NNNNN.ts → HLS segment package service import ( "context" "errors" "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/repository" ) const ( STRMEnabledSettingKey = "strm.enabled" CloudPlaybackModeSettingKey = "cloud.playback_mode" CloudPlaybackSTRMEnabledSettingKey = "cloud.playback_strm_enabled" CloudPlaybackRedirectEnabledSettingKey = "cloud.playback_redirect_proxy_enabled" CloudPlaybackModeSTRM = "strm" CloudPlaybackModeRedirectProxy = "redirect_proxy" ) type CloudPlaybackOptions struct { STRMEnabled bool RedirectProxyEnabled bool PreferredMode string } // StreamService serves media files with proper Range support so browsers can // seek into the stream. type StreamService struct { cfg *config.Config log *zap.Logger repo *repository.Container transcoder *TranscoderService } // NewStreamService is the constructor. func NewStreamService(cfg *config.Config, log *zap.Logger, repo *repository.Container, transcoder *TranscoderService) *StreamService { return &StreamService{ cfg: cfg, log: log, repo: repo, transcoder: transcoder, } } // ErrMediaNotFound is returned when the media row or its file is missing. var ErrMediaNotFound = errors.New("media not found") // ErrCloudPlaybackUnavailable 表示媒体行存在但属于云盘媒体、且当前无法 // 构造可用的播放重定向(通常是 STRMURL 缺失,需要重新扫描媒体库)。 // 调用方应把它与「媒体不存在」区分开,避免把配置类故障当成 404 返回给播放器。 var ErrCloudPlaybackUnavailable = errors.New("cloud media playback unavailable: media missing play url; re-scan the library") var ErrCloudPlaybackDisabled = errors.New("cloud media playback disabled by admin settings") // normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。 // // STRMURL 是扫描时根据当时的 server_url/请求地址生成并固化进数据库的。 // 在 Windows 开发机上扫描、再部署到 Docker(或更换了内网 IP/域名)后, // 这些绝对 URL 会指向已失效的旧地址,第三方播放器跟随 302 就会拿到 // 连接失败/404。这里只要能从 URL 中解析出 provider+ref,就重建为相对 // /api/cloud/play 路径,由 absoluteInternalRedirect 基于「当前请求」补全 // host,从而对历史脏数据免疫。 func normalizeCloudPlayTarget(raw string) string { typ, ref, ok := parseCloudMediaPlaybackURL(raw) if !ok { return raw } return BuildRelativeCloudPlayURL(typ, ref) } // BuildRelativeCloudPlayURL 构造相对的云盘播放 API 路径。 func BuildRelativeCloudPlayURL(typ, ref string) string { return "/api/cloud/play/" + url.PathEscape(strings.TrimSpace(typ)) + "?" + url.Values{"ref": []string{ref}}.Encode() } // directPlayOnly reports whether the admin enabled「客户端直连解码」mode, // in which the host never transcodes (HLS is refused) and all playback is // handled by the client (direct play / 302 redirect). func (s *StreamService) directPlayOnly(ctx context.Context) bool { if s.repo == nil || s.repo.Setting == nil { return false } v, err := s.repo.Setting.Get(ctx, PlaybackDirectOnlySettingKey) if err != nil { return false } return parseBoolSetting(v, false) } // withAuthToken propagates the caller's auth token onto an internal redirect // target. A browser