Skip to content

Commit

Permalink
add tags command and try to make chapters visible
Browse files Browse the repository at this point in the history
  • Loading branch information
umputun committed Jan 2, 2024
1 parent c0a78ba commit b048bfd
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 51 deletions.
4 changes: 2 additions & 2 deletions publisher/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ prep:
@${subl} ${makefile_dir}/../hugo/`tail -n 1 ${TMPFILE} | tr -d '\r'`;

# set necessary tags to episode mp3 file and upload it to master and all nodes
upload-mp3:
@docker-compose run --rm -it publisher upload --episode="$$EPISODE" --dbg
proc:
@docker-compose run --rm -it publisher proc --episode="$$EPISODE" --dbg

# deploy new podcast episode page to https://radio-t.com and regenerate site
deploy:
Expand Down
100 changes: 71 additions & 29 deletions publisher/app/cmd/upload.go → publisher/app/cmd/proc.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ import (
//go:embed artifacts/*
var artifactsFS embed.FS

// Upload handles podcast upload to all destinations. It sets mp3 tags first and then deploys to master and nodes via spot tool.
type Upload struct {
// Proc handles podcast upload to all destinations. It sets mp3 tags first and then deploys to master and nodes via spot tool.
type Proc struct {
Executor
LocationMp3 string
LocationPosts string
Expand All @@ -37,34 +37,34 @@ type Upload struct {
// 3. Deploy to nodes.
//
// deploy performed by spot tool, see spot.yml
func (u *Upload) Do(episodeNum int) error {
log.Printf("[INFO] upload episode %d, mp3 location:%q, posts location:%q", episodeNum, u.LocationMp3, u.LocationPosts)
mp3file := filepath.Join(u.LocationMp3, fmt.Sprintf("rt_podcast%d", episodeNum), fmt.Sprintf("rt_podcast%d.mp3", episodeNum))
func (p *Proc) Do(episodeNum int) error {
log.Printf("[INFO] upload episode %d, mp3 location:%q, posts location:%q", episodeNum, p.LocationMp3, p.LocationPosts)
mp3file := filepath.Join(p.LocationMp3, fmt.Sprintf("rt_podcast%d", episodeNum), fmt.Sprintf("rt_podcast%d.mp3", episodeNum))
log.Printf("[DEBUG] mp3 file %s", mp3file)
hugoPost := fmt.Sprintf("%s/podcast-%d.md", u.LocationPosts, episodeNum)
hugoPost := fmt.Sprintf("%s/podcast-%d.md", p.LocationPosts, episodeNum)
log.Printf("[DEBUG] hugo post file %s", hugoPost)
posstContent, err := os.ReadFile(hugoPost)
if err != nil {
return fmt.Errorf("can't read post file %s, %w", hugoPost, err)
}
chapters, err := u.parseChapters(string(posstContent))
chapters, err := p.parseChapters(string(posstContent))
if err != nil {
return fmt.Errorf("can't parse chapters from post %s, %w", hugoPost, err)
}
log.Printf("[DEBUG] chapters %v", chapters)

err = u.setMp3Tags(episodeNum, chapters)
err = p.setMp3Tags(episodeNum, chapters)
if err != nil {
log.Printf("[WARN] can't set mp3 tags for %s, %v", mp3file, err)
}

if u.SkipTransfer {
if p.SkipTransfer {
log.Printf("[WARN] skip transfer of %s", mp3file)
return nil
}

u.Run("spot", "-e mp3:"+mp3file, `--task="deploy to master"`, "-v")
u.Run("spot", "-e mp3:"+mp3file, `--task="deploy to nodes"`, "-v")
p.Run("spot", "-e mp3:"+mp3file, `--task="deploy to master"`, "-v")
p.Run("spot", "-e mp3:"+mp3file, `--task="deploy to nodes"`, "-v")
return nil
}

Expand All @@ -77,10 +77,10 @@ type chapter struct {

// setMp3Tags sets mp3 tags for a given episode. It uses artifactsFS to read cover.jpg
// and uses the chapter information to set the chapter tags.
func (u *Upload) setMp3Tags(episodeNum int, chapters []chapter) error {
mp3file := fmt.Sprintf("%s/rt_podcast%d/rt_podcast%d.mp3", u.LocationMp3, episodeNum, episodeNum)
func (p *Proc) setMp3Tags(episodeNum int, chapters []chapter) error {
mp3file := fmt.Sprintf("%s/rt_podcast%d/rt_podcast%d.mp3", p.LocationMp3, episodeNum, episodeNum)
log.Printf("[INFO] set mp3 tags for %s", mp3file)
if u.Dry {
if p.Dry {
return nil
}

Expand All @@ -90,8 +90,9 @@ func (u *Upload) setMp3Tags(episodeNum int, chapters []chapter) error {
}
defer tag.Close()

tag.DeleteAllFrames() // clear all existing tags
tag.DeleteAllFrames()

tag.SetVersion(4)
tag.SetDefaultEncoding(id3v2.EncodingUTF8)

title := fmt.Sprintf("Радио-Т %d", episodeNum)
Expand All @@ -116,15 +117,20 @@ func (u *Upload) setMp3Tags(episodeNum int, chapters []chapter) error {
tag.AddAttachedPicture(pic)

// we need to get mp3 duration to set the correct end time for the last chapter
duration, err := u.getMP3Duration(mp3file)
duration, err := p.getMP3Duration(mp3file)
if err != nil {
return fmt.Errorf("can't get mp3 duration, %w", err)
}

// create a CTOC frame manually
ctocFrame := u.createCTOCFrame(chapters)
ctocFrame := p.createCTOCFrame(chapters)
tag.AddFrame(tag.CommonID("CTOC"), ctocFrame)

// add other tags
tag.AddFrame("TLEN", id3v2.TextFrame{Encoding: id3v2.EncodingISO, Text: strconv.FormatInt(duration.Milliseconds(), 10)})
tag.AddFrame("TYER", id3v2.TextFrame{Encoding: id3v2.EncodingISO, Text: fmt.Sprintf("%d", time.Now().Year())})
tag.AddFrame("TENC", id3v2.TextFrame{Encoding: id3v2.EncodingISO, Text: "Forecast"})

// add chapters
for i, chapter := range chapters {
var endTime time.Duration
Expand All @@ -138,28 +144,32 @@ func (u *Upload) setMp3Tags(episodeNum int, chapters []chapter) error {
return fmt.Errorf("chapter title contains invalid UTF-8 characters")
}
chapFrame := id3v2.ChapterFrame{
ElementID: strconv.Itoa(i + 1),
ElementID: fmt.Sprintf("chp%d", i),
StartTime: chapter.Begin,
EndTime: endTime,
StartOffset: id3v2.IgnoredOffset,
EndOffset: id3v2.IgnoredOffset,
Title: &id3v2.TextFrame{
Encoding: id3v2.EncodingUTF8,
Encoding: id3v2.EncodingUTF16,
Text: chapterTitle,
},
Description: &id3v2.TextFrame{
Encoding: id3v2.EncodingUTF8,
Encoding: id3v2.EncodingUTF16,
Text: chapterTitle,
},
}
tag.AddChapterFrame(chapFrame)
}

return tag.Save()
if err := tag.Save(); err != nil {
return err
}
p.ShowAllTags(mp3file)
return nil
}

// parseChapters parses md post content and returns a list of chapters
func (u *Upload) parseChapters(content string) ([]chapter, error) {
func (p *Proc) parseChapters(content string) ([]chapter, error) {
parseDuration := func(timestamp string) (time.Duration, error) {
parts := strings.Split(timestamp, ":")
if len(parts) != 3 {
Expand Down Expand Up @@ -213,7 +223,7 @@ func (u *Upload) parseChapters(content string) ([]chapter, error) {
return chapters, nil
}

func (u *Upload) getMP3Duration(filePath string) (time.Duration, error) {
func (p *Proc) getMP3Duration(filePath string) (time.Duration, error) {
file, err := os.Open(filePath)
if err != nil {
return 0, err
Expand All @@ -234,18 +244,50 @@ func (u *Upload) getMP3Duration(filePath string) (time.Duration, error) {
return time.Second * time.Duration(duration), nil
}

func (u *Upload) createCTOCFrame(chapters []chapter) *id3v2.UnknownFrame {
// createCTOCFrame creates a CTOC frame for the given list of chapters in the provided mp3 file.
// Note: all this insanity is an attempt to make CTOC frames because id3v2 doesn't support it directly.
// It also is trying to replicate the behavior of the Python script that was used to create CTOC frames before this.
// Another reference for this code was the output of Forecast app, which also creates proper chapter frames and CTOC frames.
func (p *Proc) createCTOCFrame(chapters []chapter) *id3v2.UnknownFrame {
var frameBody bytes.Buffer
frameBody.WriteByte(0x03) // write flags (e.g., 0x03 for top-level and ordered chapters)
frameBody.WriteByte(byte(len(chapters))) // write the number of child elements

// append child element IDs (chapter IDs)
for i, _ := range chapters {
elementID := fmt.Sprintf("%d", i+1)
// TOC ID (encoded in ASCII, equivalent to "toc".encode("ascii") in Python)
tocID := "toc"
frameBody.WriteString(tocID)
frameBody.WriteByte(0x00) // Null terminator for TOC ID

// flags (0x03 for top-level and ordered chapters)
frameBody.WriteByte(0x03)

// number of child elements
frameBody.WriteByte(byte(len(chapters)))

// append child element IDs (chapter IDs) formatted as "chapter#i"
for i := range chapters {
elementID := fmt.Sprintf("chp%d", i)
frameBody.WriteString(elementID)
frameBody.WriteByte(0x00) // Null separator for IDs
}

// create and return an UnknownFrame with the constructed body
return &id3v2.UnknownFrame{Body: frameBody.Bytes()}
}

// ShowAllTags shows all tags for a given mp3 file
func (p *Proc) ShowAllTags(fname string) {
log.Printf("[DEBUG] show all tags for %s", fname)
tag, err := id3v2.Open(fname, id3v2.Options{Parse: true})
if err != nil {
log.Printf("[WARN] can't open mp3 file %s, %v", fname, err)
return
}
defer tag.Close()
frames := tag.AllFrames()

for name, frame := range frames {
if name == "APIC" {
continue
}
log.Printf("[DEBUG] frame %s: %+v", name, frame)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
"github.com/radio-t/radio-t-site/publisher/app/cmd/mocks"
)

func TestUpload_Do(t *testing.T) {
func TestProc_Do(t *testing.T) {
tempDir := "/tmp/publisher_test"
defer os.RemoveAll(tempDir)

Expand All @@ -36,7 +36,7 @@ func TestUpload_Do(t *testing.T) {
RunFunc: func(cmd string, params ...string) {},
}

d := Upload{
d := Proc{
Executor: ex,
LocationMp3: tempDir,
LocationPosts: "testdata",
Expand All @@ -55,7 +55,7 @@ func TestUpload_Do(t *testing.T) {
ex.RunCalls()[1].Params)
}

func TestUpload_setMp3Tags(t *testing.T) {
func TestProc_setMp3Tags(t *testing.T) {
tempDir, err := os.MkdirTemp("", "tags")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
Expand All @@ -75,7 +75,7 @@ func TestUpload_setMp3Tags(t *testing.T) {
require.NoError(t, err)

t.Run("without chapters", func(t *testing.T) {
u := Upload{LocationMp3: tempDir}
u := Proc{LocationMp3: tempDir}
err = u.setMp3Tags(123, nil)
require.NoError(t, err)

Expand All @@ -89,7 +89,7 @@ func TestUpload_setMp3Tags(t *testing.T) {
})

t.Run("with chapters", func(t *testing.T) {
u := Upload{LocationMp3: tempDir}
u := Proc{LocationMp3: tempDir}
err = u.setMp3Tags(123, []chapter{
{"Chapter One", "http://example.com/one", time.Second},
{"Chapter Two", "http://example.com/two", time.Second * 5},
Expand All @@ -115,7 +115,7 @@ func TestUpload_setMp3Tags(t *testing.T) {

}

func TestUpload_parseChapters(t *testing.T) {
func TestProc_parseChapters(t *testing.T) {
tests := []struct {
name string
content string
Expand Down Expand Up @@ -151,7 +151,7 @@ func TestUpload_parseChapters(t *testing.T) {
},
}

u := &Upload{}
u := &Proc{}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
Expand All @@ -166,7 +166,7 @@ func TestUpload_parseChapters(t *testing.T) {
}
}

func TestUpload_parseChaptersWithRealData(t *testing.T) {
func TestProc_parseChaptersWithRealData(t *testing.T) {
realDataContent := `
+++
title = "Радио-Т 686"
Expand Down Expand Up @@ -207,7 +207,7 @@ filename = "rt_podcast686"
{"Темы слушателей", "https://radio-t.com/p/2020/01/21/prep-686/", 111*time.Minute + 56*time.Second},
}

u := &Upload{}
u := &Proc{}

result, err := u.parseChapters(realDataContent)
assert.NoError(t, err)
Expand Down
35 changes: 24 additions & 11 deletions publisher/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,15 @@ var opts struct {
Dest string `long:"dest" env:"DEST" default:"./content/posts" description:"path to posts"`
} `command:"prep" description:"make new prep podcast post"`

UploadCmd struct {
ProcessCmd struct {
Location string `long:"location" env:"LOCATION" default:"/episodes" description:"podcast location"`
HugoPosts string `long:"hugo-posts" env:"HUGO_POSTS" default:"/srv/hugo/content/posts" description:"hugo posts location"`
SkipTransfer bool `long:"skip-transfer" env:"SKIP_TRANSFER" description:"skip transfer to remote locations"`
} `command:"upload" description:"upload podcast"`
} `command:"proc" description:"proces podcast - tag mp3 and upload"`

ShowTags struct {
FileName string `long:"file" env:"FILE" description:"mp3 file name" required:"true"`
} `command:"tags" description:"show mp3 tags"`

DeployCmd struct {
NewsAPI string `long:"news" env:"NEWS" default:"https://news.radio-t.com/api/v1/news" description:"news API url"`
Expand Down Expand Up @@ -70,13 +74,17 @@ func main() {
runPrep(episodeNum)
}

if p.Active != nil && p.Command.Find("upload") == p.Active {
runUpload(episodeNum)
if p.Active != nil && p.Command.Find("proc") == p.Active {
runProc(episodeNum)
}

if p.Active != nil && p.Command.Find("deploy") == p.Active {
runDeploy(episodeNum)
}

if p.Active != nil && p.Command.Find("tags") == p.Active {
runTags()
}
}

// episode gets the next episode number by hitting site-api
Expand Down Expand Up @@ -123,20 +131,25 @@ func runPrep(episodeNum int) {
fmt.Printf("%s/prep-%d.md", opts.PrepShowCmd.Dest, episodeNum) // don't delete! used by external callers
}

func runUpload(episodeNum int) {
upload := cmd.Upload{
func runProc(episodeNum int) {
proc := cmd.Proc{
Executor: &cmd.ShellExecutor{Dry: opts.Dry},
LocationMp3: opts.UploadCmd.Location,
LocationPosts: opts.UploadCmd.HugoPosts,
SkipTransfer: opts.UploadCmd.SkipTransfer,
LocationMp3: opts.ProcessCmd.Location,
LocationPosts: opts.ProcessCmd.HugoPosts,
SkipTransfer: opts.ProcessCmd.SkipTransfer,
Dry: opts.Dry,
}
if err := upload.Do(episodeNum); err != nil {
log.Fatalf("[ERROR] failed to upload #%d, %v", episodeNum, err)
if err := proc.Do(episodeNum); err != nil {
log.Fatalf("[ERROR] failed to proc #%d, %v", episodeNum, err)
}
log.Printf("[INFO] deployed #%d", episodeNum)
}

func runTags() {
proc := cmd.Proc{}
proc.ShowAllTags(opts.ShowTags.FileName)
}

func runDeploy(episodeNum int) {
deploy := cmd.Deploy{
Client: http.Client{Timeout: 10 * time.Second},
Expand Down

0 comments on commit b048bfd

Please sign in to comment.