* fix(scanner): reject durations that imply an impossible bitrate The duration-plausibility rule only rejected videos of 10 seconds or less, so a feature film that probed as 61 seconds passed untouched and persisted. Clients then had nothing trustworthy to anchor on: Android's grow-only duration ratchet has no floor to hold when the catalog value is wrong, so the playback engine's growing-HLS-window duration won and a 90-minute movie displayed as ~1 minute. Size and duration together pin an implied bitrate, which separates the two cases the absolute floor conflates. A genuine short clip has an ordinary bitrate; a 100 GB file claiming 61 seconds implies ~13 Gbps. The ceiling sits far above any real medium, so legitimate content cannot trip it — and unlike the absolute floor, it does not false-positive on a genuine high-bitrate short. Also bump the repair-rule revision marker so rows judged by the previous, weaker rule are re-checked once under this one. Without that bump an improved rule never reaches the rows it was written for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(playback): publish source runtime in v3 plans and stop faking the copy seek window Two defects with one root: a v3 plan described where playback sits without ever stating how long the media is. Add source.duration_seconds. It is the file's full runtime, never `total - source_start` and never adjusted by timeline_offset_seconds, and it is omitted rather than null when unknown — clients that coerce null to a numeric default would read it as zero, the exact value this field exists to stop them inventing. It is set in SourceDescriptorFromFileV3, the single place every delivery already flows through, so direct play, progressive remux, HLS remux and HLS transcode all carry it. Until now the v3 plan omitted duration entirely, so clients fell back to the playback engine. On an HLS copy remux the server intentionally serves FFmpeg's still-growing playlist, so the engine reports the length produced so far. With no server-supplied runtime to anchor on, a feature film played back as a couple of minutes. The legacy protocol already answered this correctly via fileDurationSeconds; this restores parity. Separately, the copy branch published seek_window_end_seconds as the media runtime. That made the window look *complete*, which clients read as proof that any target inside it is locally seekable, so they native-seek past the produced head of a growing playlist instead of asking for a reanchor. Leave the end open: an incomplete window plus can_seek_anywhere=false routes every seek through the server, which is what legacy did before v3 added the bound. Advertise plan_source_duration_v1 so a client can distinguish "this server does not populate the field" from "this server knows the runtime is genuinely unknown" — without it, both look like an absent field and a client cannot tell whether its own catalog fallback is still required. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(web): pair the exit position with the media runtime, not the element duration The player's exit state converts its position to media time but took the duration from the video element, which is player-local. On a remux or transcode stream the element only covers the window produced so far, so the two values live in different coordinate systems. Resuming a movie 50 minutes in makes that concrete: the exit position is ~3060s of media time while the element reports ~120s. The progress cache then evaluates `position >= duration`, marks the item completed, latches the watched badge, and — because completion clears the resume point — resets position to 0. Exiting a resumed movie destroyed the resume point and claimed it had been watched. The server's runtime is authoritative and already expressed in media time, so prefer it and fall back to the element only when no server value exists. The rule moves into mediaTimeline.ts next to the coordinate conversions it depends on, which is also what makes it testable — VideoPlayer itself has no test harness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
329 lines
11 KiB
Go
329 lines
11 KiB
Go
package playback
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
// SourceDurationSecondsV3 reports a media file's runtime, or nil when it is
|
|
// unknown. models.MediaFile.Duration stores 0 for "probe failed", so a zero
|
|
// must never reach a client as a duration. This mirrors the legacy start
|
|
// response's fileDurationSeconds so both protocols answer identically.
|
|
func SourceDurationSecondsV3(file *models.MediaFile) *float64 {
|
|
if file == nil || file.Duration <= 0 {
|
|
return nil
|
|
}
|
|
duration := float64(file.Duration)
|
|
return &duration
|
|
}
|
|
|
|
func SourceDescriptorFromFileV3(file *models.MediaFile, audioIndex int) SourceDescriptorV3 {
|
|
if file == nil {
|
|
return SourceDescriptorV3{DVEnhancementLayer: EnhancementUnknownV3}
|
|
}
|
|
source := SourceDescriptorV3{
|
|
MediaFileID: file.ID,
|
|
DurationSeconds: SourceDurationSecondsV3(file),
|
|
Container: normalizeCodecV3(file.Container),
|
|
VideoCodec: normalizeCodecV3(file.CodecVideo),
|
|
AudioCodec: normalizeCodecV3(file.CodecAudio),
|
|
AudioChannels: file.AudioChannels,
|
|
BitrateKbps: normalizeBitrateKbpsV3(file.Bitrate),
|
|
DVEnhancementLayer: EnhancementNoneV3,
|
|
}
|
|
if len(file.VideoTracks) > 0 {
|
|
track := file.VideoTracks[0]
|
|
source.VideoCodec = firstNonEmptyV3(normalizeCodecV3(track.Codec), source.VideoCodec)
|
|
source.VideoProfile = strings.ToLower(strings.TrimSpace(track.Profile))
|
|
source.VideoLevel = track.Level
|
|
source.BitDepth = models.NormalizeVideoBitDepth(track.BitDepth, track.PixelFormat, track.Profile)
|
|
source.ColorRange = normalizeColorRangeV3(track.ColorRange)
|
|
source.Width = track.Width
|
|
source.Height = track.Height
|
|
source.FrameRate = parseFrameRateV3(track.FrameRate)
|
|
if track.Bitrate > 0 {
|
|
source.BitrateKbps = normalizeBitrateKbpsV3(track.Bitrate)
|
|
}
|
|
source.DynamicRange = normalizeDynamicRangeV3(track)
|
|
source.HDR10Plus = track.HDR10Plus || strings.Contains(strings.ToLower(track.VideoRangeType), "hdr10+")
|
|
source.DVProfile = track.DVProfile
|
|
source.DVBLCompatID = track.DVBLCompatID
|
|
switch EnhancementLayerV3(strings.ToLower(track.DVEnhancementLayer)) {
|
|
case EnhancementNoneV3, EnhancementMELV3, EnhancementFELV3, EnhancementUnknownV3:
|
|
source.DVEnhancementLayer = EnhancementLayerV3(strings.ToLower(track.DVEnhancementLayer))
|
|
case "":
|
|
// Legacy rows predate the explicit enhancement-layer fields. A
|
|
// Profile 7 DOVIWithEL label proves an EL exists but cannot prove
|
|
// MEL versus FEL, so keep it unknown rather than misclassifying it
|
|
// as a safe single-layer stream.
|
|
legacyProfile7EL := track.DVProfile == 7 && strings.Contains(strings.ToLower(track.VideoRangeType), "withel")
|
|
if track.DVELPresent || legacyProfile7EL {
|
|
source.DVEnhancementLayer = EnhancementUnknownV3
|
|
} else {
|
|
source.DVEnhancementLayer = EnhancementNoneV3
|
|
}
|
|
default:
|
|
source.DVEnhancementLayer = EnhancementUnknownV3
|
|
}
|
|
}
|
|
if source.Width == 0 || source.Height == 0 {
|
|
source.Width, source.Height = dimensionsFromResolutionV3(file.Resolution)
|
|
}
|
|
if audioIndex >= 0 && audioIndex < len(file.AudioTracks) {
|
|
track := file.AudioTracks[audioIndex]
|
|
source.AudioCodec = firstNonEmptyV3(normalizeCodecV3(track.Codec), source.AudioCodec)
|
|
source.AudioChannels = track.Channels
|
|
source.AudioLayout = normalizeLayoutV3(track.Layout)
|
|
}
|
|
if source.DynamicRange == "" {
|
|
if file.HDR {
|
|
source.DynamicRange = "hdr_unknown"
|
|
} else {
|
|
source.DynamicRange = "sdr"
|
|
}
|
|
}
|
|
return source
|
|
}
|
|
|
|
func normalizeColorRangeV3(value string) string {
|
|
switch normalized := strings.ToLower(strings.TrimSpace(value)); normalized {
|
|
case "tv", "pc", "unknown":
|
|
return normalized
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func detailedVideoEligibleV3(source SourceDescriptorV3, request StartRequestV3) bool {
|
|
if !HasFeatureV3(request.ClientFeatures, FeatureDetailedDecodeV3) && !HasFeatureV3(request.ClientPlaybackContext.Features, FeatureDetailedDecodeV3) {
|
|
return false
|
|
}
|
|
if !detailedVideoEvidenceCompleteV3(source) {
|
|
return false
|
|
}
|
|
for _, capability := range request.Capabilities.VideoDecode {
|
|
if !strings.EqualFold(capability.Codec, source.VideoCodec) || !capability.Hardware {
|
|
continue
|
|
}
|
|
if len(capability.Profiles) > 0 && !containsFoldV3(capability.Profiles, source.VideoProfile) {
|
|
continue
|
|
}
|
|
if len(capability.Levels) > 0 && !containsAtLeastV3(capability.Levels, source.VideoLevel) {
|
|
continue
|
|
}
|
|
if len(capability.BitDepths) > 0 && !containsIntV3(capability.BitDepths, source.BitDepth) {
|
|
continue
|
|
}
|
|
if capability.MaxWidth > 0 && source.Width > capability.MaxWidth || capability.MaxHeight > 0 && source.Height > capability.MaxHeight || capability.MaxFrameRate > 0 && source.FrameRate > capability.MaxFrameRate || capability.MaxBitrateKbps > 0 && source.BitrateKbps > capability.MaxBitrateKbps {
|
|
continue
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func detailedVideoEvidenceCompleteV3(source SourceDescriptorV3) bool {
|
|
return source.VideoCodec != "" &&
|
|
source.VideoProfile != "" &&
|
|
source.VideoLevel > 0 &&
|
|
source.BitDepth > 0 &&
|
|
source.Width > 0 &&
|
|
source.Height > 0 &&
|
|
source.FrameRate > 0 &&
|
|
source.BitrateKbps > 0
|
|
}
|
|
|
|
func outputRangeEligibleV3(source SourceDescriptorV3, request StartRequestV3) (bool, VideoClaimsV3) {
|
|
hdr := request.ClientPlaybackContext.Output.HDRDetails
|
|
if hdr == nil {
|
|
hdr = request.Capabilities.HDRDetails
|
|
}
|
|
claims := VideoClaimsV3{}
|
|
switch source.DynamicRange {
|
|
case "", "sdr":
|
|
return true, claims
|
|
case "hdr10":
|
|
claims.HDR10 = hdr != nil && hdr.HDR10
|
|
return claims.HDR10, claims
|
|
case "hdr_unknown":
|
|
// Legacy rows only recorded a file-level HDR flag without per-track
|
|
// range metadata. HDR10 is by far the most common static-HDR range, so
|
|
// an HDR10-capable output treats the source as HDR10 instead of
|
|
// refusing playback outright; the planner attaches a degradation
|
|
// warning for these assumed-range plans.
|
|
claims.HDR10 = hdr != nil && hdr.HDR10
|
|
return claims.HDR10, claims
|
|
case "hdr10_plus":
|
|
claims.HDR10Plus = hdr != nil && hdr.HDR10Plus
|
|
return claims.HDR10Plus, claims
|
|
case "hlg":
|
|
claims.HLG = hdr != nil && hdr.HLG
|
|
return claims.HLG, claims
|
|
case "dolby_vision":
|
|
if source.DVProfile == 7 && source.DVEnhancementLayer == EnhancementUnknownV3 {
|
|
claims.DolbyVisionReason = "profile_7_enhancement_layer_unknown"
|
|
return false, claims
|
|
}
|
|
if hdr != nil && containsIntV3(hdr.DolbyVisionProfiles, source.DVProfile) {
|
|
claims.DolbyVision = true
|
|
claims.DolbyVisionReason = "native_profile_supported"
|
|
return true, claims
|
|
}
|
|
claims.DolbyVisionReason = "native_profile_not_supported"
|
|
return false, claims
|
|
default:
|
|
return false, claims
|
|
}
|
|
}
|
|
|
|
func clientSupportsHDR10V3(request StartRequestV3) bool {
|
|
hdr := request.ClientPlaybackContext.Output.HDRDetails
|
|
if hdr == nil {
|
|
hdr = request.Capabilities.HDRDetails
|
|
}
|
|
return hdr != nil && hdr.HDR10
|
|
}
|
|
|
|
func audioEligibilityV3(source SourceDescriptorV3, request StartRequestV3) (copyOK, passthrough bool, claim AudioClaimsV3) {
|
|
claim.Codec = source.AudioCodec
|
|
passthroughCaps := request.ClientPlaybackContext.Output.AudioPassthrough
|
|
if passthroughCaps == nil {
|
|
passthroughCaps = request.Capabilities.AudioPassthrough
|
|
}
|
|
if passthroughCaps != nil && containsFoldV3(passthroughCaps.PassthroughCodecs, source.AudioCodec) &&
|
|
(HasFeatureV3(request.ClientFeatures, FeatureLayoutPassthrough) || HasFeatureV3(request.ClientPlaybackContext.Features, FeatureLayoutPassthrough)) {
|
|
for _, entry := range passthroughCaps.Entries {
|
|
if !strings.EqualFold(entry.Codec, source.AudioCodec) || len(entry.ChannelCounts) == 0 || len(entry.Layouts) == 0 ||
|
|
!containsIntV3(entry.ChannelCounts, source.AudioChannels) || !containsFoldV3(entry.Layouts, source.AudioLayout) {
|
|
continue
|
|
}
|
|
claim.Passthrough = true
|
|
claim.AtmosPreserved = strings.Contains(strings.ToLower(source.AudioLayout), "joc") || strings.Contains(strings.ToLower(source.AudioLayout), "atmos")
|
|
claim.Reason = "sink_passthrough_validated"
|
|
return true, true, claim
|
|
}
|
|
}
|
|
if containsFoldV3(request.Capabilities.CodecsAudio, source.AudioCodec) {
|
|
claim.Reason = "client_decode_supported"
|
|
return true, false, claim
|
|
}
|
|
if passthroughCaps != nil && containsFoldV3(passthroughCaps.PassthroughCodecs, source.AudioCodec) {
|
|
claim.Reason = "passthrough_layout_unsupported"
|
|
} else {
|
|
claim.Reason = "audio_codec_unsupported"
|
|
}
|
|
return false, false, claim
|
|
}
|
|
|
|
func normalizeDynamicRangeV3(track models.VideoTrack) string {
|
|
if track.DVProfile > 0 || strings.Contains(strings.ToLower(track.VideoRangeType), "dovi") || strings.Contains(strings.ToLower(track.DolbyVision), "dolby") {
|
|
return "dolby_vision"
|
|
}
|
|
if track.HDR10Plus || strings.Contains(strings.ToLower(track.VideoRangeType), "hdr10+") {
|
|
return "hdr10_plus"
|
|
}
|
|
joined := strings.ToLower(strings.Join([]string{track.VideoRange, track.VideoRangeType, track.ColorTransfer}, " "))
|
|
if strings.Contains(joined, "hlg") || strings.Contains(joined, "arib-std-b67") {
|
|
return "hlg"
|
|
}
|
|
if strings.Contains(joined, "hdr") || strings.Contains(joined, "smpte2084") || strings.Contains(joined, "pq") {
|
|
return "hdr10"
|
|
}
|
|
if joined == " " || strings.TrimSpace(joined) == "" {
|
|
return ""
|
|
}
|
|
return "sdr"
|
|
}
|
|
|
|
func parseFrameRateV3(value string) float64 {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return 0
|
|
}
|
|
if parts := strings.Split(value, "/"); len(parts) == 2 {
|
|
n, nErr := strconv.ParseFloat(parts[0], 64)
|
|
d, dErr := strconv.ParseFloat(parts[1], 64)
|
|
if nErr == nil && dErr == nil && d != 0 {
|
|
return n / d
|
|
}
|
|
}
|
|
v, _ := strconv.ParseFloat(value, 64)
|
|
return v
|
|
}
|
|
|
|
func normalizeBitrateKbpsV3(value int) int {
|
|
if value > 10_000_000 {
|
|
return value / 1000
|
|
}
|
|
return value
|
|
}
|
|
|
|
func dimensionsFromResolutionV3(value string) (int, int) {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "4320p", "8k":
|
|
return 7680, 4320
|
|
case "2160p", "4k", "uhd":
|
|
return 3840, 2160
|
|
case "1080p", "fhd":
|
|
return 1920, 1080
|
|
case "720p", "hd":
|
|
return 1280, 720
|
|
case "480p", "sd":
|
|
return 854, 480
|
|
default:
|
|
return 0, 0
|
|
}
|
|
}
|
|
|
|
func normalizeCodecV3(value string) string {
|
|
v := strings.ToLower(strings.TrimSpace(value))
|
|
switch v {
|
|
case "h265", "h.265", "x265":
|
|
return "hevc"
|
|
case "h264", "h.264", "avc", "x264":
|
|
return "h264"
|
|
case "eac3", "e-ac-3", "ec-3":
|
|
return "eac3"
|
|
case "truehd", "mlp fba":
|
|
return "truehd"
|
|
default:
|
|
return v
|
|
}
|
|
}
|
|
|
|
func normalizeLayoutV3(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
|
|
func firstNonEmptyV3(values ...string) string {
|
|
for _, v := range values {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
func containsFoldV3(values []string, wanted string) bool {
|
|
for _, v := range values {
|
|
if strings.EqualFold(strings.TrimSpace(v), strings.TrimSpace(wanted)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func containsIntV3(values []int, wanted int) bool {
|
|
for _, v := range values {
|
|
if v == wanted {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func containsAtLeastV3(values []int, wanted int) bool {
|
|
for _, v := range values {
|
|
if v >= wanted {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|