aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTim Keller <tjk@tjkeller.xyz>2026-05-17 00:29:39 -0500
committerTim Keller <tjk@tjkeller.xyz>2026-05-17 00:29:39 -0500
commitd4f8fc15b7e6a2204e8dda92a083684d93a5fa59 (patch)
tree74196a63e31a244170c2e35863e655af8b06d008
parent1316aa7ca5e1668bbb7967264540bff3c8dbef86 (diff)
downloadembedtube-d4f8fc15b7e6a2204e8dda92a083684d93a5fa59.tar.xz
embedtube-d4f8fc15b7e6a2204e8dda92a083684d93a5fa59.zip
refactor some and client side support for new api + cleanup client code and fix some server bugs
-rw-r--r--api.go27
-rw-r--r--main.go2
-rw-r--r--official-api.go54
-rw-r--r--static/index.js191
-rw-r--r--templates/watch.html18
-rw-r--r--util.go10
6 files changed, 165 insertions, 137 deletions
diff --git a/api.go b/api.go
index 21a2b5f..f6e0044 100644
--- a/api.go
+++ b/api.go
@@ -8,22 +8,23 @@ import (
)
type VideoDetails struct {
- Channel string `json:"channel"`
- DatePublished time.Time `json:"datePublished"`
- Description string `json:"description"`
- NumComments int `json:"comments"`
- NumLikes int `json:"likes"`
- NumViews int `json:"views"`
- Tags []string `json:"tags"`
+ Author string `json:"author"`
+ Channel string `json:"channel"`
+ Published time.Time `json:"published"`
+ Description string `json:"description"`
+ Comments int `json:"comments"` // nunber of comments
+ Likes int `json:"likes"`
+ Views int `json:"views"`
+ Tags []string `json:"tags"`
}
type Comment struct {
- Author string `json:"a"`
- Body string `json:"b"`
- DatePublished time.Time `json:"p"`
- DateUpdated time.Time `json:"u"`
- Likes int `json:"l"`
- Replies []*Comment `json:"r,omitempty"`
+ Author string `json:"author"`
+ Body string `json:"body"`
+ Published time.Time `json:"published"`
+ Updated time.Time `json:"updated,omitempty"`
+ Likes int `json:"likes"`
+ Replies []*Comment `json:"replies,omitempty"`
}
type APISource interface {
diff --git a/main.go b/main.go
index 2f39712..3c6b79a 100644
--- a/main.go
+++ b/main.go
@@ -18,7 +18,7 @@ func rootHandler(w http.ResponseWriter, r *http.Request) {
//http.ServeFile(w, r, "static/index.html")
renderIndexTemplate(w)
} else {
- renderWatchTemplate(w, path)
+ renderWatchTemplate(w, path) // FIXME just redirect this one to /watch for simplicity
}
}
diff --git a/official-api.go b/official-api.go
index 39db1b8..f44a4b9 100644
--- a/official-api.go
+++ b/official-api.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "strconv"
"time"
)
@@ -40,15 +41,16 @@ func apiRequest[T any](videoId string, endpoint string, videoIdParam string, par
type ytVideoDetails struct {
Items []struct {
Snippet struct {
- Channel string `json:"channel"`
- Description string `json:"description"`
- PublishedAt time.Time `json:"publishedAt"`
- Tags []string `json:"tags"`
+ ChannelTitle string `json:"channelTitle"`
+ ChannelId string `json:"channelId"`
+ Description string `json:"description"`
+ PublishedAt time.Time `json:"publishedAt"`
+ Tags []string `json:"tags"`
} `json:"snippet"`
Statistics struct {
- CommentCount int `json:"commentCount"`
- LikeCount int `json:"likeCount"`
- ViewCount int `json:"viewCount"`
+ CommentCount string `json:"commentCount"`
+ LikeCount string `json:"likeCount"`
+ ViewCount string `json:"viewCount"`
} `json:"statistics"`
} `json:"items"`
}
@@ -78,18 +80,28 @@ type ytComments struct {
type APISourceOfficial struct {}
func (a *APISourceOfficial) getDetails(videoId string) (VideoDetails, error) {
+ var zero VideoDetails
d, err := apiRequest[ytVideoDetails](videoId, "videos", "id", "snippet,statistics,topicDetails")
+ if err != nil {
+ return zero, err
+ }
+
+ if len(d.Items) == 0 {
+ return zero, fmt.Errorf("YouTube API returned no video details")
+ }
+
n := d.Items[0].Snippet
t := d.Items[0].Statistics
return VideoDetails{
- Channel: n.Channel,
- DatePublished: n.PublishedAt,
- Description: n.Description,
- NumComments: t.CommentCount,
- NumLikes: t.LikeCount,
- NumViews: t.ViewCount,
- Tags: n.Tags,
+ Author: n.ChannelTitle,
+ Channel: n.ChannelId,
+ Published: n.PublishedAt,
+ Description: n.Description,
+ Comments: AtoiOrZero(t.CommentCount),
+ Likes: AtoiOrZero(t.LikeCount),
+ Views: AtoiOrZero(t.ViewCount),
+ Tags: n.Tags,
}, err
}
@@ -102,18 +114,18 @@ func genComment(c ytComment, r []ytComment) Comment {
}
return Comment{
- Author: n.AuthorDisplayName,
- Body: n.TextDisplay,
- DatePublished: n.PublishedAt,
- DateUpdated: n.UpdatedAt,
- Likes: n.LikeCount,
- Replies: replies,
+ Author: n.AuthorDisplayName,
+ Body: n.TextDisplay,
+ Published: n.PublishedAt,
+ Updated: n.UpdatedAt,
+ Likes: n.LikeCount,
+ Replies: replies,
}
}
func (a *APISourceOfficial) getComments(videoId string) ([]Comment, error) {
const maxResults = 100
- d, err := apiRequest[ytComments](videoId, "commentThreads", "videoId", "snippet,replies&maxResults=" + fmt.Sprint(maxResults)) // TODO configure max results
+ d, err := apiRequest[ytComments](videoId, "commentThreads", "videoId", "snippet,replies&maxResults=" + strconv.Itoa(maxResults)) // TODO configure max results
var comments []Comment
for _, c := range d.Items {
diff --git a/static/index.js b/static/index.js
index c577050..c753e3f 100644
--- a/static/index.js
+++ b/static/index.js
@@ -1,7 +1,18 @@
-/* setup player */
-let player
+/* static elements */
const titleBar = document.getElementById("title")
+const buttonDetails = document.getElementById("toggle-details")
+const divDetails = document.getElementById("details")
+const buttonComments = document.getElementById("toggle-comments")
+const divComments = document.getElementById("comments")
+const commentTemplate = document.getElementById("template-comment")
+
+/* state */
+let player
+let detailsLoaded = false
+let commentsLoaded = false
+
+/* setup player */
function onPlayerReady(_e) {
document.title = titleBar.textContent = player.videoTitle
}
@@ -15,28 +26,25 @@ function onYouTubeIframeAPIReady() {
})
}
+
/* helpers */
async function getApiReq(path) {
return fetch("/api" + path)
.then(async res => {
- if (!res.ok) {
- const err = await res.text();
- throw new Error(`Server error: ${err}`);
- }
+ if (!res.ok)
+ throw new Error("Server error:", await res.text())
return res.json()
})
- .then(data => {
- return data
- })
- .catch(error => {
- console.error("Fetch error:", error)
- })
+ .catch(e => console.error(e))
}
function toggleVis(el) {
- el.style.display = el.style.display ? null : "none"
+ const v = !!el.style.display
+ el.style.display = v ? null : "none"
+ return v
}
+// TODO this func could be moved to server
function createAnchors(text) {
function _a(href, innerText) {
return `<a href="${href}" rel="noopener noreferrer">${innerText ?? href}</a>`
@@ -47,9 +55,10 @@ function createAnchors(text) {
const timestampRegex = /[0-9]+:[0-9]+/g
return text
+ .replaceAll("\n", "<br>")
.replace(urlRegex, url => {
- const ytRegex = /^(https?:\/\/)?(www\.)?(youtu(be\.com|\.be|be-nocookie\.com))\//gi
- const href = url.replace(ytRegex, `${window.location.origin}/`)
+ const ytWatchRegex = /^(https?:\/\/)?(www\.)?(youtu(be\.com|\.be|be-nocookie\.com))\/watch/gi
+ const href = url.replace(ytWatchRegex, `${window.location.origin}/watch`)
return _a(href, url)
})
.replace(channelRegex, channel => {
@@ -61,113 +70,108 @@ function createAnchors(text) {
})
}
-/* setup video details buttons */
-const buttonDetails = document.getElementById("toggle-details")
-const divDetails = document.getElementById("details")
-let detailsLoaded = false
+function fillFields(data, fieldContainer) {
+ // see html if this doesn't make sense
+ const fieldElements = fieldContainer.querySelectorAll("[data-f]")
+ const fieldElementsMap = {}
+ for (const fe of fieldElements) {
+ const f = fe.dataset.f
+ fieldElementsMap[f] = fe
+ if (fe.dataset.skip !== undefined)
+ continue
+
+ const v = data[f]
+ switch (fe.tagName) {
+ case "DIV":
+ fe.innerHTML = fe.dataset.anchor !== undefined ? createAnchors(v) : v
+ break
+ case "SPAN": // only used for stats
+ fe.textContent = new Intl.NumberFormat().format(v)
+ break
+ case "TIME":
+ fe.textContent = fe.datetime = v
+ break
+ case "A": // only used for channel links
+ fe.href = `https://www.youtube.com/${v}`
+ default:
+ fe.textContent = v
+ }
+ }
+ return fieldElementsMap
+}
+
+/* setup video details */
async function toggleDetails() {
if (!player)
- return
- if (detailsLoaded) {
- toggleVis(divDetails)
- return
- }
- // elements
- const channel = document.getElementById("details-channel")
- const likes = document.getElementById("details-likes")
- const views = document.getElementById("details-views")
- const date = document.getElementById("details-date")
- const desc = document.getElementById("details-desc")
- const tags = document.getElementById("details-tags")
- const comments = document.getElementById("details-comments")
-
- // req
- const d = (await getApiReq(`/details?id=${player.playerInfo.videoData.video_id}`)).items[0]
-
- // populate
- channel.textContent = d.snippet.channelTitle
- channel.href = `https://www.youtube.com/channel/${d.snippet.channelId}`
- views.textContent = new Intl.NumberFormat().format(d.statistics.viewCount)
- likes.textContent = new Intl.NumberFormat().format(d.statistics.likeCount)
- comments.textContent = new Intl.NumberFormat().format(d.statistics.commentCount)
- date.textContent = d.snippet.publishedAt
- if (d.snippet.description)
- desc.innerHTML = createAnchors(d.snippet.description.replaceAll("\n", "<br>"))
- tags.textContent = d.snippet.tags.join(", ")
+ return false
+ if (detailsLoaded)
+ return toggleVis(divDetails)
+
+
+ // data
+ const data = await getApiReq(`/details?id=${player.playerInfo.videoData.video_id}`)
+
+ // pop
+ const fieldElements = fillFields(data, divDetails)
+ fieldElements.author.href = `https://www.youtube.com/channel/${data.channel}`
+ fieldElements.tags.textContent = data.tags.join(", ")
// done
detailsLoaded = true
- toggleVis(divDetails)
+ return toggleVis(divDetails)
}
-/* setup video comments buttons */
-const buttonComments = document.getElementById("toggle-comments")
-const divComments = document.getElementById("comments")
-const commentTemplate = document.getElementById("template-comment")
-let commentsLoaded = false
-
-function genComment(cd, parent, replies) {
- const c = commentTemplate.content.cloneNode(true)
-
- const author = c.querySelector(".author")
- const date = c.querySelector(".date")
- const dateM = c.querySelector(".date.modified")
- const body = c.querySelector(".body")
- const likes = c.querySelector(".likes")
-
- author.textContent = cd.authorDisplayName
- author.href = cd.authorChannelUrl
- date.textContent = cd.publishedAt
- if (cd.publishedAt === cd.updatedAt)
- dateM.remove()
- else
- dateM.textContent = `(Edited ${cd.updatedAt})`
- body.innerHTML = cd.textDisplay
- likes.textContent = new Intl.NumberFormat().format(cd.likeCount)
-
- if (replies) {
- const divReplies = c.querySelector(".replies")
- for (const reply of replies)
- genComment(reply.snippet, divReplies)
- }
- parent.append(c)
-}
+/* setup video comments */
async function toggleComments() {
if (!player)
- return
- if (commentsLoaded) {
- toggleVis(divComments)
- return
+ return false
+ if (commentsLoaded)
+ return toggleVis(divComments)
+
+ // data
+ const data = await getApiReq(`/comments?id=${player.playerInfo.videoData.video_id }`)
+
+ // pop
+ function _genComment(commentData, parent) {
+ const c = commentTemplate.content.cloneNode(true)
+
+ const fieldElements = fillFields(commentData, c)
+ if (commentData.published === commentData.updated)
+ fieldElements.updated.parentElement.remove()
+
+ if (commentData.replies) {
+ const divReplies = c.querySelector(".replies")
+ for (const rc of commentData.replies)
+ _genComment(rc, divReplies)
+ }
+ parent.append(c)
}
- // req
- const d = (await getApiReq(`/comments?id=${player.playerInfo.videoData.video_id }`))
-
- // populate
- for (const cdd of d.items) {
- const cd = cdd.snippet.topLevelComment.snippet
- const replies = cdd.replies ? cdd.replies.comments : null
- genComment(cd, divComments, replies)
- }
+ for (const commentData of data)
+ _genComment(commentData, divComments)
// done
commentsLoaded = true
- toggleVis(divComments)
+ return toggleVis(divComments)
}
+
+/* details button */
buttonDetails.addEventListener("click", () => {
toggleDetails()
toggleComments()
buttonDetails.remove()
})
+
/* timestamps in comments */
function timestampLinkClick(e) {
if (e.target.tagName !== "A")
return
+ // FIXME this needs to check origin as well
const url = new URL(e.target.href)
if (url.pathname !== "/watch" || url.searchParams.get("v") !== player.playerInfo.videoData.video_id)
return
@@ -180,6 +184,7 @@ function timestampLinkClick(e) {
player.seekTo(t)
// change url to match window in case clicking on youtube.com link
+ // FIXME get ridded of replace youtube links w/ host links
url.protocol = window.location.protocol
url.hostname = window.location.hostname
url.port = window.location.port
diff --git a/templates/watch.html b/templates/watch.html
index 07600de..9849bba 100644
--- a/templates/watch.html
+++ b/templates/watch.html
@@ -17,22 +17,22 @@
<h1 id="title"></h1>
<button id="toggle-details">Show Details</button>
<div id="details" style="display:none">
- <span><a id="details-channel"></a> @ <span id="details-date"></span></span><br>
- <span><span id="details-views"></span> Views => 👍 <span id="details-likes"></span> => <span id="details-comments"></span> Comments</span><br><br>
- <div id="details-desc">No description has been added to this video.</div>
+ <span><a data-f="author"></a> @ <time data-f="published"></time></span><br>
+ <span><span data-f="views"></span> Views =&gt; 👍 <span data-f="likes"></span> =&gt; <span data-f="comments"></span> Comments</span><br><br>
+ <div data-f="description" data-anchor>No description has been added to this video.</div>
<h4>Tags:</h4>
- <div id="details-tags"></div>
+ <div data-f="tags" data-skip></div>
</div>
<div id="comments" style="display:none">
<h3>Comments:</h3>
<template id="template-comment">
<hr>
<div class="comment">
- <a class="author"></a>
- <span>@ <span class="date"></span></span>
- <span class="date modified"></span>
- <div class="body"></div>
- <span>👍 <span class="likes"></span></span>
+ <a data-f="author"></a>
+ <span>@ <time data-f="published"></time></span>
+ <span>(Edited <time data-f="updated"></time>)</span>
+ <div data-f="body"></div>
+ <span>👍 <span data-f="likes"></span></span>
<div class="replies"></div>
</div>
</template>
diff --git a/util.go b/util.go
index 6381bff..55077b8 100644
--- a/util.go
+++ b/util.go
@@ -5,6 +5,7 @@ import (
"html/template"
"log"
"net/http"
+ "strconv"
)
func reloadTemplate(t **template.Template, files ...string) error {
@@ -23,3 +24,12 @@ func templateError(err error, w http.ResponseWriter) {
http.Error(w, msg, http.StatusInternalServerError)
log.Println(msg)
}
+
+
+func AtoiOrZero(s string) int {
+ i, err := strconv.Atoi(s)
+ if err != nil {
+ return 0
+ }
+ return i
+}