package protocol import ( "git.smesh.lol/nostr/pkg/lol/errorf" ) // Participant is a `p` tag carrying a role: an artist on a track, a credited // engineer, a roster member. The role is index 3, after the optional relay // hint. Roles come from the roster and credit vocabularies; the vocabularies // are conventions, so an unknown role is carried, not rejected. type Participant struct { Pubkey string Relay string Role string } func (p *Participant) Tag() (t []string) { if p == nil { return nil } // The role sits at index 3, so a participant with a role but no relay hint // still gets an empty relay slot: a bare third element would be read as a // relay by NIP-52 consumers. n := int32(2) switch { case p.Role != "": n = 4 case p.Relay != "": n = 3 } t = []string{:n} t[0] = TagP t[1] = p.Pubkey if n > 2 { t[2] = p.Relay } if n > 3 { t[3] = p.Role } return } // Price is a price tag: ["price", "", ""] and, for a service, // ["price", "", "", ""]. The amount stays a string: the // wire type is a string and a fiat amount is not an integer. type Price struct { Amount string Unit string Period string } func (p *Price) Tag() (t []string) { if p == nil { return nil } if p.Period != "" { t = []string{:4} t[0] = TagPrice t[1] = p.Amount t[2] = p.Unit t[3] = p.Period return } t = []string{:3} t[0] = TagPrice t[1] = p.Amount t[2] = p.Unit return } // BlobRef is cover art or an event image held on Blossom: a sha256 plus the // server that has it. The spec writes it ["image", "", ""]. type BlobRef struct { Sha256 string Server string } func (b *BlobRef) Tag() (t []string) { if b == nil { return nil } if b.Server == "" { t = []string{:2} t[0] = TagImage t[1] = b.Sha256 return } t = []string{:3} t[0] = TagImage t[1] = b.Sha256 t[2] = b.Server return } // ExternalID is a NIP-73 `i`/`k` pair: the identifier and its kind token, e.g. // musicbrainz:recording: with musicbrainz:recording. type ExternalID struct { I string K string } // Encoding is one published rendering of a recording: an `audio` tag and its // mirrored single-letter `x` tag. The x tag is what makes "which asset is this // blob?" a plain {"#x": [...]} query; a host needs that answer to gate a // download. type Encoding struct { Sha256 string // blob hash, 64 lowercase hex Spec string // codec-spec, e.g. flac-24bit-96kHz Server string // Blossom server that has the blob; "" falls back to the publisher's server list MIME string // media type, lowercase Size int64 // blob size in bytes HasSize bool // false when the optional size field is absent } func (e *Encoding) Tag() (t []string) { if e == nil { return nil } // The tag is positional: a media type or a size cannot be written without // the slot before it, so an absent middle field is written empty rather // than shifting the rest left. n := int32(3) switch { case e.HasSize: n = 6 case e.MIME != "": n = 5 case e.Server != "": n = 4 } t = []string{:n} t[0] = TagAudio t[1] = e.Sha256 t[2] = e.Spec if n > 3 { t[3] = e.Server } if n > 4 { t[4] = e.MIME } if n > 5 { t[5] = intStr(e.Size) } return } func (e *Encoding) XTag() (t []string) { if e == nil { return nil } t = []string{:2} t[0] = TagX t[1] = e.Sha256 return } // Track is the payload of a kind 32210 event: the recording, with every // published encoding. The d tag is the recording identity and should be the // ISRC when the recording has one; the event id changes on every update, so // references use the address. type Track struct { D string Title string Artists []string // free-text artist names, repeatable Participants []Participant Album *Address AlbumTitle string TrackNo string DiscNo string Duration string // seconds, floating point (NIP-71 convention) Genres []string Hashtags []string ReleaseDate string ISRC string Language string BPM string Key string Copyright string Price *Price Cover *BlobRef External []ExternalID Encodings []Encoding Master string // sha256 of the lossless master encoding, exactly one Content string // liner notes Extra [][]string } // Tags renders the payload as the tag list of the event. func (t *Track) Tags() (tags [][]string) { tags = appendKV(nil, TagD, t.D) tags = appendKV(tags, TagTitle, t.Title) for _, a := range t.Artists { tags = appendKV(tags, TagArtist, a) } for i := range t.Participants { tags = appendTag(tags, t.Participants[i].Tag()) } if t.Album != nil { tags = appendTag(tags, t.Album.Tag()) } tags = appendKV(tags, TagAlbum, t.AlbumTitle) tags = appendKV(tags, TagTrack, t.TrackNo) tags = appendKV(tags, TagDisc, t.DiscNo) tags = appendKV(tags, TagDuration, t.Duration) for _, g := range t.Genres { tags = appendKV(tags, TagGenre, g) } for _, h := range t.Hashtags { tags = appendKV(tags, TagT, h) } tags = appendKV(tags, TagReleaseDate, t.ReleaseDate) tags = appendKV(tags, TagISRC, t.ISRC) tags = appendKV(tags, TagLanguage, t.Language) tags = appendKV(tags, TagBPM, t.BPM) tags = appendKV(tags, TagKey, t.Key) tags = appendKV(tags, TagCopyright, t.Copyright) tags = appendTag(tags, t.Price.Tag()) tags = appendTag(tags, t.Cover.Tag()) for i := range t.External { tags = appendKV(tags, TagI, t.External[i].I) tags = appendKV(tags, TagK, t.External[i].K) } for i := range t.Encodings { tags = appendTag(tags, t.Encodings[i].Tag()) } for i := range t.Encodings { tags = appendTag(tags, t.Encodings[i].XTag()) } tags = appendKV(tags, TagMaster, t.Master) tags = appendTags(tags, t.Extra) return } // ParseTrack reads a kind 32210 tag list. Tags this version does not know are // kept in Extra and re-emitted, so an older build cannot strip a newer field. func ParseTrack(tags [][]string, content string) (t *Track, err error) { t = &Track{Content: content} for i := range tags { tg := tags[i] if len(tg) < 1 { continue } switch tg[0] { case TagD: t.D = elem(tg, 1) case TagTitle: t.Title = elem(tg, 1) case TagArtist: t.Artists = push(t.Artists, elem(tg, 1)) case TagP: p, perr := parseParticipant(tg) if perr != nil { err = perr return } t.Participants = push(t.Participants, p) case TagA: a, aerr := AddressFromTag(tg) if aerr != nil { err = aerr return } t.Album = a case TagAlbum: t.AlbumTitle = elem(tg, 1) case TagTrack: t.TrackNo = elem(tg, 1) case TagDisc: t.DiscNo = elem(tg, 1) case TagDuration: t.Duration = elem(tg, 1) case TagGenre: t.Genres = push(t.Genres, elem(tg, 1)) case TagT: t.Hashtags = push(t.Hashtags, elem(tg, 1)) case TagReleaseDate: t.ReleaseDate = elem(tg, 1) case TagISRC: t.ISRC = elem(tg, 1) case TagLanguage: t.Language = elem(tg, 1) case TagBPM: t.BPM = elem(tg, 1) case TagKey: t.Key = elem(tg, 1) case TagCopyright: t.Copyright = elem(tg, 1) case TagPrice: t.Price = parsePrice(tg) case TagImage: t.Cover = parseBlobRef(tg) case TagI: t.External = pushExternalI(t.External, elem(tg, 1)) case TagK: t.External = pushExternalK(t.External, elem(tg, 1)) case TagAudio: enc := parseEncoding(tg) // An x tag may have arrived first and left a bare entry; fill it // rather than adding a second entry for the same blob. merged := false for j := range t.Encodings { if t.Encodings[j].Sha256 == enc.Sha256 && t.Encodings[j].Spec == "" { t.Encodings[j] = enc merged = true break } } if !merged { t.Encodings = push(t.Encodings, enc) } case TagX: // The x tags mirror the audio tags; fold each into its encoding // so the pair survives even when the order is interleaved. hash := elem(tg, 1) if hash != "" && !encodingHas(t.Encodings, hash) { t.Encodings = push(t.Encodings, Encoding{Sha256: hash}) } case TagMaster: t.Master = elem(tg, 1) default: t.Extra = appendTag(t.Extra, tg) } } return } // Validate checks the structural rules the spec states. It does not enforce a // required-tag list: the documents define no such list, so a track without a // title is carried and reported by Warnings instead. func (t *Track) Validate() (err error) { if t == nil { err = errorf.E("nil track") return } if t.D == "" { err = errorf.E("track: empty d tag") return } if t.Album != nil && t.Album.Kind != KindAlbum { err = errorf.E("track: album reference is kind %d, want %d", t.Album.Kind, KindAlbum) return } if t.Price != nil && t.Price.Unit == "" { err = errorf.E("track: price without a unit") return } if t.Cover != nil && !IsHex64(t.Cover.Sha256) { err = errorf.E("track: cover art hash %q is not 64 hex characters", t.Cover.Sha256) return } for i := range t.Encodings { e := &t.Encodings[i] if !IsHex64(e.Sha256) { err = errorf.E("track: encoding %d hash %q is not 64 hex characters", i, e.Sha256) return } if e.Spec == "" { err = errorf.E("track: encoding %d has no codec spec", i) return } if _, cerr := ParseCodecSpec(e.Spec); cerr != nil { err = errorf.E("track: encoding %d: %s", i, cerr.Error()) return } } if t.Master != "" { if !IsHex64(t.Master) { err = errorf.E("track: master hash %q is not 64 hex characters", t.Master) return } if !encodingHas(t.Encodings, t.Master) { err = errorf.E("track: master %s is not one of the encodings", t.Master) return } } for i := range t.Participants { if !IsHex64(t.Participants[i].Pubkey) { err = errorf.E("track: participant %d pubkey is not 64 hex characters", i) return } } return } // Warnings lists what the spec calls for but does not require. A relay can // store such a track; a client should surface the gap rather than invent data. func (t *Track) Warnings() (w []string) { if t.Title == "" { w = push(w, "no title tag") } if len(t.Artists) == 0 && len(t.Participants) == 0 { w = push(w, "no artist or participant") } if len(t.Encodings) == 0 { w = push(w, "no audio encoding") } if len(t.Encodings) > 0 && t.Master == "" { w = push(w, "encodings but no master tag") } if t.ISRC != "" && t.D != t.ISRC { w = push(w, "isrc set but d tag is not the isrc") } for i := range t.Participants { if !KnownRole(t.Participants[i].Role) && t.Participants[i].Role != "" { w = push(w, "unknown role: " | t.Participants[i].Role) } } return } // Album is the payload of a kind 32211 event: the release identity. The // ordered track addresses are the tracklist - tag order is the order. type Album struct { D string Title string Artists []string Participants []Participant ReleaseDate string Genres []string Hashtags []string Language string Copyright string Price *Price Cover *BlobRef External []ExternalID Tracks []*Address Content string Extra [][]string } func (a *Album) Tags() (tags [][]string) { tags = appendKV(nil, TagD, a.D) tags = appendKV(tags, TagTitle, a.Title) for _, s := range a.Artists { tags = appendKV(tags, TagArtist, s) } for i := range a.Participants { tags = appendTag(tags, a.Participants[i].Tag()) } tags = appendKV(tags, TagReleaseDate, a.ReleaseDate) for _, g := range a.Genres { tags = appendKV(tags, TagGenre, g) } for _, h := range a.Hashtags { tags = appendKV(tags, TagT, h) } tags = appendKV(tags, TagLanguage, a.Language) tags = appendKV(tags, TagCopyright, a.Copyright) tags = appendTag(tags, a.Price.Tag()) tags = appendTag(tags, a.Cover.Tag()) for i := range a.External { tags = appendKV(tags, TagI, a.External[i].I) tags = appendKV(tags, TagK, a.External[i].K) } for i := range a.Tracks { tags = appendTag(tags, a.Tracks[i].Tag()) } tags = appendTags(tags, a.Extra) return } func ParseAlbum(tags [][]string, content string) (a *Album, err error) { a = &Album{Content: content} for i := range tags { tg := tags[i] if len(tg) < 1 { continue } switch tg[0] { case TagD: a.D = elem(tg, 1) case TagTitle: a.Title = elem(tg, 1) case TagArtist: a.Artists = push(a.Artists, elem(tg, 1)) case TagP: p, perr := parseParticipant(tg) if perr != nil { err = perr return } a.Participants = push(a.Participants, p) case TagReleaseDate: a.ReleaseDate = elem(tg, 1) case TagGenre: a.Genres = push(a.Genres, elem(tg, 1)) case TagT: a.Hashtags = push(a.Hashtags, elem(tg, 1)) case TagLanguage: a.Language = elem(tg, 1) case TagCopyright: a.Copyright = elem(tg, 1) case TagPrice: a.Price = parsePrice(tg) case TagImage: a.Cover = parseBlobRef(tg) case TagI: a.External = pushExternalI(a.External, elem(tg, 1)) case TagK: a.External = pushExternalK(a.External, elem(tg, 1)) case TagA: addr, aerr := AddressFromTag(tg) if aerr != nil { err = aerr return } a.Tracks = push(a.Tracks, addr) default: a.Extra = appendTag(a.Extra, tg) } } return } func (a *Album) Validate() (err error) { if a == nil { err = errorf.E("nil album") return } if a.D == "" { err = errorf.E("album: empty d tag") return } for i := range a.Tracks { if a.Tracks[i].Kind != KindTrack { err = errorf.E("album: track %d is kind %d, want %d", i, a.Tracks[i].Kind, KindTrack) return } } if a.Price != nil && a.Price.Unit == "" { err = errorf.E("album: price without a unit") return } return } func (a *Album) Warnings() (w []string) { if a.Title == "" { w = push(w, "no title tag") } if len(a.Tracks) == 0 { w = push(w, "no track addresses") } return } // Playlist is the payload of a kind 32212 event: a named, ordered set of // tracks following NIP-51 conventions. A private playlist encrypts its members // into Content (NIP-44, self-key) and carries no track tags at all. type Playlist struct { D string Title string Description string Cover *BlobRef Tracks []*Address Content string // private lists carry the encrypted member list here Extra [][]string } func (p *Playlist) Tags() (tags [][]string) { tags = appendKV(nil, TagD, p.D) tags = appendKV(tags, TagTitle, p.Title) tags = appendKV(tags, TagDescription, p.Description) tags = appendTag(tags, p.Cover.Tag()) for i := range p.Tracks { tags = appendTag(tags, p.Tracks[i].Tag()) } tags = appendTags(tags, p.Extra) return } func ParsePlaylist(tags [][]string, content string) (p *Playlist, err error) { p = &Playlist{Content: content} for i := range tags { tg := tags[i] if len(tg) < 1 { continue } switch tg[0] { case TagD: p.D = elem(tg, 1) case TagTitle: p.Title = elem(tg, 1) case TagDescription: p.Description = elem(tg, 1) case TagImage: p.Cover = parseBlobRef(tg) case TagA: addr, aerr := AddressFromTag(tg) if aerr != nil { err = aerr return } p.Tracks = push(p.Tracks, addr) default: p.Extra = appendTag(p.Extra, tg) } } return } func (p *Playlist) Validate() (err error) { if p == nil { err = errorf.E("nil playlist") return } if p.D == "" { err = errorf.E("playlist: empty d tag") return } for i := range p.Tracks { if p.Tracks[i].Kind != KindTrack { err = errorf.E("playlist: member %d is kind %d, want %d", i, p.Tracks[i].Kind, KindTrack) return } } return } func (p *Playlist) IsPrivate() (ok bool) { return len(p.Tracks) == 0 && p.Content != "" } // Dleq is the NUT-12 proof that makes a blind signature verifiable by a third // party. On secp256k1 the check C == K*H(m) is a pairing equation and cannot be // computed at all, so without this proof a paid play report is verifiable only // by the host that signed it. type Dleq struct { E string S string } // PlayReport is the payload of a kind 3221 event: one play, one immutable // record, counted by relays (NIP-45). A free play is self-attested and carries // no host proof; a paid play carries the host's blind signature and the DLEQ // proof. type PlayReport struct { Asset *Address Artist string Blob string Server string Codec string T0 string T1 string Blind string Host string Statement string Dleq *Dleq Content string Extra [][]string } func (r *PlayReport) Tags() (tags [][]string) { tags = appendTag(nil, r.Asset.Tag()) tags = appendKV(tags, TagP, r.Artist) tags = appendKV(tags, TagX, r.Blob) tags = appendKV(tags, TagServer, r.Server) tags = appendKV(tags, TagCodec, r.Codec) tags = appendKV(tags, TagT0, r.T0) tags = appendKV(tags, TagT1, r.T1) tags = appendKV(tags, TagBlind, r.Blind) tags = appendKV(tags, TagHost, r.Host) tags = appendKV(tags, TagStatement, r.Statement) if r.Dleq != nil { d := []string{:3} d[0] = TagDleq d[1] = r.Dleq.E d[2] = r.Dleq.S tags = appendTag(tags, d) } tags = appendTags(tags, r.Extra) return } func ParsePlayReport(tags [][]string, content string) (r *PlayReport, err error) { r = &PlayReport{Content: content} for i := range tags { tg := tags[i] if len(tg) < 1 { continue } switch tg[0] { case TagA: addr, aerr := AddressFromTag(tg) if aerr != nil { err = aerr return } r.Asset = addr case TagP: r.Artist = elem(tg, 1) case TagX: r.Blob = elem(tg, 1) case TagServer: r.Server = elem(tg, 1) case TagCodec: r.Codec = elem(tg, 1) case TagT0: r.T0 = elem(tg, 1) case TagT1: r.T1 = elem(tg, 1) case TagBlind: r.Blind = elem(tg, 1) case TagHost: r.Host = elem(tg, 1) case TagStatement: r.Statement = elem(tg, 1) case TagDleq: r.Dleq = &Dleq{E: elem(tg, 1), S: elem(tg, 2)} default: r.Extra = appendTag(r.Extra, tg) } } return } // IsPaid reports whether the report carries a host proof at all. func (r *PlayReport) IsPaid() (ok bool) { return r.Blind != "" || r.Host != "" || r.Statement != "" || r.Dleq != nil } func (r *PlayReport) Validate() (err error) { if r == nil { err = errorf.E("nil play report") return } if r.Asset == nil { err = errorf.E("play report: no asset address") return } if r.Asset.Kind != KindTrack { err = errorf.E("play report: asset is kind %d, want %d", r.Asset.Kind, KindTrack) return } if r.Blob != "" && !IsHex64(r.Blob) { err = errorf.E("play report: blob %q is not 64 hex characters", r.Blob) return } if r.Artist != "" && !IsHex64(r.Artist) { err = errorf.E("play report: artist pubkey is not 64 hex characters") return } if r.Host != "" && !IsHex64(r.Host) { err = errorf.E("play report: host pubkey is not 64 hex characters") return } // A paid play is all of the four fields or none: a blind signature without // its DLEQ proof cannot be verified by anyone but the host. if r.IsPaid() { if r.Blind == "" || r.Host == "" || r.Statement == "" || r.Dleq == nil { err = errorf.E("play report: paid play needs blind, host, statement and dleq") return } } if r.Codec != "" && !IsCodecSpec(r.Codec) { err = errorf.E("play report: codec %q is not a codec spec", r.Codec) return } return } // PlayStatement is the canonical string a host blind-signs, per PROTOCOL_FLOWS // R2 and the applied decision G-3: the fields are joined by '|' and no field // may contain one. It covers only what the host itself observed - asset, blob // and window - because a host that blind-signs an arbitrary statement is a // signing oracle, and money is accounted by receipts and aggregates instead. func PlayStatement(assetAddress, blob, start, end string) (s string) { return "musiquay.play:v1|" | assetAddress | "|" | blob | "|" | start | "|" | end } // --- shared parse helpers --- func elem(tag []string, i int32) (s string) { if i < 0 || i >= int32(len(tag)) { return "" } return tag[i] } func parseParticipant(tag []string) (p Participant, err error) { if len(tag) < 2 { err = errorf.E("p tag needs a pubkey") return } p.Pubkey = tag[1] if len(tag) > 2 { p.Relay = tag[2] } if len(tag) > 3 { p.Role = tag[3] } return } func parsePrice(tag []string) (p *Price) { if len(tag) < 3 { return nil } p = &Price{Amount: tag[1], Unit: tag[2]} if len(tag) > 3 { p.Period = tag[3] } return } func parseBlobRef(tag []string) (b *BlobRef) { if len(tag) < 2 { return nil } b = &BlobRef{Sha256: tag[1]} if len(tag) > 2 { b.Server = tag[2] } return } func parseEncoding(tag []string) (e Encoding) { e.Sha256 = elem(tag, 1) e.Spec = elem(tag, 2) e.Server = elem(tag, 3) e.MIME = elem(tag, 4) if len(tag) > 5 { if n, err := ParseInt64(tag[5]); err == nil { e.Size = n e.HasSize = true } } return } func encodingHas(encs []Encoding, hash string) (ok bool) { for i := range encs { if encs[i].Sha256 == hash { return true } } return false } // pushExternalI records an `i` value against the last external id, so an i/k // pair is one entry however the tags are ordered. func pushExternalI(xs []ExternalID, i string) (out []ExternalID) { out = xs out = push(out, ExternalID{I: i}) return } func pushExternalK(xs []ExternalID, k string) (out []ExternalID) { out = xs if len(out) > 0 && out[len(out)-1].K == "" { out[len(out)-1].K = k return } out = push(out, ExternalID{K: k}) return } func appendKV(dst [][]string, k, v string) (out [][]string) { out = dst if v == "" { return } t := []string{:2} t[0] = k t[1] = v out = push(out, t) return } func appendTag(dst [][]string, t []string) (out [][]string) { out = dst if len(t) == 0 { return } out = push(out, t) return } func appendTags(dst, src [][]string) (out [][]string) { out = dst for i := range src { out = appendTag(out, src[i]) } return }