streamdata: channel I/O helpers
These takes assets as a stream alongside their metadata. Signed-off-by: Yonah <contrib@gensokyo.uk>
This commit is contained in:
199
streamdata.go
Normal file
199
streamdata.go
Normal file
@@ -0,0 +1,199 @@
|
||||
// Package streamdata provides a simple API for downloading VODs and maintaining
|
||||
// basic metadata alongside them.
|
||||
package streamdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Channel represents a Twitch channel.
|
||||
type Channel struct {
|
||||
// Numerical identifier specific to the channel, returned by Twitch.
|
||||
Identifier uint64 `json:"id"`
|
||||
// Unique, mutable channel name.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Directory to place persistent data in.
|
||||
root *os.Root
|
||||
}
|
||||
|
||||
// Is returns whether target is equivalent to c.
|
||||
func (c *Channel) Is(target *Channel) bool {
|
||||
return (c == nil && target == nil) || (c != nil && target != nil &&
|
||||
c.Identifier == target.Identifier &&
|
||||
c.Name == target.Name)
|
||||
}
|
||||
|
||||
// Close closes the underlying on-disk representation.
|
||||
func (c *Channel) Close() error {
|
||||
if c == nil || c.root == nil {
|
||||
return syscall.EINVAL
|
||||
}
|
||||
return c.root.Close()
|
||||
}
|
||||
|
||||
const (
|
||||
// channelPathMetadata points to the metadata file relative to Channel.root.
|
||||
channelPathMetadata = "channel"
|
||||
// channelPathVOD points to the vod directory relative to Channel.root.
|
||||
channelPathVOD = "vod"
|
||||
// ChannelVODSuffix is the (apparently) hardcoded asset file name suffix.
|
||||
ChannelVODSuffix = ".mp4"
|
||||
// channelPathPending points to the transaction backing file relative to Channel.root.
|
||||
channelPathPending = "pending"
|
||||
)
|
||||
|
||||
// Create initialises the on-disk representation of a [Channel].
|
||||
func (c *Channel) Create(pathname string) error {
|
||||
if c == nil || c.root != nil || c.Identifier == 0 || c.Name == "" {
|
||||
return syscall.EINVAL
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(pathname, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if root, err := os.OpenRoot(pathname); err != nil {
|
||||
return err
|
||||
} else {
|
||||
c.root = root
|
||||
}
|
||||
|
||||
if w, err := c.root.OpenFile(
|
||||
channelPathMetadata,
|
||||
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||
0444,
|
||||
); err != nil {
|
||||
_ = c.root.Close()
|
||||
return err
|
||||
} else if err = json.NewEncoder(w).Encode(c); err != nil {
|
||||
_ = c.root.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.root.Mkdir(channelPathVOD, 0755); err != nil {
|
||||
_ = c.root.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrDanglingTransaction is returned when attempting to [Open] the on-disk
|
||||
// representation of a [Channel] with a dangling transaction backing file.
|
||||
var ErrDanglingTransaction = errors.New("dangling transaction backing file")
|
||||
|
||||
// Open opens the on-disk representation of [Channel] and returns its address.
|
||||
func Open(pathname string) (*Channel, error) {
|
||||
var c Channel
|
||||
if root, err := os.OpenRoot(pathname); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
c.root = root
|
||||
}
|
||||
|
||||
if _, err := c.root.Lstat(channelPathPending); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
_ = c.root.Close()
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
_ = c.root.Close()
|
||||
return nil, ErrDanglingTransaction
|
||||
}
|
||||
|
||||
if f, err := c.root.Open(channelPathMetadata); err != nil {
|
||||
_ = c.root.Close()
|
||||
return nil, err
|
||||
} else if err = json.NewDecoder(f).Decode(&c); err != nil {
|
||||
_ = c.root.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// VOD holds additional metadata associated with a vod.
|
||||
type VOD struct {
|
||||
// Stream title.
|
||||
Title string `json:"title"`
|
||||
// Day of stream start.
|
||||
Date time.Time `json:"date"`
|
||||
// Free-form category string.
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
// ChannelMismatchError describes a mismatching [Ident.Channel] passed to [Channel.Add].
|
||||
type ChannelMismatchError struct {
|
||||
Got, Want uint64
|
||||
}
|
||||
|
||||
func (c *ChannelMismatchError) Error() string {
|
||||
return "attempting to add VOD from channel " +
|
||||
strconv.FormatUint(c.Got, 10) + " to channel " +
|
||||
strconv.FormatUint(c.Want, 10)
|
||||
}
|
||||
|
||||
// Add adds a [VOD] and its corresponding asset to the on-disk representation.
|
||||
func (c *Channel) Add(ident *Ident, f func(v *VOD, w io.Writer) error) error {
|
||||
if ident == nil || f == nil {
|
||||
return syscall.EINVAL
|
||||
}
|
||||
if ident.Channel != c.Identifier {
|
||||
return &ChannelMismatchError{ident.Channel, c.Identifier}
|
||||
}
|
||||
|
||||
w, err := c.root.OpenFile(
|
||||
channelPathPending,
|
||||
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var v VOD
|
||||
err = f(&v, w)
|
||||
if closeErr := w.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
_ = c.root.Remove(channelPathPending)
|
||||
return err
|
||||
}
|
||||
|
||||
if err = c.root.Chmod(channelPathPending, 0444); err != nil {
|
||||
return err
|
||||
}
|
||||
pathname := path.Join(channelPathVOD, ident.String())
|
||||
|
||||
if w, err = c.root.OpenFile(
|
||||
pathname,
|
||||
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||
0444,
|
||||
); err != nil {
|
||||
return err
|
||||
} else if err = json.NewEncoder(w).Encode(&v); err != nil {
|
||||
_ = w.Close()
|
||||
_ = c.root.Remove(pathname)
|
||||
return err
|
||||
} else if err = w.Close(); err != nil {
|
||||
_ = c.root.Remove(pathname)
|
||||
return err
|
||||
}
|
||||
|
||||
if err = c.root.Rename(
|
||||
channelPathPending,
|
||||
pathname+ChannelVODSuffix,
|
||||
); err != nil {
|
||||
_ = c.root.Remove(pathname)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user