diff options
| -rw-r--r-- | api.go | 27 | ||||
| -rw-r--r-- | main.go | 2 | ||||
| -rw-r--r-- | official-api.go | 54 | ||||
| -rw-r--r-- | static/index.js | 191 | ||||
| -rw-r--r-- | templates/watch.html | 18 | ||||
| -rw-r--r-- | util.go | 10 |
6 files changed, 165 insertions, 137 deletions
@@ -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 { @@ -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 => 👍 <span data-f="likes"></span> => <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> @@ -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 +} |
