Skip to content

Instantly share code, notes, and snippets.

@smartass08
Created March 4, 2026 18:52
Show Gist options
  • Select an option

  • Save smartass08/b1595437ff30942f62d9a3c9bf4c2f5f to your computer and use it in GitHub Desktop.

Select an option

Save smartass08/b1595437ff30942f62d9a3c9bf4c2f5f to your computer and use it in GitHub Desktop.
A go scirpt which helps combining nzb files into one single batch.
package main
import (
"bytes"
"crypto/sha1"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"golang.org/x/net/html/charset"
)
const (
defaultXMLNS = "http://www.newzbin.com/DTD/2003/nzb"
doctypeNZB = "<!DOCTYPE nzb PUBLIC \"-//newzBin//DTD NZB 1.1//EN\" \"http://www.newzbin.com/DTD/nzb/nzb-1.1.dtd\">"
)
type nzbDoc struct {
XMLName xml.Name `xml:"nzb"`
XMLNS string `xml:"xmlns,attr,omitempty"`
Head *nzbHead `xml:"head,omitempty"`
Files []nzbFile `xml:"file"`
}
type nzbHead struct {
Meta []nzbMeta `xml:"meta"`
}
type nzbMeta struct {
Type string `xml:"type,attr,omitempty"`
Value string `xml:",chardata"`
}
type nzbFile struct {
Poster string `xml:"poster,attr,omitempty"`
Date int64 `xml:"date,attr,omitempty"`
Subject string `xml:"subject,attr,omitempty"`
Groups []string `xml:"groups>group"`
Segments []nzbSegment `xml:"segments>segment"`
}
type nzbSegment struct {
Bytes int64 `xml:"bytes,attr,omitempty"`
Number int `xml:"number,attr,omitempty"`
MessageID string `xml:",chardata"`
}
type sourceDoc struct {
name string
doc nzbDoc
}
func main() {
if len(os.Args) != 3 {
exitf("usage: %s <source_folder> <output_name_or_path>", filepath.Base(os.Args[0]))
}
sourceFolder := os.Args[1]
outputPath := ensureNZBExtension(os.Args[2])
sources, err := collectNZBs(sourceFolder)
if err != nil {
exitf("collecting nzb files: %v", err)
}
if len(sources) == 0 {
exitf("no .nzb files found in %q", sourceFolder)
}
combined := combineSources(sources)
if err := writeCombined(outputPath, combined); err != nil {
exitf("writing output nzb: %v", err)
}
fmt.Printf("Combined %d NZB files into %q (%d file entries).\n", len(sources), outputPath, len(combined.Files))
}
func collectNZBs(folder string) ([]sourceDoc, error) {
entries, err := os.ReadDir(folder)
if err != nil {
return nil, err
}
var names []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
if strings.EqualFold(filepath.Ext(entry.Name()), ".nzb") {
names = append(names, entry.Name())
}
}
sort.Slice(names, func(i, j int) bool {
return strings.ToLower(names[i]) < strings.ToLower(names[j])
})
out := make([]sourceDoc, 0, len(names))
for _, name := range names {
path := filepath.Join(folder, name)
doc, err := parseNZB(path)
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
out = append(out, sourceDoc{name: name, doc: doc})
}
return out, nil
}
func parseNZB(path string) (nzbDoc, error) {
data, err := os.ReadFile(path)
if err != nil {
return nzbDoc{}, err
}
dec := xml.NewDecoder(bytes.NewReader(data))
dec.CharsetReader = charset.NewReaderLabel
var doc nzbDoc
if err := dec.Decode(&doc); err != nil {
return nzbDoc{}, err
}
if doc.XMLName.Local != "nzb" {
return nzbDoc{}, errors.New("root element is not <nzb>")
}
for i := range doc.Files {
for j := range doc.Files[i].Segments {
doc.Files[i].Segments[j].MessageID = normalizeMsgID(doc.Files[i].Segments[j].MessageID)
}
}
if doc.XMLNS == "" {
doc.XMLNS = defaultXMLNS
}
return doc, nil
}
func combineSources(sources []sourceDoc) nzbDoc {
combined := nzbDoc{XMLNS: defaultXMLNS}
metaSeen := make(map[string]struct{})
var mergedMeta []nzbMeta
fileSeen := make(map[string]struct{})
for _, src := range sources {
if src.doc.XMLNS != "" {
combined.XMLNS = src.doc.XMLNS
}
if src.doc.Head != nil {
for _, meta := range src.doc.Head.Meta {
meta.Type = strings.TrimSpace(meta.Type)
meta.Value = strings.TrimSpace(meta.Value)
if meta.Type == "" && meta.Value == "" {
continue
}
key := strings.ToLower(meta.Type) + "\x00" + meta.Value
if _, ok := metaSeen[key]; ok {
continue
}
metaSeen[key] = struct{}{}
mergedMeta = append(mergedMeta, meta)
}
}
for _, f := range src.doc.Files {
if len(f.Segments) == 0 {
continue
}
sig := fileSignature(f)
if _, ok := fileSeen[sig]; ok {
continue
}
fileSeen[sig] = struct{}{}
combined.Files = append(combined.Files, f)
}
}
if len(mergedMeta) > 0 {
combined.Head = &nzbHead{Meta: mergedMeta}
}
return combined
}
func fileSignature(f nzbFile) string {
h := sha1.New()
_, _ = io.WriteString(h, f.Poster)
_, _ = io.WriteString(h, "\x00")
_, _ = io.WriteString(h, fmt.Sprintf("%d", f.Date))
_, _ = io.WriteString(h, "\x00")
_, _ = io.WriteString(h, f.Subject)
_, _ = io.WriteString(h, "\x00")
for _, g := range f.Groups {
_, _ = io.WriteString(h, g)
_, _ = io.WriteString(h, "\x00")
}
for _, s := range f.Segments {
_, _ = io.WriteString(h, fmt.Sprintf("%d:%d:%s\x00", s.Number, s.Bytes, normalizeMsgID(s.MessageID)))
}
return hex.EncodeToString(h.Sum(nil))
}
func writeCombined(path string, doc nzbDoc) error {
if len(doc.Files) == 0 {
return errors.New("combined nzb has no file entries")
}
var buf bytes.Buffer
buf.WriteString(xml.Header)
buf.WriteString(doctypeNZB)
buf.WriteByte('\n')
enc := xml.NewEncoder(&buf)
enc.Indent("", " ")
if err := enc.Encode(doc); err != nil {
return err
}
if err := enc.Flush(); err != nil {
return err
}
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, buf.Bytes(), 0o644); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
func normalizeMsgID(id string) string {
id = strings.TrimSpace(id)
id = strings.TrimPrefix(id, "<")
id = strings.TrimSuffix(id, ">")
return id
}
func ensureNZBExtension(name string) string {
if strings.EqualFold(filepath.Ext(name), ".nzb") {
return name
}
return name + ".nzb"
}
func exitf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...)
os.Exit(1)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment