Created
March 4, 2026 18:52
-
-
Save smartass08/b1595437ff30942f62d9a3c9bf4c2f5f to your computer and use it in GitHub Desktop.
A go scirpt which helps combining nzb files into one single batch.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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