358 lines
13 KiB
Go
358 lines
13 KiB
Go
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."
|
|
}
|
|
}
|