diff options
| author | Tim Keller <tjkeller.xyz> | 2025-05-23 21:46:20 -0500 | 
|---|---|---|
| committer | Tim Keller <tjkeller.xyz> | 2025-05-23 21:46:20 -0500 | 
| commit | 865911f8ffdac7d1d9773570216c0bd35fc601d9 (patch) | |
| tree | 2c29ff9cfe78d95e095a39163ade4e83e4d56b92 | |
| download | mintube-865911f8ffdac7d1d9773570216c0bd35fc601d9.tar.xz mintube-865911f8ffdac7d1d9773570216c0bd35fc601d9.zip  | |
initial commit
| -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>  | 
