diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | go.mod | 5 | ||||
-rw-r--r-- | go.sum | 2 | ||||
-rw-r--r-- | main.go | 96 | ||||
-rw-r--r-- | static/index.js | 175 | ||||
-rw-r--r-- | static/style.css | 67 | ||||
-rw-r--r-- | template.html | 48 |
7 files changed, 394 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env @@ -0,0 +1,5 @@ +module tjkeller.xyz/mintube + +go 1.23.7 + +require github.com/joho/godotenv v1.5.1 @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= @@ -0,0 +1,96 @@ +package main + +import ( + "fmt" + "html/template" + "io" + "log" + "net/http" + "os" + "strings" + "github.com/joho/godotenv" +) + +const youtubeAPI = "https://www.googleapis.com/youtube/v3/" +var tmpl = template.Must(template.ParseFiles("template.html")) + +func apiRequest(w http.ResponseWriter, r *http.Request, endpoint string, videoIdParam string, part string) { + videoID := r.URL.Query().Get("id") + if videoID == "" { + msg := "Missing ?id=VIDEO_ID parameter" + http.Error(w, msg, http.StatusBadRequest) + return + } + + apiKey := os.Getenv("API_KEY") + if apiKey == "" { + msg := "API_KEY environment variable not set" + http.Error(w, msg, http.StatusInternalServerError) + log.Println(msg) + return + } + + //url := fmt.Sprintf("%s?part=snippet&id=%s&key=%s", youtubeAPI, videoID, apiKey) + url := fmt.Sprintf("%s?part=%s&%s=%s&key=%s", youtubeAPI + endpoint, part, videoIdParam, videoID, apiKey) + resp, err := http.Get(url) + if err != nil { + msg := "Failed to fetch video info: " + err.Error() + http.Error(w, msg, http.StatusInternalServerError) + log.Println(msg) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + msg := "YouTube API error: " + resp.Status + http.Error(w, msg, http.StatusBadGateway) + log.Println(msg) + return + } + + w.Header().Set("Content-Type", "application/json") + io.Copy(w, resp.Body) +} + +func videoDetailsHandler(w http.ResponseWriter, r *http.Request) { + apiRequest(w, r, "videos", "id", "snippet,statistics,topicDetails") +} + +func commentThreadsHandler(w http.ResponseWriter, r *http.Request) { + apiRequest(w, r, "commentThreads", "videoId", "snippet,replies&maxResults=100") +} + +func handler(w http.ResponseWriter, r *http.Request) { + path := strings.Trim(r.URL.Path, "/") + if path == "" { + path = "world" + } + data := struct { + Id string + }{ + Id: path, + } + err := tmpl.Execute(w, data) + if err != nil { + msg := "Template execution error" + http.Error(w, msg, http.StatusInternalServerError) + log.Println(msg) + } +} + +func main() { + // load .env file if it exists + godotenv.Load() + + // setup routes + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + http.HandleFunc("/api/details", videoDetailsHandler) + http.HandleFunc("/api/comments", commentThreadsHandler) + + http.HandleFunc("/", handler) + log.Println("Listening on http://localhost:8080") + err := http.ListenAndServe(":8080", nil) + if err != nil { + log.Fatal(err) + } +} diff --git a/static/index.js b/static/index.js new file mode 100644 index 0000000..e1c1f41 --- /dev/null +++ b/static/index.js @@ -0,0 +1,175 @@ +/* setup player */ +let player +const titleBar = document.getElementById("title") + +function onPlayerReady(event) { + document.title = titleBar.textContent = player.videoTitle +} + +function onYouTubeIframeAPIReady() { + player = new YT.Player("player", { + host: "https://www.youtube-nocookie.com", + events: { + "onReady": onPlayerReady, + }, + }) +} + +/* helpers */ +function getApiReq(path) { + return fetch("/api" + path) + .then(async res => { + if (!res.ok) { + const err = await res.text(); + throw new Error(`Server error: ${err}`); + } + return res.json() + }) + .then(data => { + return data + }) + .catch(error => { + console.error("Fetch error:", error) + }) +} + +function toggleVis(e) { + e.style.display = e.style.display ? null : "none" + //window.location.hash = e.id && !e.style.display ? e.id : "" +} + +function createAnchors(text) { + const urlRegex = /(https?:\/\/[a-zA-Z0-9#$%&-./=?@_~]+[a-zA-Z0-9/]|@[a-zA-Z0-9.-_]+|[0-9]+:[0-9]+)/gi; + return text.replace(urlRegex, url => { + let href = url + if (url[0] == "@") { + href = `https://youtube.com/${url}` + } else if (parseInt(url[0]) !== NaN) { + const ts = url.split(":").reverse() + let t = 0 + for (let i = 0; i < ts.length; i++) + t += ts[i] * Math.pow(60, i) + href = `https://youtube.com/watch?v=${player.playerInfo.videoData.video_id}&t=${t}` + } + return `<a href="${href}" target="_blank" rel="noopener noreferrer">${url}</a>` + }) +} + +/* setup video details buttons */ +const buttonDetails = document.getElementById("toggle-details") +const divDetails = document.getElementById("details") +let detailsLoaded = false + +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") + + // 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) + date.textContent = d.snippet.publishedAt + if (d.snippet.description) + desc.innerHTML = createAnchors(d.snippet.description.replaceAll("\n", "<br>")) + tags.textContent = d.snippet.tags.join(", ") + + // done + detailsLoaded = true + 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 = `(Modified ${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) +} + +async function toggleComments() { + if (!player) + return + if (commentsLoaded) { + toggleVis(divComments) + return + } + + // 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) + } + + // done + commentsLoaded = true + toggleVis(divComments) +} + +buttonDetails.addEventListener("click", () => { + toggleDetails() + toggleComments() + buttonDetails.remove() +}) + +/* timestamps in comments */ +function timestampLinkClick(e) { + if (e.target.tagName !== "A") + return + + const url = new URL(e.target.href) + if (url.pathname !== "/watch" || url.searchParams.get("v") !== player.playerInfo.videoData.video_id) + return + + e.preventDefault() + + const t = url.searchParams.get("t") + if (!t) + return + player.seekTo(t) +} + +document.addEventListener("click", timestampLinkClick) diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..d9e461f --- /dev/null +++ b/static/style.css @@ -0,0 +1,67 @@ +html { + background: #000; + color: #fff; +} + +body { + margin: 0; +} + +body, pre { + font-family: "Roboto", sans-serif; +} + +#player { + display: block; + width: 100vw; + height: 90vh; + max-height: 80vw; + margin: 1rem auto; + box-sizing: border-box; +} + +#title { + margin: .5rem; +} + +button { + margin: .5rem; + background: #222; + color: #fff; + cursor: pointer; + border: 0; + border-radius: .25rem; + padding: .5rem; +} + +#details, #comments { + padding: 0 1rem; +} + +.replies { + margin-left: 2rem; +} + +a { + color: lightblue; +} + +.comment { + background: #191919; + border-radius: .25rem; + margin: .5rem 0; + padding: .5rem; +} +.comment .author { + text-decoration: none; +} +.comment .date { + color: #aaa; + font-size-adjust: .4; +} +.comment .body { + margin-left: .5rem; +} +.replies .comment { + background: #ffffff0a +} diff --git a/template.html b/template.html new file mode 100644 index 0000000..bae485d --- /dev/null +++ b/template.html @@ -0,0 +1,48 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> + <link rel="stylesheet" href="/static/style.css"> + <script src="/static/index.js" defer></script> + <script src="https://www.youtube.com/iframe_api"></script> +</head> +<body> + <iframe + id="player" + src="https://www.youtube-nocookie.com/embed/{{ .Id }}?enablejsapi=1&autoplay=1" + frameborder="0" + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" + referrerpolicy="no-referrer" + allowfullscreen + > + </iframe> + <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-likes"></span></span> + <br> + <span><span id="details-views"></span> Views   <span id="details-date"></span></span> + <br> + <h3>Description:</h3> + <span id="details-desc">No description has been added to this video.</span> + <br> + <h4>Tags:</h4> + <div id="details-tags"></div> + </div> + <br> + <div id="comments" style="display:none"> + <h3>Comments:</h3> + <template id="template-comment"> + <div class="comment"> + <a class="author"></a> + <span class="date"></span> + <span class="date modified"></span> + <div class="body"></div> + <span>👍 <span class="likes"></span></span> + <div class="replies"></div> + </div> + </template> + </div> +</body> +</html> |