Live video on the AT Protocol
1package linking
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "net/url"
9
10 "golang.org/x/net/html"
11 "stream.place/streamplace/pkg/streamplace"
12)
13
14type Linker struct {
15 BaseHTML []byte
16}
17
18func NewLinker(ctx context.Context, baseHTML []byte) (*Linker, error) {
19 _, err := html.Parse(bytes.NewReader(baseHTML))
20 if err != nil {
21 return nil, err
22 }
23
24 return &Linker{BaseHTML: baseHTML}, nil
25}
26
27type PageConfig struct {
28 Title string
29 Metas []MetaTag
30 SentryDSN string
31}
32
33// Define all meta tags in a structured way
34type MetaTag struct {
35 Type string // "name" or "property"
36 Key string
37 Content string
38}
39
40func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *streamplace.Livestream_LivestreamView, sentryDSN string) ([]byte, error) {
41 if u == nil {
42 return nil, errors.New("url is nil")
43 }
44 if lsv == nil {
45 return nil, errors.New("livestream view is nil")
46 }
47 ls, ok := lsv.Record.Val.(*streamplace.Livestream)
48 if !ok {
49 return nil, errors.New("livestream view is not a livestream")
50 }
51
52 titleStr := fmt.Sprintf("@%s's livestream on %s", lsv.Author.Handle, u.Host)
53 outURL := u.String()
54
55 pageTitle := fmt.Sprintf("@%s | %s", lsv.Author.Handle, u.Host)
56
57 thumbURL, _ := url.Parse(u.String())
58 thumbURL.Path = "/xrpc/place.stream.live.getProfileCard"
59 thumbURL.RawQuery = fmt.Sprintf("id=%s", lsv.Author.Did)
60
61 // Define all meta tags
62 metaTags := []MetaTag{
63 // Basic meta
64 {Type: "name", Key: "description", Content: ls.Title},
65
66 // Facebook Meta Tags
67 {Type: "property", Key: "og:url", Content: u.String()},
68 {Type: "property", Key: "og:type", Content: "website"},
69 {Type: "property", Key: "og:title", Content: titleStr},
70 {Type: "property", Key: "og:description", Content: ls.Title},
71 {Type: "property", Key: "og:image", Content: thumbURL.String()},
72
73 // Twitter Meta Tags
74 {Type: "name", Key: "twitter:card", Content: "summary_large_image"},
75 {Type: "property", Key: "twitter:domain", Content: u.Host},
76 {Type: "property", Key: "twitter:url", Content: outURL},
77 {Type: "name", Key: "twitter:title", Content: titleStr},
78 {Type: "name", Key: "twitter:description", Content: ls.Title},
79 {Type: "name", Key: "twitter:image", Content: thumbURL.String()},
80 }
81
82 return l.GenerateHTML(ctx, &PageConfig{
83 Title: pageTitle,
84 Metas: metaTags,
85 SentryDSN: sentryDSN,
86 })
87}
88
89func (l *Linker) GenerateDefaultCard(ctx context.Context, u *url.URL, sentryDSN string) ([]byte, error) {
90 if u == nil {
91 return nil, errors.New("url is nil")
92 }
93
94 thumbURL, _ := url.Parse(u.String())
95 thumbURL.Path = "/linkbanner.png"
96
97 // Define all meta tags
98 metaTags := []MetaTag{
99 // Basic meta
100 {Type: "name", Key: "description", Content: "Stream.place is open-source livestreaming on the AT Protocol."},
101
102 // Facebook Meta Tags
103 {Type: "property", Key: "og:url", Content: u.String()},
104 {Type: "property", Key: "og:type", Content: "website"},
105 {Type: "property", Key: "og:title", Content: "Stream.place"},
106 {Type: "property", Key: "og:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."},
107 {Type: "property", Key: "og:image", Content: thumbURL.String()},
108
109 // Twitter Meta Tags
110 {Type: "name", Key: "twitter:card", Content: "summary_large_image"},
111 {Type: "property", Key: "twitter:domain", Content: u.Host},
112 {Type: "property", Key: "twitter:url", Content: u.String()},
113 {Type: "name", Key: "twitter:title", Content: "Stream.place"},
114 {Type: "name", Key: "twitter:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."},
115 {Type: "name", Key: "twitter:image", Content: thumbURL.String()},
116 }
117
118 return l.GenerateHTML(ctx, &PageConfig{
119 Title: "Stream.place",
120 Metas: metaTags,
121 SentryDSN: sentryDSN,
122 })
123}
124
125func (l *Linker) GenerateHTML(ctx context.Context, pc *PageConfig) ([]byte, error) {
126
127 root, err := html.Parse(bytes.NewReader(l.BaseHTML))
128 if err != nil {
129 return nil, err
130 }
131
132 var htmlNode *html.Node
133 for node := range root.ChildNodes() {
134 if node.Type == html.ElementNode && node.Data == "html" {
135 htmlNode = node
136 break
137 }
138 }
139 if htmlNode == nil {
140 return nil, errors.New("html not found")
141 }
142
143 var head *html.Node
144 for node := range htmlNode.ChildNodes() {
145 if node.Data == "head" {
146 head = node
147 break
148 }
149 }
150 if head == nil {
151 return nil, errors.New("head not found")
152 }
153
154 // Title tag (handled separately as it's not a meta tag)
155
156 var oldTitle *html.Node
157 for node := range head.ChildNodes() {
158 if node.Type == html.ElementNode && node.Data == "title" {
159 oldTitle = node
160 break
161 }
162 }
163 if oldTitle != nil {
164 head.RemoveChild(oldTitle)
165 }
166
167 title := &html.Node{
168 Type: html.ElementNode,
169 Data: "title",
170 }
171 head.AppendChild(title)
172 title.AppendChild(&html.Node{
173 Type: html.TextNode,
174 Data: pc.Title,
175 })
176
177 // Add all meta tags in a loop
178 for _, tag := range pc.Metas {
179 head.AppendChild(&html.Node{
180 Type: html.ElementNode,
181 Data: "meta",
182 Attr: []html.Attribute{
183 {Key: tag.Type, Val: tag.Key},
184 {Key: "content", Val: tag.Content},
185 },
186 })
187 }
188
189 // Add Sentry DSN script if configured
190 if pc.SentryDSN != "" {
191 script := &html.Node{
192 Type: html.ElementNode,
193 Data: "script",
194 }
195 head.AppendChild(script)
196 script.AppendChild(&html.Node{
197 Type: html.TextNode,
198 Data: `window.SENTRY_DSN = "` + pc.SentryDSN + `";`,
199 })
200 }
201
202 // Render the HTML to a string
203 var buf bytes.Buffer
204 if err := html.Render(&buf, root); err != nil {
205 return nil, err
206 }
207
208 return buf.Bytes(), nil
209}