Initial commit: Null DRM Official
Capture, decrypt, and restream toolkit with compiled-in app modules (RTE, TG4, BBC), on-device MITM proxy, streamd control plane, and www. BBC module.yaml is published (clear streams); other module values stay local.
This commit is contained in:
commit
2fa8f2435f
121 changed files with 17802 additions and 0 deletions
181
apps/cli/catalogcmd/catalogcmd.go
Normal file
181
apps/cli/catalogcmd/catalogcmd.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// Package catalogcmd resolves a channel's stream credentials from its provider's
|
||||
// public catalog, with no phone involved. It is provider-agnostic: any app module
|
||||
// implementing app.Catalog works here.
|
||||
package catalogcmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"drmdecryption/app"
|
||||
"drmdecryption/repo"
|
||||
"drmdecryption/session"
|
||||
)
|
||||
|
||||
// Run performs a catalog lookup for one channel.
|
||||
func Run(args []string) error {
|
||||
fs := flag.NewFlagSet("catalog", flag.ExitOnError)
|
||||
appName := fs.String("app", "", "app module to query")
|
||||
channel := fs.String("channel", "", "channel id")
|
||||
asJSON := fs.Bool("json", false, "print streamd-ready JSON only")
|
||||
list := fs.Bool("list", false, "list the app's channels and their catalog ids")
|
||||
writeOut := fs.Bool("out", false, "write outputs/<app>/<stamp>/session.json")
|
||||
withKeys := fs.Bool("keys", false, "also fetch PSSH and resolve KID:KEY via the local CDM")
|
||||
python := fs.String("python", "", "python for wvkey.py (default: .venv)")
|
||||
wvd := fs.String("wvd", "", "path to .wvd device file (required with --keys)")
|
||||
// Each module contributes --<app>.<field> overrides for its local values.
|
||||
app.BindFlags(fs)
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(fs.Output(), "catalog — resolve a channel from its public catalog (no phone)\n\nUsage:\n catalog --app <%s> --channel ID [--keys] [--json]\n\nFlags:\n", strings.Join(catalogApps(), "|"))
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
available := catalogApps()
|
||||
if strings.TrimSpace(*appName) == "" {
|
||||
if len(available) != 1 {
|
||||
return fmt.Errorf("--app required (apps with a catalog: %s)", strings.Join(available, ", "))
|
||||
}
|
||||
*appName = available[0]
|
||||
}
|
||||
a, err := app.Get(*appName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cat, ok := a.(app.Catalog)
|
||||
if !ok {
|
||||
return fmt.Errorf("app %q has no catalog — capture it from the phone instead: drm capture --app %s", a.Name(), a.Name())
|
||||
}
|
||||
|
||||
if *list {
|
||||
return listChannels(a, cat)
|
||||
}
|
||||
if strings.TrimSpace(*channel) == "" {
|
||||
return fmt.Errorf("--channel required (see: drm catalog --app %s --list)", a.Name())
|
||||
}
|
||||
|
||||
info, err := cat.Resolve(*channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *withKeys {
|
||||
fmt.Fprintln(os.Stderr, "[*] fetching PSSH + Widevine keys…")
|
||||
if err := cat.EnrichWithKeys(&info, *python, *wvd); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[+] key %s (%d total)\n", info.Key, len(info.Keys))
|
||||
}
|
||||
if *writeOut || *withKeys {
|
||||
dir, err := session.WriteStream(repo.Root(), a.Name(), info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[+] wrote %s\n", dir)
|
||||
}
|
||||
if *asJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(info)
|
||||
}
|
||||
printInfo(a.Name(), info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// catalogApps lists compiled-in modules that can answer catalog lookups.
|
||||
func catalogApps() []string {
|
||||
out := []string{}
|
||||
for _, a := range app.All() {
|
||||
if _, ok := a.(app.Catalog); ok {
|
||||
out = append(out, a.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func listChannels(a app.App, cat app.Catalog) error {
|
||||
if l, ok := cat.(app.CatalogLister); ok {
|
||||
rows, flags, err := l.ListChannels()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%-10s %-16s %s\n", "CHANNEL", "CATALOG ID", "LABEL")
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
|
||||
for _, r := range rows {
|
||||
id := r.CatalogID
|
||||
if id == "" {
|
||||
id = "-"
|
||||
}
|
||||
fmt.Printf("%-10s %-16s %s\n", r.ID, id, r.Label)
|
||||
}
|
||||
if len(flags) > 0 {
|
||||
keys := make([]string, 0, len(flags))
|
||||
for k := range flags {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%v", k, flags[k]))
|
||||
}
|
||||
fmt.Printf("\nprovider flags: %s\n", strings.Join(parts, " "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Fall back to the plain channel list every app can produce.
|
||||
fmt.Printf("%-10s %s\n", "CHANNEL", "LABEL")
|
||||
for _, ch := range a.Channels() {
|
||||
fmt.Printf("%-10s %s\n", ch.ID, ch.Label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printInfo(appName string, info session.Stream) {
|
||||
fmt.Printf("name: %s\n", info.Name)
|
||||
fmt.Printf("title: %s\n", info.Title)
|
||||
fmt.Printf("app/channel: %s / %s\n", info.App, info.Channel)
|
||||
if info.VideoID != "" {
|
||||
fmt.Printf("catalog id: %s\n", info.VideoID)
|
||||
}
|
||||
fmt.Printf("manifest: %s\n", info.MPD)
|
||||
fmt.Printf("manifest type: %s\n", info.ManifestType)
|
||||
fmt.Printf("license_url: %s\n", truncate(info.LicenseURL, 100))
|
||||
if info.PSSH != "" {
|
||||
fmt.Printf("pssh: %s\n", truncate(info.PSSH, 64))
|
||||
}
|
||||
if info.Key != "" {
|
||||
fmt.Printf("key: %s\n", info.Key)
|
||||
}
|
||||
for i, k := range info.Keys {
|
||||
if k == info.Key {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("key[%d]: %s\n", i, k)
|
||||
}
|
||||
body, _ := json.MarshalIndent(map[string]any{
|
||||
"name": info.Name,
|
||||
"title": info.Title,
|
||||
"app": info.App,
|
||||
"channel": info.Channel,
|
||||
"mpd": info.MPD,
|
||||
"key": info.Key,
|
||||
"headers_json": info.HeadersJSON,
|
||||
"rewriter": info.Rewriter,
|
||||
}, "", " ")
|
||||
fmt.Printf("\nstreamd create body:\n%s\n", body)
|
||||
fmt.Printf("\nTip: drm catalog --app %s --channel %s --keys --wvd path/to/device.wvd --json\n",
|
||||
appName, info.Channel)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
36
apps/cli/go.mod
Normal file
36
apps/cli/go.mod
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
module drmdecryption/apps/cli
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
drmdecryption v0.0.0
|
||||
drmdecryption/apps/agent v0.0.0
|
||||
drmdecryption/apps/capture v0.0.0
|
||||
drmdecryption/apps/proxy v0.0.0
|
||||
drmdecryption/apps/streamd v0.0.0
|
||||
drmdecryption/modules v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/sqlite v1.34.5 // indirect
|
||||
)
|
||||
|
||||
replace (
|
||||
drmdecryption => ../pkg
|
||||
drmdecryption/apps/agent => ../agent
|
||||
drmdecryption/apps/capture => ../capture
|
||||
drmdecryption/apps/proxy => ../proxy
|
||||
drmdecryption/apps/streamd => ../streamd
|
||||
drmdecryption/modules => ../modules
|
||||
)
|
||||
49
apps/cli/go.sum
Normal file
49
apps/cli/go.sum
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
76
apps/cli/main.go
Normal file
76
apps/cli/main.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Command drm is the single entry point for the toolchain: phone capture,
|
||||
// catalog lookups, the always-on agent, the streamd control plane and the
|
||||
// on-device MITM. Every app module under apps/modules is compiled in.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"drmdecryption/apps/agent/agentcmd"
|
||||
"drmdecryption/apps/capture/capturecmd"
|
||||
"drmdecryption/apps/cli/catalogcmd"
|
||||
"drmdecryption/apps/cli/modulescmd"
|
||||
"drmdecryption/apps/proxy/proxyctlcmd"
|
||||
"drmdecryption/apps/streamd/servecmd"
|
||||
|
||||
// Links every app module into this binary.
|
||||
_ "drmdecryption/modules/all"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
cmd, args := os.Args[1], os.Args[2:]
|
||||
var err error
|
||||
switch cmd {
|
||||
case "capture":
|
||||
err = capturecmd.Run(args)
|
||||
case "catalog":
|
||||
err = catalogcmd.Run(args)
|
||||
case "modules":
|
||||
err = modulescmd.Run(args)
|
||||
case "agent":
|
||||
err = agentcmd.Run(args)
|
||||
case "serve":
|
||||
err = servecmd.Run(append([]string{"serve"}, args...))
|
||||
case "proxy":
|
||||
err = proxyctlcmd.Run(args)
|
||||
case "help", "-h", "--help":
|
||||
usage()
|
||||
return
|
||||
default:
|
||||
usage()
|
||||
err = fmt.Errorf("unknown command %q", cmd)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[!]", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `drm — DRM capture / restream toolchain
|
||||
|
||||
Usage:
|
||||
drm modules list compiled-in app modules and their channels
|
||||
drm capture --app NAME [--channel ID] [--auto-play] [--wvd PATH]
|
||||
one-shot phone MITM capture
|
||||
drm catalog --app NAME --channel ID [--keys] [--json] [--list]
|
||||
resolve a channel from its public catalog (no phone)
|
||||
drm agent run|status|devices|enqueue|cancel [--config …]
|
||||
always-on credential refresher
|
||||
drm serve [--bind …] [--data …] [--token …]
|
||||
streamd control plane + dashboard
|
||||
drm proxy build|push|install-ca|start|stop|discover|pull|clear-proxy
|
||||
on-device HTTPS MITM
|
||||
|
||||
App modules are compiled in; their local values live in
|
||||
apps/modules/<name>/module.yaml (gitignored) and can be overridden per run with
|
||||
--<app>.<field> flags or <APP>_<FIELD> environment variables.
|
||||
|
||||
Run any subcommand with --help for its own flags.
|
||||
`)
|
||||
}
|
||||
78
apps/cli/modulescmd/modulescmd.go
Normal file
78
apps/cli/modulescmd/modulescmd.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// Package modulescmd lists the app modules compiled into this binary, their
|
||||
// channels, and where each one's local values came from.
|
||||
package modulescmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"text/tabwriter"
|
||||
|
||||
"drmdecryption/app"
|
||||
"drmdecryption/modcfg"
|
||||
)
|
||||
|
||||
// Run prints the compiled-in app modules.
|
||||
func Run(args []string) error {
|
||||
fs := flag.NewFlagSet("modules", flag.ExitOnError)
|
||||
verbose := fs.Bool("channels", true, "list each module's channels")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
names := app.Names()
|
||||
if len(names) == 0 {
|
||||
return fmt.Errorf("no app modules compiled in — check the blank imports in apps/modules/all/all.go")
|
||||
}
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "APP\tPACKAGE\tKEY MODE\tREWRITER\tCHANNELS\tVALUES")
|
||||
for _, name := range names {
|
||||
a, err := app.Get(name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(tw, "%s\t-\t-\t-\t-\t%v\n", name, err)
|
||||
continue
|
||||
}
|
||||
rewriter := "none"
|
||||
if sd, ok := a.(app.StreamDefaults); ok && sd.RewriterName() != "" {
|
||||
rewriter = sd.RewriterName()
|
||||
}
|
||||
pkg := a.Package()
|
||||
if pkg == "" {
|
||||
pkg = "(not configured)"
|
||||
}
|
||||
values := modcfg.Path(name)
|
||||
if _, err := os.Stat(values); err != nil {
|
||||
values = "missing: " + values
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\n",
|
||||
name, pkg, a.KeyMode(), rewriter, len(a.Channels()), values)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
|
||||
if !*verbose {
|
||||
return nil
|
||||
}
|
||||
for _, name := range names {
|
||||
a, err := app.Get(name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
chans := a.Channels()
|
||||
sort.Slice(chans, func(i, j int) bool { return chans[i].ID < chans[j].ID })
|
||||
fmt.Printf("\n%s channels:", name)
|
||||
if len(chans) == 0 {
|
||||
fmt.Printf(" none — add %s\n", modcfg.Path(name))
|
||||
continue
|
||||
}
|
||||
fmt.Println()
|
||||
for _, ch := range chans {
|
||||
catalog := ""
|
||||
if _, ok := a.(app.Catalog); ok {
|
||||
catalog = " (catalog)"
|
||||
}
|
||||
fmt.Printf(" %-10s %s%s\n", ch.ID, ch.Label, catalog)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue