Skip to content

Commit 4c1f1ce

Browse files
authored
Merge pull request #887 from kindlyops/plt-phase-2
Add plt build command to download and cut playlist media
2 parents 1441cc8 + 41bdf2f commit 4c1f1ce

16 files changed

Lines changed: 2276 additions & 19 deletions

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,26 @@ The format is detected by content, not file extension, so a renamed export is
5353
still recognized. Invalid files are rejected with a message naming what was
5454
wrong (not a zip, missing manifest, non-SQLite database, or missing tables).
5555

56+
### Build a working directory
57+
58+
Download every referenced video at the chosen resolution, pre-cut segment clips,
59+
and write a self-contained per-playlist working directory:
60+
61+
```bash
62+
vbs plt build meeting.playlist
63+
```
64+
65+
This produces `<playlist-slug>/` containing ordered play-ready `clips/`, the
66+
untouched source downloads in `media/`, extracted `thumbs/`, a `playlist.json`
67+
cue sheet, and a Typst `cuesheet.typ` (compiled to `cuesheet.pdf` when `typst`
68+
is installed). Downloads are cached and verified by checksum, so re-running is
69+
fast and deterministic. `ffmpeg` and `ffprobe` are required.
70+
71+
The media API endpoint is read from the config key `plt.mediaapi` (never
72+
committed); override it per run with `--media-api`. Other flags: `--out` (where
73+
to create the working directory), `--resolution` (default `720p`), and `--lang`
74+
(override the written-language code).
75+
5676
## installation for homebrew (MacOS/Linux)
5777

5878
brew install kindlyops/tap/vbs

cmd/BUILD.bazel

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ go_library(
1111
"play_unix.go",
1212
"play_windows.go",
1313
"plt.go",
14+
"plt_build.go",
15+
"plt_clips.go",
16+
"plt_cuesheet.go",
1417
"plt_helpers.go",
18+
"plt_media.go",
1519
"plt_parse.go",
1620
"root.go",
1721
],
@@ -54,8 +58,15 @@ go_test(
5458
srcs = [
5559
"chapters_test.go",
5660
"lighting_test.go",
61+
"plt_build_integration_test.go",
62+
"plt_cache_test.go",
63+
"plt_client_test.go",
64+
"plt_clips_integration_test.go",
65+
"plt_clips_test.go",
66+
"plt_cuesheet_test.go",
5767
"plt_fixture_test.go",
5868
"plt_helpers_test.go",
69+
"plt_media_test.go",
5970
"plt_parse_test.go",
6071
"plt_print_test.go",
6172
"plt_sniff_test.go",

cmd/plt.go

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ import (
2121
"io"
2222
"os"
2323
"path/filepath"
24+
"strconv"
2425
"text/tabwriter"
2526

2627
"github.com/muesli/coral"
2728
"github.com/rs/zerolog/log"
29+
"github.com/spf13/viper"
2830
)
2931

3032
var pltPrintJSON bool
@@ -63,7 +65,7 @@ func runPltPrint(_ *coral.Command, args []string) {
6365
log.Fatal().Err(err).Msg("Could not parse playlist")
6466
}
6567

66-
view := buildPrintView(pl)
68+
view := buildPrintView(pl, viper.GetString("plt.mediaapi"))
6769

6870
if pltPrintJSON {
6971
err = renderJSON(os.Stdout, view)
@@ -132,6 +134,7 @@ type printItem struct {
132134
Kind string `json:"kind"`
133135
DurationSec float64 `json:"durationSec"`
134136
EndAction int `json:"endAction"`
137+
MediaURL string `json:"mediaURL,omitempty"`
135138
Markers []printMarker `json:"markers,omitempty"`
136139
}
137140

@@ -141,8 +144,11 @@ type printMarker struct {
141144
DurationSec float64 `json:"durationSec"`
142145
}
143146

144-
// buildPrintView projects the parsed playlist into the print summary.
145-
func buildPrintView(pl *Playlist) printView {
147+
// buildPrintView projects the parsed playlist into the print summary. base is
148+
// the configured media API endpoint (plt.mediaapi); when empty, media URLs are
149+
// shown with a "<plt.mediaapi>" placeholder so the playlist-derived query is
150+
// still visible offline.
151+
func buildPrintView(pl *Playlist, base string) printView {
146152
view := printView{Name: pl.Name, Items: make([]printItem, 0, len(pl.Items))}
147153

148154
for _, it := range pl.Items {
@@ -153,6 +159,7 @@ func buildPrintView(pl *Playlist) printView {
153159
Kind: itemKind(it),
154160
DurationSec: itemDurationSec(it),
155161
EndAction: it.EndAction,
162+
MediaURL: itemMediaURL(it, base),
156163
}
157164
for _, m := range it.Markers {
158165
pi.Markers = append(pi.Markers, printMarker{
@@ -166,6 +173,33 @@ func buildPrintView(pl *Playlist) printView {
166173
return view
167174
}
168175

176+
// itemMediaURL builds the media API query URL an item resolves to, derived from
177+
// its catalog keys. Image cues and unsupported shapes have no URL. When base is
178+
// empty the placeholder "<plt.mediaapi>" stands in for the configured endpoint.
179+
func itemMediaURL(it Item, base string) string {
180+
if it.Location == nil {
181+
return ""
182+
}
183+
if base == "" {
184+
base = "<plt.mediaapi>"
185+
}
186+
187+
url, err := buildMediaURL(base, displayLangCode(it.Location.MepsLanguage), it.Location)
188+
if err != nil {
189+
return ""
190+
}
191+
return url
192+
}
193+
194+
// displayLangCode returns the written-language code for a MepsLanguage ID, or
195+
// the numeric ID when it is not mapped (best effort for display only).
196+
func displayLangCode(id int) string {
197+
if code, err := resolveLanguage(id, ""); err == nil {
198+
return code
199+
}
200+
return strconv.Itoa(id)
201+
}
202+
169203
// describeSource renders a one-line description of where an item's media comes
170204
// from, following the catalog resolution rule (KeySymbol+Track, book/chapter, docid).
171205
func describeSource(it Item) string {
@@ -178,12 +212,17 @@ func describeSource(it Item) string {
178212
return "unknown source"
179213
}
180214

181-
switch {
182-
case loc.KeySymbol == "nwt" && loc.BookNumber > 0:
215+
shape, err := classifyLocation(loc)
216+
if err != nil {
217+
return fmt.Sprintf("unsupported location (type %d)", loc.Type)
218+
}
219+
220+
switch shape {
221+
case shapeBookChapter:
183222
return fmt.Sprintf("book %d:%d", loc.BookNumber, loc.ChapterNumber)
184-
case loc.KeySymbol != "" && loc.Track > 0:
223+
case shapePub:
185224
return fmt.Sprintf("pub %s track %d", loc.KeySymbol, loc.Track)
186-
case loc.Type == 3 && loc.DocumentID > 0:
225+
case shapeDocid:
187226
return fmt.Sprintf("docid %d", loc.DocumentID)
188227
default:
189228
return fmt.Sprintf("unsupported location (type %d)", loc.Type)
@@ -226,7 +265,7 @@ func renderText(w io.Writer, view printView) error {
226265
}
227266

228267
tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0)
229-
if _, err := fmt.Fprintln(tw, "#\tLABEL\tSOURCE\tDURATION\tMARKERS\tEND"); err != nil {
268+
if _, err := fmt.Fprintln(tw, "#\tLABEL\tSOURCE\tDURATION\tMARKERS\tAFTER"); err != nil {
230269
return fmt.Errorf("could not write table header: %w", err)
231270
}
232271

@@ -235,15 +274,40 @@ func renderText(w io.Writer, view printView) error {
235274
if len(it.Markers) > 0 {
236275
markers = fmt.Sprintf("%d", len(it.Markers))
237276
}
238-
if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%.1fs\t%s\t%d\n",
239-
it.Position, it.Label, it.Source, it.DurationSec, markers, it.EndAction); err != nil {
277+
if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%.1fs\t%s\t%s\n",
278+
it.Position, it.Label, it.Source, it.DurationSec, markers, endActionLabel(it.EndAction)); err != nil {
240279
return fmt.Errorf("could not write table row: %w", err)
241280
}
242281
}
243282

244283
if err := tw.Flush(); err != nil {
245284
return fmt.Errorf("could not flush table: %w", err)
246285
}
286+
287+
return renderMediaURLs(w, view)
288+
}
289+
290+
// renderMediaURLs prints the media API query URL each item resolves to, derived
291+
// from the playlist's catalog keys.
292+
func renderMediaURLs(w io.Writer, view printView) error {
293+
var withURL []printItem
294+
for _, it := range view.Items {
295+
if it.MediaURL != "" {
296+
withURL = append(withURL, it)
297+
}
298+
}
299+
if len(withURL) == 0 {
300+
return nil
301+
}
302+
303+
if _, err := fmt.Fprint(w, "\nMedia URLs:\n"); err != nil {
304+
return fmt.Errorf("could not write media URL header: %w", err)
305+
}
306+
for _, it := range withURL {
307+
if _, err := fmt.Fprintf(w, " %d %s\n", it.Position, it.MediaURL); err != nil {
308+
return fmt.Errorf("could not write media URL: %w", err)
309+
}
310+
}
247311
return nil
248312
}
249313

0 commit comments

Comments
 (0)