Release v0.2.34

This commit is contained in:
ponzischeme89
2026-08-09 08:25:50 +12:00
parent b1128bcce2
commit fdd9e6cab2
116 changed files with 11418 additions and 879 deletions
+357
View File
@@ -0,0 +1,357 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/subsync"
)
// Fixing a subtitle's timing.
//
// The whole of the work is in internal/subsync, which is pure. What lives here is the part
// that cannot be: choosing which track to measure against, reading both of them out of
// wherever they happen to live, and storing the result as an ordinary sidecar so it is a
// track on every later playback rather than something that exists only in this response.
//
// It is deliberately a separate route from the download one. A viewer whose subtitle is
// out of sync already *has* the file they want, and sending them through a provider search
// to get another copy of it — which may be equally out — is the wrong answer to what they
// asked.
// subtitleFixSuffix marks the repaired copy. It is part of the stored id, so fixing the
// same track twice replaces the earlier attempt instead of growing a third row that a
// viewer has to tell apart from the other two by guessing.
const subtitleFixSuffix = "fixed"
type subtitleFixRequest struct {
SubtitleID string `json:"subtitleId"`
}
type subtitleFixResponse struct {
// SubtitleID is the new track to select, absent when nothing needed changing.
SubtitleID string `json:"subtitleId,omitempty"`
Message string `json:"message"`
// Changed is false when the track was already in sync. It is on the wire rather than
// inferred from an empty id because "already right" is a success a viewer should be
// told about plainly, not a silent no-op that reads as the button having failed.
Changed bool `json:"changed"`
// OffsetMs and Reference are what was done and what it was judged against. A viewer
// deciding whether to trust the result needs both.
OffsetMs int64 `json:"offsetMs"`
Reference string `json:"reference,omitempty"`
}
// subtitleFixAvailable answers the smaller question the playback response needs: whether
// opening the timing action can lead anywhere for this title. The handler remains
// authoritative because a track can disappear or fail to parse between playback and the
// press, but not advertising an impossible action avoids a guaranteed refusal in the menu.
func (s *Server) subtitleFixAvailable(tracks []playableSubtitle) bool {
if s.store == nil {
return false
}
for _, target := range tracks {
if len(referenceCandidates(target, tracks)) > 0 {
return true
}
}
return false
}
func (s *Server) handleSubtitleFix(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
itemID := r.PathValue("id")
if s.store == nil {
writeError(w, http.StatusServiceUnavailable, "this server cannot fix subtitle timing")
return
}
var req subtitleFixRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid subtitle fix request")
return
}
if strings.TrimSpace(req.SubtitleID) == "" {
writeError(w, http.StatusBadRequest, "no subtitle was named")
return
}
cred := credentials(sess)
tracks, mediaSourceID, _, _, _ := s.playbackSubtitles(
ctx, cred, itemID, 0, nil, "", false, sessionPlaybackCapabilities(sess),
)
target, ok := trackByID(tracks, req.SubtitleID)
if !ok {
writeError(w, http.StatusNotFound, "that subtitle is no longer on this title")
return
}
log := s.loggerFor(ctx)
fixed, err := s.fixSubtitleTiming(ctx, cred, itemID, mediaSourceID, target, tracks)
if err != nil {
var refusal *subsync.ErrNoAlignment
if errors.As(err, &refusal) {
// A refusal is the ordinary answer, not a fault: most of subsync's job is
// declining to guess. It is reported as a 200 carrying the reason, because
// the viewer needs the sentence and an error status would have the client
// print its own generic one over the top of it.
log.Info("subtitle timing not fixed", "item_id", itemID,
"subtitle", req.SubtitleID, "reason", refusal.Reason,
"score", refusal.Score, "margin", refusal.Margin)
writeJSON(w, http.StatusOK, subtitleFixResponse{Message: refusal.Reason})
return
}
log.Warn("subtitle fix failed", "item_id", itemID, "subtitle", req.SubtitleID, "error", err)
writeError(w, http.StatusBadGateway, subtitleFixFailureMessage(err))
return
}
log.Info("subtitle timing fixed", "item_id", itemID, "subtitle", req.SubtitleID,
"reference", fixed.Reference, "correction", fixed.Correction,
"score", fixed.Score, "changed", fixed.Changed)
writeJSON(w, http.StatusOK, fixed.response())
}
type subtitleFixOutcome struct {
StoredID string
Reference string
Correction string
OffsetMs int64
Score float64
Changed bool
}
func (o subtitleFixOutcome) response() subtitleFixResponse {
if !o.Changed {
return subtitleFixResponse{
Message: "This subtitle is already in time with " + o.Reference + ".",
Reference: o.Reference,
}
}
return subtitleFixResponse{
SubtitleID: o.StoredID,
Message: fmt.Sprintf("Timing corrected by %s against %s. The fixed copy is in the list.",
o.Correction, o.Reference),
Changed: true,
OffsetMs: o.OffsetMs,
Reference: o.Reference,
}
}
// fixSubtitleTiming reads both tracks, aligns them, and stores the result.
func (s *Server) fixSubtitleTiming(
ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string,
target playableSubtitle, tracks []playableSubtitle,
) (subtitleFixOutcome, error) {
brokenRaw, err := s.subtitleContent(ctx, cred, itemID, mediaSourceID, target)
if err != nil {
return subtitleFixOutcome{}, fmt.Errorf("read the subtitle being fixed: %w", err)
}
broken, err := subsync.Parse(brokenRaw)
if err != nil {
return subtitleFixOutcome{}, fmt.Errorf("parse the subtitle being fixed: %w", err)
}
reference, referenceTrack, err := s.referenceTrack(ctx, cred, itemID, mediaSourceID, target, tracks)
if err != nil {
return subtitleFixOutcome{}, err
}
opts := subsync.DefaultOptions()
result, err := subsync.Align(broken, reference, opts)
if err != nil {
return subtitleFixOutcome{}, err
}
outcome := subtitleFixOutcome{
Reference: subtitleTrackName(referenceTrack),
Correction: result.String(),
OffsetMs: result.Offset.Milliseconds(),
Score: result.Score,
Changed: result.Correction(),
}
if !outcome.Changed {
return outcome, nil
}
stored := store.DownloadedSubtitle{
ID: fixedSubtitleID(itemID, target),
ItemID: itemID,
Language: target.Language,
Label: fixedSubtitleLabel(target),
Forced: target.IsForced,
HearingImpaired: target.IsHearingImpaired,
Format: "srt",
Provider: store.SubtitleProviderMemby,
Content: subsync.FormatSRT(subsync.Shift(broken, result.Offset, result.Scale)),
}
if err := s.store.PutDownloadedSubtitle(ctx, stored); err != nil {
return subtitleFixOutcome{}, fmt.Errorf("store the fixed subtitle: %w", err)
}
outcome.StoredID = stored.ID
return outcome, nil
}
// referenceTrack picks and reads the yardstick.
//
// Reading is the expensive half — an embedded track is an Emby request each — so the
// candidates are read lazily in the order subsync.Reference would rank them, and the first
// one that parses wins. A track that will not parse is simply not a reference; failing the
// whole fix because the third-choice yardstick is malformed would be perverse.
func (s *Server) referenceTrack(
ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string,
target playableSubtitle, tracks []playableSubtitle,
) ([]subsync.Cue, playableSubtitle, error) {
candidates := referenceCandidates(target, tracks)
if len(candidates) == 0 {
return nil, playableSubtitle{}, &subsync.ErrNoAlignment{
Reason: "there is no other subtitle on this title to check the timing against",
}
}
for _, candidate := range candidates {
raw, err := s.subtitleContent(ctx, cred, itemID, mediaSourceID, candidate)
if err != nil {
s.loggerFor(ctx).Debug("reference subtitle unreadable",
"item_id", itemID, "subtitle", candidate.ID, "error", err)
continue
}
cues, err := subsync.Parse(raw)
if err != nil || len(cues) < subsync.DefaultOptions().MinCues {
continue
}
return cues, candidate, nil
}
return nil, playableSubtitle{}, &subsync.ErrNoAlignment{
Reason: "none of the other subtitles on this title could be read as a timing reference",
}
}
// referenceCandidates orders the tracks worth measuring against, best first.
//
// The rules are about what makes a usable yardstick rather than a good subtitle. A forced
// track carries only what is foreign to the film's own audio, so it is mostly silence and
// would agree with almost any shift — it is excluded rather than ranked last. A track the
// gateway already fixed is preferred, since it has been checked against something. After
// that, a track in the same language is the closest match in line breaks and therefore in
// timing, and an embedded track outranks a downloaded one because it shipped with this
// copy of the film.
func referenceCandidates(target playableSubtitle, tracks []playableSubtitle) []playableSubtitle {
out := make([]playableSubtitle, 0, len(tracks))
for _, track := range tracks {
if track.ID == target.ID || track.IsForced {
continue
}
if !isStoredSubtitleID(track.ID) && track.URL == "" {
// An embedded track Emby will not deliver as text: it is burned in or needs a
// transcode, and there is nothing to read.
continue
}
out = append(out, track)
}
rank := func(track playableSubtitle) int {
switch {
case strings.HasSuffix(track.ID, ":"+subtitleFixSuffix):
return 0
case target.Language != "" && strings.EqualFold(track.Language, target.Language):
return 1
case !isStoredSubtitleID(track.ID):
return 2
default:
return 3
}
}
// A stable sort, so tracks of equal rank keep the order Emby listed them in — which
// puts the default track first, and the default is usually the one that is right.
sort.SliceStable(out, func(i, j int) bool { return rank(out[i]) < rank(out[j]) })
return out
}
// subtitleContent reads a track from wherever it lives: the gateway's own table for one it
// fetched, Emby for one that came with the film.
func (s *Server) subtitleContent(
ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string,
track playableSubtitle,
) ([]byte, error) {
if isStoredSubtitleID(track.ID) {
stored, err := s.store.DownloadedSubtitle(ctx, track.ID)
if err != nil {
return nil, err
}
return stored.Content, nil
}
index, err := strconv.Atoi(track.ID)
if err != nil {
return nil, fmt.Errorf("subtitle id %q is not an Emby stream index", track.ID)
}
return s.emby.SubtitleBytes(ctx, cred, itemID, mediaSourceID, index)
}
func trackByID(tracks []playableSubtitle, id string) (playableSubtitle, bool) {
for _, track := range tracks {
if track.ID == id {
return track, true
}
}
return playableSubtitle{}, false
}
// fixedSubtitleID keeps the repaired copy in the gateway's own namespace and distinct from
// a downloaded file for the same language, so fixing a subtitle never overwrites the one it
// was made from — a correction can be wrong, and the original has to still be there.
func fixedSubtitleID(itemID string, target playableSubtitle) string {
language := target.Language
if language == "" {
language = "und"
}
return strings.Join([]string{
"gw", itemID, language, subtitleFixSuffix, sanitiseIDPart(target.ID),
}, ":")
}
// sanitiseIDPart keeps a source track's id usable inside another id. A stored track's own
// id already contains colons, and nesting them would make the parts ambiguous.
func sanitiseIDPart(id string) string {
return strings.ReplaceAll(strings.TrimPrefix(id, storedSubtitleIDPrefix), ":", "-")
}
func fixedSubtitleLabel(target playableSubtitle) string {
label := strings.TrimSpace(target.Label)
if label == "" {
label = subtitleLanguageLabel(target.Language)
}
// Named rather than silently substituted: this track sits in the menu beside the one
// it was made from, and the two are otherwise indistinguishable.
return label + " (timing fixed)"
}
func subtitleTrackName(track playableSubtitle) string {
if label := strings.TrimSpace(track.Label); label != "" {
return label
}
if language := subtitleLanguageLabel(track.Language); language != "" {
return language
}
return "another subtitle"
}
// subtitleFixFailureMessage is the whole diagnosis. A television has no log and no support
// channel, so the sentence has to say what happened and whether pressing again would help.
func subtitleFixFailureMessage(err error) string {
switch {
case errors.Is(err, subsync.ErrNoCues):
return "That subtitle could not be read, so its timing cannot be fixed."
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
return "Fixing the timing took too long. Try again."
default:
return "The timing could not be fixed just now."
}
}