ident: decode twitch vod name
There are a few unknown fields, represented as is for now. Signed-off-by: Yonah <contrib@gensokyo.uk>
This commit is contained in:
142
ident.go
Normal file
142
ident.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package streamdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// identFields is the expected number of fields in the [Ident] wire format.
|
||||||
|
identFields = 7
|
||||||
|
// identSeparator is the byte separating [Ident] fields.
|
||||||
|
identSeparator = '-'
|
||||||
|
|
||||||
|
// identFF0Len is the expected size of the first free-form [Ident] field.
|
||||||
|
identFF0Len = 4
|
||||||
|
// identFF123Len is the expected size of the second, third, fourth free-form
|
||||||
|
// [Ident] fields.
|
||||||
|
identFF123Len = 2
|
||||||
|
// identFF4Len is the expected size of the fifth free-form [Ident] field.
|
||||||
|
identFF4Len = 6
|
||||||
|
|
||||||
|
// IdentFFLen is the total size of all free-form fields.
|
||||||
|
IdentFFLen = identFF0Len + identFF123Len*3 + identFF4Len
|
||||||
|
)
|
||||||
|
|
||||||
|
// identFF are segment lengths of [Ident.Data].
|
||||||
|
var identFF = []int{
|
||||||
|
identFF0Len,
|
||||||
|
identFF123Len,
|
||||||
|
identFF123Len,
|
||||||
|
identFF123Len,
|
||||||
|
identFF4Len,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ident represents the unique identifier of a VOD, returned by Twitch.
|
||||||
|
type Ident struct {
|
||||||
|
// An incrementing internal identifier, decimal encoding.
|
||||||
|
Serial uint64
|
||||||
|
// Internal identifier specific to the channel, decimal encoding.
|
||||||
|
Channel uint64
|
||||||
|
// Constant-size remaining data, unknown semantics, hexadecimal encoding.
|
||||||
|
Data [IdentFFLen]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode encodes a canonical representation of ident.
|
||||||
|
func (ident *Ident) Encode() []byte {
|
||||||
|
data := make(
|
||||||
|
[]byte, 0,
|
||||||
|
20 /* decimal numbers with one extra byte of headroom */ +
|
||||||
|
identFields-1+
|
||||||
|
IdentFFLen*2,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = strconv.AppendUint(data, ident.Serial, 10)
|
||||||
|
data = append(data, identSeparator)
|
||||||
|
data = strconv.AppendUint(data, ident.Channel, 10)
|
||||||
|
|
||||||
|
ff := ident.Data[:]
|
||||||
|
for _, el := range identFF {
|
||||||
|
data = append(data, identSeparator)
|
||||||
|
data = hex.AppendEncode(data, ff[:el])
|
||||||
|
ff = ff[el:]
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalText encodes a canonical representation of ident.
|
||||||
|
func (ident *Ident) MarshalText() (data []byte, _ error) {
|
||||||
|
return ident.Encode(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the result of MarshalText as a string.
|
||||||
|
func (ident *Ident) String() string {
|
||||||
|
data := ident.Encode()
|
||||||
|
return unsafe.String(unsafe.SliceData(data), len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// atoi is like [strconv.Atoi], but with the resulting error unwrapped for
|
||||||
|
// cleaner error messages. The caller must ensure data is never modified.
|
||||||
|
func atoi(data []byte) (v uint64, err error) {
|
||||||
|
v, err = strconv.ParseUint(
|
||||||
|
unsafe.String(unsafe.SliceData(data), len(data)),
|
||||||
|
10, 64,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
var numError *strconv.NumError
|
||||||
|
if errors.As(err, &numError) && numError != nil {
|
||||||
|
err = numError.Unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdentFieldError describes an [Ident] representation with not enough fields.
|
||||||
|
type IdentFieldError int
|
||||||
|
|
||||||
|
func (e IdentFieldError) Error() string {
|
||||||
|
return "got " + strconv.Itoa(int(e)) +
|
||||||
|
" field(s) instead of " + strconv.Itoa(identFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdentFFError describes an [Ident] representation with an irregular size
|
||||||
|
// free-form field.
|
||||||
|
type IdentFFError struct {
|
||||||
|
Got, Want int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *IdentFFError) Error() string {
|
||||||
|
return "got " + strconv.Itoa(e.Got) + " bytes for a " +
|
||||||
|
strconv.Itoa(e.Want) + "-byte long free-form field"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalText strictly decodes an unsuffixed VOD name returned by Twitch.
|
||||||
|
func (ident *Ident) UnmarshalText(data []byte) (err error) {
|
||||||
|
fields := bytes.SplitN(data, []byte{identSeparator}, identFields)
|
||||||
|
if l := IdentFieldError(len(fields)); l != identFields {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
if ident.Serial, err = atoi(fields[0]); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ident.Channel, err = atoi(fields[1]); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
buf := ident.Data[:]
|
||||||
|
for i, el := range identFF {
|
||||||
|
field := fields[2+i]
|
||||||
|
if n, err = hex.Decode(buf, field); err != nil {
|
||||||
|
return err
|
||||||
|
} else if n != el {
|
||||||
|
return &IdentFFError{n, el}
|
||||||
|
}
|
||||||
|
buf = buf[el:]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
103
ident_test.go
Normal file
103
ident_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package streamdata_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.gensokyo.uk/yonah/streamdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIdent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
data string
|
||||||
|
ident *streamdata.Ident
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{"sample0", "2717837930-430126387-fd9c9877-00e1-47e3-867b-916cd1e6b90c", &streamdata.Ident{
|
||||||
|
Serial: 2717837930,
|
||||||
|
Channel: 430126387,
|
||||||
|
Data: [streamdata.IdentFFLen]byte{
|
||||||
|
0xfd, 0x9c, 0x98, 0x77,
|
||||||
|
0x00, 0xe1,
|
||||||
|
0x47, 0xe3,
|
||||||
|
0x86, 0x7b,
|
||||||
|
0x91, 0x6c, 0xd1, 0xe6, 0xb9, 0x0c,
|
||||||
|
},
|
||||||
|
}, nil},
|
||||||
|
|
||||||
|
{"sample1", "2719457116-430126387-8f8dbbd4-26e7-4fbe-bc3b-fb71c93b5c39", &streamdata.Ident{
|
||||||
|
Serial: 2719457116,
|
||||||
|
Channel: 430126387,
|
||||||
|
Data: [streamdata.IdentFFLen]byte{
|
||||||
|
0x8f, 0x8d, 0xbb, 0xd4,
|
||||||
|
0x26, 0xe7,
|
||||||
|
0x4f, 0xbe,
|
||||||
|
0xbc, 0x3b,
|
||||||
|
0xfb, 0x71, 0xc9, 0x3b, 0x5c, 0x39,
|
||||||
|
},
|
||||||
|
}, nil},
|
||||||
|
|
||||||
|
{"sample2", "2721992950-430126387-08ad437f-3599-43d6-9934-83f31265e9d0", &streamdata.Ident{
|
||||||
|
Serial: 2721992950,
|
||||||
|
Channel: 430126387,
|
||||||
|
Data: [streamdata.IdentFFLen]byte{
|
||||||
|
0x08, 0xad, 0x43, 0x7f,
|
||||||
|
0x35, 0x99,
|
||||||
|
0x43, 0xd6,
|
||||||
|
0x99, 0x34,
|
||||||
|
0x83, 0xf3, 0x12, 0x65, 0xe9, 0xd0,
|
||||||
|
},
|
||||||
|
}, nil},
|
||||||
|
|
||||||
|
{"field count", "", nil, streamdata.IdentFieldError(1)},
|
||||||
|
{"invalid serial", "\x00------", nil, strconv.ErrSyntax},
|
||||||
|
{"invalid channel", "0-\x00-----", nil, strconv.ErrSyntax},
|
||||||
|
{"short ff0", "0-0-ff----", nil, &streamdata.IdentFFError{Got: 1, Want: 4}},
|
||||||
|
{"long ff1", "0-0-deadbeef-fefefe---", nil, &streamdata.IdentFFError{Got: 3, Want: 2}},
|
||||||
|
{"short ff2", "0-0-deadbeef-fefe-fd--", nil, &streamdata.IdentFFError{Got: 1, Want: 2}},
|
||||||
|
{"long ff3", "0-0-deadbeef-fefe-fdfd-fcfcfcfc-", nil, &streamdata.IdentFFError{Got: 4, Want: 2}},
|
||||||
|
{"short ff4", "0-0-deadbeef-fefe-fdfd-fcfc-cafe", nil, &streamdata.IdentFFError{Got: 2, Want: 6}},
|
||||||
|
{"invalid ff", "0-0-deadbeef-fefe-fdfd-fcfc-meow", nil, hex.InvalidByteError('m')},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("decode", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var got streamdata.Ident
|
||||||
|
if err := got.UnmarshalText([]byte(tc.data)); !reflect.DeepEqual(err, tc.err) {
|
||||||
|
t.Errorf("UnmarshalText: error = %v, want %v", err, tc.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.err == nil && !reflect.DeepEqual(&got, tc.ident) {
|
||||||
|
t.Errorf("UnmarshalText: %#v, want %#v", got, *tc.ident)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if tc.err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("encode", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := tc.ident.String(); got != tc.data {
|
||||||
|
t.Fatalf("String: %s, want %s", got, tc.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, err := tc.ident.MarshalText(); err != nil {
|
||||||
|
t.Fatalf("MarshalText: error = %v", err)
|
||||||
|
} else if string(got) != tc.data {
|
||||||
|
t.Errorf("MarshalText: %s, want %s", string(got), tc.data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
31
streamdata_test.go
Normal file
31
streamdata_test.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package streamdata_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.gensokyo.uk/yonah/streamdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"IdentFieldError", streamdata.IdentFieldError(0xcafe),
|
||||||
|
"got 51966 field(s) instead of 7"},
|
||||||
|
{"IdentFFError", &streamdata.IdentFFError{Got: 0xcafe, Want: 0xbabe},
|
||||||
|
"got 51966 bytes for a 47806-byte long free-form field"},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := tc.err.Error(); got != tc.want {
|
||||||
|
t.Errorf("Error: %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user