Release v0.2.34
This commit is contained in:
@@ -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."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user