Null-DRM-Official/apps/cli/catalogcmd/catalogcmd.go
404errordeveloper 2fa8f2435f 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.
2026-10-06 00:25:35 +02:00

181 lines
5.2 KiB
Go

// 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] + "..."
}