From e60f36b985c5286f7fdcd9013c6dbf470b1c91c8 Mon Sep 17 00:00:00 2001 From: Yonah Date: Wed, 18 Mar 2026 18:16:26 +0900 Subject: [PATCH] ident: decode twitch vod name There are a few unknown fields, represented as is for now. Signed-off-by: Yonah --- ident.go | 142 +++++++++++++++++++++++++++++++++++++++++++++ ident_test.go | 103 ++++++++++++++++++++++++++++++++ streamdata_test.go | 31 ++++++++++ 3 files changed, 276 insertions(+) create mode 100644 ident.go create mode 100644 ident_test.go create mode 100644 streamdata_test.go diff --git a/ident.go b/ident.go new file mode 100644 index 0000000..0277497 --- /dev/null +++ b/ident.go @@ -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 +} diff --git a/ident_test.go b/ident_test.go new file mode 100644 index 0000000..555c67f --- /dev/null +++ b/ident_test.go @@ -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) + } + }) + }) + } +} diff --git a/streamdata_test.go b/streamdata_test.go new file mode 100644 index 0000000..11a1ff5 --- /dev/null +++ b/streamdata_test.go @@ -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) + } + }) + } +}