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.
181 lines
5.2 KiB
Go
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] + "..."
|
|
}
|