Skip to content

Commit 41dbca0

Browse files
KillerXclaude
andcommitted
Make TranscodePreviewVX tolerant of missing per-language shape-tags
When previewing MU2/CLN files, the workflow generates an audio preview track for every language defined in LanguagesByMU2/LanguageBySoftron and tries to register each as a Vidispine shape using the language's MBPreviewTag. For languages whose tag is not configured in Vidispine (e.g. mul_yue_low), the import would fail and tank the entire preview workflow. - Iterate the audio preview map deterministically with GetMapKeysSafely. - Skip languages with an empty MBPreviewTag. - Treat per-language import failures as non-fatal: log + telegram warning, then continue. Don't leak the loop variable through return err. - Parse Vidispine's structured error envelope in AddShapeToItem and surface shape-tag-not-found via ErrShapeTagNotFound. ImportFileAsShapeActivity wraps that as a non-retryable Temporal error so missing tags fail fast instead of burning through the default retry policy. - Add unit tests for parseVSError covering the shape-tag, generic, and non-JSON cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 11ec257 commit 41dbca0

4 files changed

Lines changed: 111 additions & 8 deletions

File tree

activities/vidispine/files.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package vsactivity
22

33
import (
44
"context"
5+
"errors"
56

67
"github.com/bcc-code/bcc-media-flows/paths"
78
"github.com/bcc-code/bcc-media-flows/services/vidispine/vsapi"
89
"go.temporal.io/sdk/activity"
10+
"go.temporal.io/sdk/temporal"
911
)
1012

1113
type ImportFileAsShapeParams struct {
@@ -53,6 +55,9 @@ func (a Activities) ImportFileAsShapeActivity(ctx context.Context, params Import
5355
}
5456

5557
res, err := vsClient.AddShapeToItem(params.ShapeTag, params.AssetID, fileID)
58+
if err != nil && errors.Is(err, vsapi.ErrShapeTagNotFound) {
59+
err = temporal.NewNonRetryableApplicationError(err.Error(), "VS_SHAPE_TAG_NOT_FOUND", err)
60+
}
5661
return &ImportFileResult{
5762
JobID: res,
5863
FileID: fileID,

services/vidispine/vsapi/shapes.go

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package vsapi
22

33
import (
4+
"encoding/json"
5+
"errors"
46
"fmt"
57
"net/url"
68

@@ -11,6 +13,31 @@ import (
1113
"github.com/samber/lo"
1214
)
1315

16+
// ErrShapeTagNotFound is returned by AddShapeToItem when Vidispine reports
17+
// that the requested shape-tag is not configured. Wrap-aware: callers can
18+
// detect this with errors.Is and treat it as a configuration error rather
19+
// than a transient failure.
20+
var ErrShapeTagNotFound = errors.New("shape-tag not found in Vidispine")
21+
22+
// vsErrorBody mirrors the structured error envelope Vidispine returns on
23+
// non-2xx responses. Only the fields we need are populated; the rest are
24+
// kept as raw messages so unknown failure modes still surface in the error
25+
// string.
26+
type vsErrorBody struct {
27+
NotFound *struct {
28+
Type string `json:"type"`
29+
ID string `json:"id"`
30+
} `json:"notFound"`
31+
InternalServer json.RawMessage `json:"internalServer"`
32+
Forbidden json.RawMessage `json:"forbidden"`
33+
NotYetImpl json.RawMessage `json:"notYetImplemented"`
34+
Conflict json.RawMessage `json:"conflict"`
35+
InvalidInput json.RawMessage `json:"invalidInput"`
36+
LicenseFault json.RawMessage `json:"licenseFault"`
37+
FileExists json.RawMessage `json:"fileAlreadyExists"`
38+
NotAuthorized json.RawMessage `json:"notAuthorized"`
39+
}
40+
1441
func (c *Client) GetShapes(vsID string) (*ShapeResult, error) {
1542
url := c.baseURL + "/item/" + vsID + "?content=shape&terse=true"
1643

@@ -45,14 +72,34 @@ func (c *Client) AddShapeToItem(tag, itemID, fileID string) (string, error) {
4572
return "", err
4673
}
4774

75+
if result.IsError() {
76+
return "", parseVSError(result.Body(), result.StatusCode(), tag, itemID)
77+
}
78+
4879
jobID := result.Result().(*JobDocument).JobID
4980
if jobID == "" {
50-
return "", fmt.Errorf("No job id Returned. Body: %s", string(result.Body()))
81+
return "", parseVSError(result.Body(), result.StatusCode(), tag, itemID)
5182
}
5283

5384
spew.Dump(result.Result())
5485

55-
return result.Result().(*JobDocument).JobID, nil
86+
return jobID, nil
87+
}
88+
89+
// parseVSError turns a Vidispine error envelope into a descriptive error.
90+
// Shape-tag-not-found is returned wrapped around ErrShapeTagNotFound so
91+
// callers can detect it with errors.Is and avoid wasteful retries.
92+
func parseVSError(body []byte, statusCode int, tag, itemID string) error {
93+
var e vsErrorBody
94+
if jsonErr := json.Unmarshal(body, &e); jsonErr != nil {
95+
return fmt.Errorf("vidispine request failed (status %d): %s", statusCode, string(body))
96+
}
97+
98+
if e.NotFound != nil && e.NotFound.Type == "shape-tag" {
99+
return fmt.Errorf("shape-tag %q not configured in Vidispine (item %s): %w", e.NotFound.ID, itemID, ErrShapeTagNotFound)
100+
}
101+
102+
return fmt.Errorf("vidispine request failed (status %d) for tag %q on item %s: %s", statusCode, tag, itemID, string(body))
56103
}
57104

58105
func (c *Client) DeleteShape(assetID, shapeID string) error {

services/vidispine/vsapi/shapes_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,47 @@
11
package vsapi
22

33
import (
4+
"errors"
5+
"net/http"
46
"testing"
57

68
"github.com/stretchr/testify/assert"
79
)
810

11+
func Test_parseVSError_ShapeTagNotFound(t *testing.T) {
12+
body := []byte(`{"notFound":{"type":"shape-tag","id":"mul_yue_low","context":null},"internalServer":null,"forbidden":null,"notYetImplemented":null,"conflict":null,"invalidInput":null,"licenseFault":null,"fileAlreadyExists":null,"notAuthorized":null}`)
13+
14+
err := parseVSError(body, http.StatusNotFound, "mul_yue_low", "VX-123")
15+
16+
assert.Error(t, err)
17+
assert.True(t, errors.Is(err, ErrShapeTagNotFound), "expected error to wrap ErrShapeTagNotFound")
18+
assert.Contains(t, err.Error(), "mul_yue_low")
19+
assert.Contains(t, err.Error(), "VX-123")
20+
}
21+
22+
func Test_parseVSError_OtherEnvelope(t *testing.T) {
23+
body := []byte(`{"notFound":null,"forbidden":{},"internalServer":null}`)
24+
25+
err := parseVSError(body, http.StatusForbidden, "lowres_watermarked", "VX-456")
26+
27+
assert.Error(t, err)
28+
assert.False(t, errors.Is(err, ErrShapeTagNotFound), "non-shape-tag errors must not wrap ErrShapeTagNotFound")
29+
assert.Contains(t, err.Error(), "403")
30+
assert.Contains(t, err.Error(), "lowres_watermarked")
31+
assert.Contains(t, err.Error(), "VX-456")
32+
}
33+
34+
func Test_parseVSError_NonJSONBody(t *testing.T) {
35+
body := []byte(`<html>internal server error</html>`)
36+
37+
err := parseVSError(body, http.StatusInternalServerError, "tag", "VX-789")
38+
39+
assert.Error(t, err)
40+
assert.False(t, errors.Is(err, ErrShapeTagNotFound))
41+
assert.Contains(t, err.Error(), "500")
42+
assert.Contains(t, err.Error(), "<html>internal server error</html>")
43+
}
44+
945
func Test_GetPath(t *testing.T) {
1046
sr := ShapeResult{
1147
Shape: []Shape{

workflows/misc/transcode_preview-vx.go

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ package miscworkflows
33
import (
44
"fmt"
55
bccmflows "github.com/bcc-code/bcc-media-flows"
6-
"github.com/bcc-code/mediabank-bridge/log"
76
"github.com/samber/lo"
87
"path/filepath"
98
"strings"
109
"time"
1110

1211
"github.com/bcc-code/bcc-media-flows/activities"
1312
vsactivity "github.com/bcc-code/bcc-media-flows/activities/vidispine"
13+
"github.com/bcc-code/bcc-media-flows/services/telegram"
1414
wfutils "github.com/bcc-code/bcc-media-flows/utils/workflows"
1515

1616
"go.temporal.io/sdk/workflow"
@@ -87,18 +87,33 @@ func TranscodePreviewVX(
8787
return err
8888
}
8989

90-
for l, p := range previewResponse.AudioPreviewFiles {
90+
audioLangs, err := wfutils.GetMapKeysSafely(ctx, previewResponse.AudioPreviewFiles)
91+
if err != nil {
92+
return err
93+
}
94+
95+
for _, l := range audioLangs {
96+
p := previewResponse.AudioPreviewFiles[l]
9197
tag := bccmflows.LanguagesByISO[l].MBPreviewTag
92-
err = wfutils.Execute(ctx, activities.Vidispine.ImportFileAsShapeActivity,
98+
if tag == "" {
99+
logger.Info("Skipping audio preview with empty MBPreviewTag", "language", l)
100+
continue
101+
}
102+
103+
iterErr := wfutils.Execute(ctx, activities.Vidispine.ImportFileAsShapeActivity,
93104
vsactivity.ImportFileAsShapeParams{
94105
AssetID: params.VXID,
95106
FilePath: p,
96107
ShapeTag: tag,
97108
}).Wait(ctx)
98-
if err != nil {
99-
log.L.Log().Err(err).Msg("Error importing audio preview")
109+
if iterErr != nil {
110+
logger.Error("Failed to import audio preview shape",
111+
"language", l, "tag", tag, "vxid", params.VXID, "error", iterErr.Error())
112+
wfutils.SendTelegramText(ctx, telegram.ChatOther,
113+
fmt.Sprintf("🟧 Failed to import audio preview for `%s` (tag `%s`) on `%s`: ```%s```",
114+
l, tag, params.VXID, iterErr.Error()))
100115
}
101116
}
102117

103-
return err
118+
return nil
104119
}

0 commit comments

Comments
 (0)