diff options
-rw-r--r-- | Dockerfile | 1 | ||||
-rw-r--r-- | api.go | 58 | ||||
-rw-r--r-- | docker-compose.yaml | 2 | ||||
-rw-r--r-- | go.mod | 5 | ||||
-rw-r--r-- | go.sum | 2 | ||||
-rw-r--r-- | home.go | 43 | ||||
-rw-r--r-- | main.go | 90 | ||||
-rw-r--r-- | templates/base.html | 12 | ||||
-rw-r--r-- | templates/index.html | 3 | ||||
-rw-r--r-- | templates/watch.html | 89 | ||||
-rw-r--r-- | util.go | 25 | ||||
-rw-r--r-- | watch.go | 34 |
12 files changed, 243 insertions, 121 deletions
@@ -18,6 +18,7 @@ FROM alpine:latest WORKDIR /app COPY --from=builder /app/mintube . +COPY --from=builder /app/README.md . COPY --from=builder /app/templates ./templates COPY --from=builder /app/static ./static @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" +) + +const youtubeAPI = "https://www.googleapis.com/youtube/v3/" + +/* function to make an api request */ +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 + } + + if apiKey == "" { + msg := "API_KEY environment variable not set" + http.Error(w, msg, http.StatusInternalServerError) + log.Println(msg) + return + } + + 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) +} + +/* routes */ +func setupRoutesAPI() { + http.HandleFunc("/api/details", func(w http.ResponseWriter, r *http.Request) { + apiRequest(w, r, "videos", "id", "snippet,statistics,topicDetails") + }) + + http.HandleFunc("/api/comments", func(w http.ResponseWriter, r *http.Request) { + apiRequest(w, r, "commentThreads", "videoId", "snippet,replies&maxResults=100") + }) +} diff --git a/docker-compose.yaml b/docker-compose.yaml index bd98f85..3ad6b28 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -6,4 +6,4 @@ services: - 7008:8080 # TOOB # Add api key here, or in .env file #environment: - # - API_KEY: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + # API_KEY: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA @@ -2,4 +2,7 @@ module tjkeller.xyz/mintube go 1.23.7 -require github.com/joho/godotenv v1.5.1 +require ( + github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b + github.com/joho/godotenv v1.5.1 +) @@ -1,2 +1,4 @@ +github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk= +github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= 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,43 @@ +package main + +import ( + "html/template" + "io/ioutil" + "log" + "net/http" + "github.com/gomarkdown/markdown" +) + +var templateIndexFiles = []string{ "templates/base.html", "templates/index.html" } +var templateIndex = template.Must(template.ParseFiles(templateIndexFiles...)) +var contentHTML template.HTML // just display README.md + +type IndexTemplateData struct { + Content template.HTML +} + +func loadReadme() { + mdBytes, err := ioutil.ReadFile("README.md") + if err != nil { + log.Fatalf("Failed to read markdown file: %v", err) + } + htmlBytes := markdown.ToHTML(mdBytes, nil, nil) + contentHTML = template.HTML(htmlBytes) +} + +func renderIndexTemplate(w http.ResponseWriter) { + if debug { + reloadTemplate(&templateIndex, templateIndexFiles...) + loadReadme() + } + err := templateIndex.Execute(w, IndexTemplateData{ + Content: contentHTML, + }) + if err != nil { + templateError(err, w) + } +} + +func setupHome() { + loadReadme() +} @@ -1,9 +1,6 @@ package main import ( - "fmt" - "html/template" - "io" "log" "net/http" "os" @@ -11,86 +8,35 @@ import ( "github.com/joho/godotenv" ) -const youtubeAPI = "https://www.googleapis.com/youtube/v3/" -var tmpl = template.Must(template.ParseFiles("templates/watch.html")) +var debug = false +var apiKey = "" -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) { - id := strings.Trim(r.URL.Path, "/") - //if path == "" { - // TODO landing page - //} - if id == "watch" { - id = r.URL.Query().Get("v") - } - data := struct { - Id string - }{ - Id: id, - } - err := tmpl.Execute(w, data) - if err != nil { - msg := "Template execution error" - http.Error(w, msg, http.StatusInternalServerError) - log.Println(msg) +/* root handler */ +func rootHandler(w http.ResponseWriter, r *http.Request) { + path := strings.Trim(r.URL.Path, "/") + if path == "" { + //http.ServeFile(w, r, "static/index.html") + renderIndexTemplate(w) + } else { + renderWatchTemplate(w, path) } } +/* main */ func main() { // load .env file if it exists godotenv.Load() + debug = os.Getenv("DEBUG") != "" + apiKey = os.Getenv("API_KEY") // setup routes + setupRoutesAPI() + setupRoutesWatch() + setupHome() http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) - http.HandleFunc("/api/details", videoDetailsHandler) - http.HandleFunc("/api/comments", commentThreadsHandler) + http.HandleFunc("/", rootHandler) - http.HandleFunc("/", handler) + // start http server log.Println("Listening on http://localhost:8080") err := http.ListenAndServe(":8080", nil) if err != nil { diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..7c1f8eb --- /dev/null +++ b/templates/base.html @@ -0,0 +1,12 @@ +<!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"> + {{ block "scripts" . }}{{ end }} +</head> +<body> + {{ block "content" . }}{{ end }} +</body> +</html> diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..c4ade88 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,3 @@ +{{ define "content" }} + {{ .Content }} +{{ end }} diff --git a/templates/watch.html b/templates/watch.html index bae485d..be4be41 100644 --- a/templates/watch.html +++ b/templates/watch.html @@ -1,48 +1,43 @@ -<!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> +{{ define "scripts" }} +<script src="/static/index.js" defer></script> +<script src="https://www.youtube.com/iframe_api"></script> +{{ end }} + +{{ define "content" }} +<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> - <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> + <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> +{{ end }} @@ -0,0 +1,25 @@ +package main + +import ( + "fmt" + "html/template" + "log" + "net/http" +) + +func reloadTemplate(t **template.Template, files ...string) error { + tmpl, err := template.ParseFiles(files...) + if err != nil { + msg := fmt.Sprintf("Template load error: %v", err) + log.Println(msg) + } else { + *t = tmpl + } + return err +} + +func templateError(err error, w http.ResponseWriter) { + msg := "Template execution error" + http.Error(w, msg, http.StatusInternalServerError) + log.Println(msg) +} diff --git a/watch.go b/watch.go new file mode 100644 index 0000000..8352e62 --- /dev/null +++ b/watch.go @@ -0,0 +1,34 @@ +package main + +import ( + "html/template" + "net/http" +) + +var templateWatchFiles = []string{ "templates/base.html", "templates/watch.html" } +var templateWatch = template.Must(template.ParseFiles(templateWatchFiles...)) + +/* render template */ +type WatchTemplateData struct { + Id string +} + +func renderWatchTemplate(w http.ResponseWriter, id string) { + if debug { + reloadTemplate(&templateWatch, templateWatchFiles...) + } + err := templateWatch.Execute(w, WatchTemplateData{ + Id: id, + }) + if err != nil { + templateError(err, w) + } +} + +/* routes */ +func setupRoutesWatch() { + http.HandleFunc("/watch", func(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("v") + renderWatchTemplate(w, id) + }) +} |