Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions activities/vidispine/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,26 @@ func (a Activities) AddToVXMetadataFieldActivity(ctx context.Context, params vsa
return nil, err
}

type DeleteMetadataGroupParams struct {
VXID string
Group string
}

type DeleteMetadataGroupResult struct {
DeletedInstances int
}

func (a Activities) DeleteMetadataGroupInstancesActivity(ctx context.Context, params DeleteMetadataGroupParams) (*DeleteMetadataGroupResult, error) {
log := activity.GetLogger(ctx)
log.Info("Starting DeleteMetadataGroupInstancesActivity", "vxid", params.VXID, "group", params.Group)

count, err := a.Client.DeleteMetadataGroupInstances(params.VXID, params.Group)
if err != nil {
return nil, err
}
return &DeleteMetadataGroupResult{DeletedInstances: count}, nil
}

type GetResolutionsParams struct {
VXID string
}
Expand Down
3 changes: 3 additions & 0 deletions potential_improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Condensed 2026-08-21. Items confirmed fixed were removed. Bugs section validated

## Bugs

- `workflows/misc/merge_import_subs.go:145` — `langs = append(langs, lang)` inside `for _, lang := range langs`; copy-paste from import_subs.go where `langs` is a separate accumulator. Here it just grows the slice being ranged over with duplicates. Remove the append.
- `workflows/misc/merge_import_subs.go:147` — `_ = wfutils.Execute(...WaitForJobCompletion...).Wait(ctx)` discards the job result, so a failed shape-import job doesn't fail the workflow (same pattern was fixed in import_subs.go).


## Security

Expand Down
2 changes: 2 additions & 0 deletions services/vidispine/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Client interface {
CreateThumbnails(assetID string, width, height int) (string, error)

DeleteItems(ctx context.Context, itemVXIDs []string, deleteFiles bool) error
DeleteMetadataGroupInstances(itemID, groupName string) (int, error)
DeleteShape(assetID, shapeID string) error

FindJob(itemID string, jobType string) (*vsapi.JobDocument, error)
Expand All @@ -30,6 +31,7 @@ type Client interface {
GetJob(jobID string) (*vsapi.JobDocument, error)
GetMetadata(vsID string) (*vsapi.MetadataResult, error)
GetMetadataFields(vsID string, fields []string) (*vsapi.MetadataResult, error)
GetMetadataGroupInstances(itemID, groupName string) ([]vsapi.MetadataGroupInstance, error)
GetRelations(assetID string) ([]vsapi.Relation, error)
GetResolutions(itemVXID string) ([]vsapi.Resolution, error)
GetSequence(itemVXID string) (*vsapi.SequenceDocument, error)
Expand Down
104 changes: 104 additions & 0 deletions services/vidispine/vsapi/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,110 @@ func (c *Client) GetMetadataAdvanced(params GetMetadataAdvancedParams) (*Metadat
return resp.Result().(*MetadataResult), nil
}

// MetadataGroupInstance identifies one occurrence of a named metadata group on an
// item: the group's uuid and the timespan it lives in.
type MetadataGroupInstance struct {
UUID string
Start string
End string
}

// The non-terse metadata endpoint answers with a MetadataListDocument
// ({"item":[{"metadata":{"timespan":[...]}}]}); a bare MetadataDocument carries
// the timespans at the top level. metadataDocumentJSON accepts both.
type metadataDocumentJSON struct {
Item []struct {
Metadata struct {
Timespan []metadataTimespanJSON `json:"timespan"`
} `json:"metadata"`
} `json:"item"`
Timespan []metadataTimespanJSON `json:"timespan"`
}

type metadataTimespanJSON struct {
Start string `json:"start"`
End string `json:"end"`
Group []metadataGroupJSON `json:"group"`
}

type metadataGroupJSON struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Group []metadataGroupJSON `json:"group"`
}

func collectGroupInstances(groups []metadataGroupJSON, name, start, end string, out []MetadataGroupInstance) []MetadataGroupInstance {
for _, g := range groups {
if g.Name == name && g.UUID != "" {
out = append(out, MetadataGroupInstance{UUID: g.UUID, Start: start, End: end})
}
out = collectGroupInstances(g.Group, name, start, end, out)
}
return out
}

// GetMetadataGroupInstances lists every occurrence of the named metadata group on
// the item, across all timespans (nested groups included).
func (c *Client) GetMetadataGroupInstances(itemID, groupName string) ([]MetadataGroupInstance, error) {
requestURL, _ := url.Parse(c.baseURL)
requestURL.Path += fmt.Sprintf("/item/%s/metadata", url.PathEscape(itemID))
q := requestURL.Query()
q.Set("group", groupName)
requestURL.RawQuery = q.Encode()

// An item with no instances of the group can come back as 404.
resp, err := tolerating404(c.restyClient.R()).
SetResult(&metadataDocumentJSON{}).
Get(requestURL.String())
if err != nil {
return nil, err
}

doc := resp.Result().(*metadataDocumentJSON)
timespans := doc.Timespan
for _, item := range doc.Item {
timespans = append(timespans, item.Metadata.Timespan...)
}

var out []MetadataGroupInstance
for _, ts := range timespans {
out = collectGroupInstances(ts.Group, groupName, ts.Start, ts.End, out)
}
return out, nil
}

// DeleteMetadataGroupInstances removes every occurrence of the named metadata group
// from the item and returns how many were removed. Removal is addressed by group
// uuid per timespan — addressing by name is what Vidispine rejects as "ambiguous
// path to group" when the name resolves to more than one path.
func (c *Client) DeleteMetadataGroupInstances(itemID, groupName string) (int, error) {
instances, err := c.GetMetadataGroupInstances(itemID, groupName)
if err != nil {
return 0, err
}
if len(instances) == 0 {
return 0, nil
}

body, err := createRemoveMetadataGroupsXml(instances)
if err != nil {
return 0, err
}

requestURL, _ := url.Parse(c.baseURL)
requestURL.Path += fmt.Sprintf("/item/%s/metadata", url.PathEscape(itemID))

_, err = c.restyClient.R().
SetHeader("content-type", "application/xml").
SetBody(body.String()).
Put(requestURL.String())
if err != nil {
return 0, err
}

return len(instances), nil
}

type ItemMetadataFieldParams struct {
ItemID string
GroupID string
Expand Down
44 changes: 44 additions & 0 deletions services/vidispine/vsapi/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,47 @@ func Test_GenerateMetUpdateWithTCXML(t *testing.T) {
</MetadataDocument>`
assert.Equal(t, expected, buf.String())
}

func Test_MetadataDocumentJSON_GroupInstances(t *testing.T) {
// MetadataListDocument envelope with nested groups.
listDoc := `{"item":[{"id":"VX-1","metadata":{"timespan":[
{"start":"-INF","end":"+INF","group":[
{"uuid":"uuid-1","name":"stl_subtitle"},
{"uuid":"uuid-2","name":"Subclips","group":[{"uuid":"uuid-3","name":"stl_subtitle"}]}
]},
{"start":"0@PAL","end":"250@PAL","group":[{"uuid":"uuid-4","name":"stl_subtitle"}]}
]}}]}`

doc := metadataDocumentJSON{}
assert.NoError(t, json.Unmarshal([]byte(listDoc), &doc))

timespans := doc.Timespan
for _, item := range doc.Item {
timespans = append(timespans, item.Metadata.Timespan...)
}

var out []MetadataGroupInstance
for _, ts := range timespans {
out = collectGroupInstances(ts.Group, "stl_subtitle", ts.Start, ts.End, out)
}

assert.Equal(t, []MetadataGroupInstance{
{UUID: "uuid-1", Start: "-INF", End: "+INF"},
{UUID: "uuid-3", Start: "-INF", End: "+INF"},
{UUID: "uuid-4", Start: "0@PAL", End: "250@PAL"},
}, out)
}

func Test_MetadataDocumentJSON_BareDocument(t *testing.T) {
bareDoc := `{"timespan":[{"start":"-INF","end":"+INF","group":[{"uuid":"uuid-9","name":"stl_subtitle"}]}]}`

doc := metadataDocumentJSON{}
assert.NoError(t, json.Unmarshal([]byte(bareDoc), &doc))

var out []MetadataGroupInstance
for _, ts := range doc.Timespan {
out = collectGroupInstances(ts.Group, "stl_subtitle", ts.Start, ts.End, out)
}

assert.Equal(t, []MetadataGroupInstance{{UUID: "uuid-9", Start: "-INF", End: "+INF"}}, out)
}
16 changes: 16 additions & 0 deletions services/vidispine/vsapi/xml_templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ var (
xmlMasterPlaceholderTmpl = template.Must(template.New("master").Parse(xmlMasterPlaceholder))
xmlRawMaterialPlaceholderTmpl = template.Must(template.New("raw").Parse(xmlRawMaterialPlaceholder))
xmlSetMetadataPlaceholderTmpl = template.Must(template.New("metadata").Parse(xmlSetItemMetadataFieldPlaceholder))
xmlRemoveMetadataGroupsTmpl = template.Must(template.New("removeGroups").Parse(xmlRemoveMetadataGroupsPlaceholder))
)

const (
Expand Down Expand Up @@ -84,6 +85,15 @@ const (
</group>
{{end}}
</timespan>
</MetadataDocument>`

xmlRemoveMetadataGroupsPlaceholder = `<?xml version="1.0"?>
<MetadataDocument xmlns="http://xml.vidispine.com/schema/vidispine">
{{- range . }}
<timespan start="{{ .Start }}" end="{{ .End }}">
<group uuid="{{ .UUID }}" mode="remove"/>
</timespan>
{{- end }}
</MetadataDocument>`
)

Expand All @@ -96,6 +106,12 @@ type xmlSetItemMetadataFieldParams struct {
Add bool
}

func createRemoveMetadataGroupsXml(instances []MetadataGroupInstance) (*bytes.Buffer, error) {
buf := new(bytes.Buffer)
err := xmlRemoveMetadataGroupsTmpl.Execute(buf, instances)
return buf, err
}

func createSetItemMetadataFieldXml(params xmlSetItemMetadataFieldParams) (*bytes.Buffer, error) {
if params.StartTC == "" {
params.StartTC = MinusInf
Expand Down
36 changes: 36 additions & 0 deletions services/vidispine/vsapi/xml_templates_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package vsapi

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestCreateRemoveMetadataGroupsXml_Empty(t *testing.T) {
buf, err := createRemoveMetadataGroupsXml(nil)
assert.NoError(t, err)
assert.NotContains(t, buf.String(), "<timespan")
}

func TestCreateRemoveMetadataGroupsXml_Single(t *testing.T) {
buf, err := createRemoveMetadataGroupsXml([]MetadataGroupInstance{
{UUID: "uuid-1", Start: "-INF", End: "+INF"},
})
assert.NoError(t, err)
out := buf.String()
assert.Contains(t, out, `<timespan start="-INF" end="+INF">`)
assert.Contains(t, out, `<group uuid="uuid-1" mode="remove"/>`)
}

func TestCreateRemoveMetadataGroupsXml_Multiple(t *testing.T) {
buf, err := createRemoveMetadataGroupsXml([]MetadataGroupInstance{
{UUID: "uuid-1", Start: "0@PAL", End: "250@PAL"},
{UUID: "uuid-2", Start: "250@PAL", End: "500@PAL"},
})
assert.NoError(t, err)
out := buf.String()
assert.Contains(t, out, `<group uuid="uuid-1" mode="remove"/>`)
assert.Contains(t, out, `<group uuid="uuid-2" mode="remove"/>`)
assert.Contains(t, out, `<timespan start="0@PAL" end="250@PAL">`)
assert.Contains(t, out, `<timespan start="250@PAL" end="500@PAL">`)
}
4 changes: 4 additions & 0 deletions services/vidispine/vscommon/fields.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import "github.com/orsinium-labs/enum"

type FieldType enum.Member[string]

// GroupStlSubtitle is the metadata group Vidispine writes subtitle cues
// (FieldStlText) into during sidecar import.
const GroupStlSubtitle = "stl_subtitle"

var (
FieldDurationSeconds = FieldType{"durationSeconds"}
FieldDescription = FieldType{"portal_mf982016"}
Expand Down
30 changes: 30 additions & 0 deletions services/vidispine/vsmock/mock_Client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading