Compare commits

..

28 Commits
v1.1.2 ... main

Author SHA1 Message Date
Evan Fiordeliso 0e35f73f06 Handle cancelled proxy downloads using context 2025-11-10 16:15:59 -05:00
Evan Fiordeliso c281da0eb0 Fix proxy for other formats 2025-11-10 15:56:13 -05:00
Evan Fiordeliso 2630af4921 Simplify structure by moving routes into command package 2025-11-10 15:12:24 -05:00
Evan Fiordeliso f3f852fefd Use custom configuration, reducing dependencies 2025-11-10 15:04:19 -05:00
Evan Fiordeliso 668b36ad04 Switch to templ for views and simplify routing 2025-11-10 13:56:15 -05:00
Evan Fiordeliso b871ccdd7c Update README 2024-11-07 09:03:54 -05:00
Evan Fiordeliso 59fa1bd369 Bump ytdl-web version 2024-11-06 23:27:27 -05:00
Evan Fiordeliso d814432ebb Fix incorrect thumbnail issue 2024-11-06 23:25:44 -05:00
Evan Fiordeliso 247cc211fa Fix show more formats collapse 2024-11-06 23:07:55 -05:00
Evan Fiordeliso 681225c2ae Fix config loading and fix/simplify yt-dlp command handling 2024-11-06 22:55:47 -05:00
Evan Fiordeliso 47caf17973 Bump go version and yt-dlp version in dockerfile and bump project version 2024-11-06 21:21:51 -05:00
Evan Fiordeliso 36de48cc94 Bump go version in dockerfile 2024-09-18 20:48:01 -04:00
Evan Fiordeliso 63556e2983 Bump yt-dlp version in dockerfile 2024-09-18 20:47:01 -04:00
Evan Fiordeliso 9dff066a9c Add stderr to log messages 2024-09-18 20:46:18 -04:00
Evan Fiordeliso f6d1ecd757 chore: bump yt-dlp version in docker image 2024-05-17 20:56:38 -04:00
Evan Fiordeliso aa0f47607f add nixos module to flake, add mainProgram option to package, and fix typo in readme 2024-04-16 09:59:09 -04:00
Evan Fiordeliso 77c42dffd8 rename .gitea to .forgejo 2024-04-16 00:51:59 -04:00
Evan Fiordeliso 9bba5db233 add nix flake with default package and add shell completions subcommand 2024-04-16 00:51:25 -04:00
Evan Fiordeliso c2b70e2ead Bump app version 2024-02-17 12:03:46 -05:00
Evan Fiordeliso e59915a8f5 Add collapse block with filtered out formats 2024-02-17 12:01:59 -05:00
Evan Fiordeliso 74ea23add9 Update bootstrap, format html files, remove samber/lo dep 2024-02-17 11:19:10 -05:00
Evan Fiordeliso 06ba575c42 Update devenv (including go version) 2024-02-17 11:09:31 -05:00
Evan Fiordeliso 8e4a9cb4f1 Fix proxied download by removing approx filesize 2024-02-17 11:05:49 -05:00
Evan Fiordeliso dd84590ac7 Fix ytdlp version in dockerfile 2024-01-01 15:22:58 -05:00
Evan Fiordeliso 8f9119d566 Switch from standard debian image to python image for yt-dlp 2024-01-01 15:17:21 -05:00
Evan Fiordeliso efb5aa8a33 Bump yt-dlp version 2024-01-01 15:11:09 -05:00
Evan Fiordeliso 2a51b37e81 chore: Bump version 2023-09-17 20:30:53 -04:00
Evan Fiordeliso 7cecc16695 Add extra view context to download controller 2023-09-17 20:29:27 -04:00
65 changed files with 2387 additions and 1874 deletions

View File

@ -3,7 +3,7 @@ testdata_dir = "testdata"
tmp_dir = "tmp" tmp_dir = "tmp"
[build] [build]
args_bin = ["-l", "0.0.0.0"] args_bin = ["-l", "0.0.0.0", "-p", "3000"]
bin = "./out/ytdl-web" bin = "./out/ytdl-web"
cmd = "build" cmd = "build"
delay = 1000 delay = 1000

11
.envrc
View File

@ -1,3 +1,12 @@
source_url "https://raw.githubusercontent.com/cachix/devenv/d1f7b48e35e6dee421cfd0f51481d17f77586997/direnvrc" "sha256-YBzqskFZxmNb3kYVoKD9ZixoPXJh1C9ZvTLGFRkauZ0=" #!/usr/bin/env bash
export DIRENV_WARN_TIMEOUT=20s
eval "$(devenv direnvrc)"
# `use devenv` supports the same options as the `devenv shell` command.
#
# To silence all output, use `--quiet`.
#
# Example usage: use devenv --quiet --impure --option services.postgres.enable:bool true
use devenv use devenv

21
.gitignore vendored
View File

@ -14,3 +14,24 @@ devenv.local.nix
# pre-commit # pre-commit
.pre-commit-config.yaml .pre-commit-config.yaml
# Devenv
.devenv*
devenv.local.nix
devenv.local.yaml
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml
# Devenv
.devenv*
devenv.local.nix
devenv.local.yaml
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml

View File

@ -1,3 +0,0 @@
{
"ansible.python.interpreterPath": "/bin/python"
}

View File

@ -1,7 +1,7 @@
ARG GOLANG_VERSION="1.20.5" ARG GOLANG_VERSION="1.22.1"
ARG ALPINE_VERSION="3.18" ARG DEBIAN_VERSION="bookworm"
FROM golang:${GOLANG_VERSION}-alpine${ALPINE_VERSION} AS build FROM golang:${GOLANG_VERSION}-${DEBIAN_VERSION} AS build
ENV VERSION_PKG="go.fifitido.net/ytdl-web/version" ENV VERSION_PKG="go.fifitido.net/ytdl-web/version"
ARG VERSION=latest ARG VERSION=latest
@ -25,12 +25,18 @@ RUN go build \
-X \"$VERSION_PKG.BuiltBy=$BUILT_BY\" \ -X \"$VERSION_PKG.BuiltBy=$BUILT_BY\" \
" -o /ytdl-web . " -o /ytdl-web .
FROM alpine:${ALPINE_VERSION} FROM python:${DEBIAN_VERSION}
ARG YTDLP_VERSION="2024.11.04"
WORKDIR /app WORKDIR /app
ARG YTDLP_VERSION="2023.07.06-r0" # hadolint ignore=DL3008
RUN apk add --no-cache yt-dlp==${YTDLP_VERSION} RUN apt-get update \
&& apt-get install --no-install-recommends -y wget \
&& wget --progress=dot:giga "https://github.com/yt-dlp/yt-dlp/releases/download/${YTDLP_VERSION}/yt-dlp" \
&& install -pm755 yt-dlp /usr/bin/yt-dlp \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=build /ytdl-web ./ COPY --from=build /ytdl-web ./

View File

@ -25,7 +25,7 @@ You can configure the application using environment variables
| ---------------------------------- | ----------------------------------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------ | | ---------------------------------- | ----------------------------------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------ |
| YTDL_CONFIGDIR | Add a custom config directory to search for the config file | ` ` | | | YTDL_CONFIGDIR | Add a custom config directory to search for the config file | ` ` | |
| YTDL_ENV | The application environment | `Production` | `Development`, `Staging`, `Production` | | YTDL_ENV | The application environment | `Production` | `Development`, `Staging`, `Production` |
| YTDL_BINARYPATH | The path to the yt-dlp binary | `yt-dlp` | | | YTDL_YTDLP_BINARYPATH | The path to the yt-dlp binary | `yt-dlp` | |
| YTDL_HTTP_PORT | The tcp port for the web server to listen on | `8080` | | | YTDL_HTTP_PORT | The tcp port for the web server to listen on | `8080` | |
| YTDL_HTTP_LISTEN | The address for the web server to listen on | `127.0.0.1` | `0.0.0.0`, `127.0.0.1`, etc. | | YTDL_HTTP_LISTEN | The address for the web server to listen on | `127.0.0.1` | `0.0.0.0`, `127.0.0.1`, etc. |
| YTDL_HTTP_BASEPATH | The base path of the application, useful for reverse proxies | `/` | | | YTDL_HTTP_BASEPATH | The base path of the application, useful for reverse proxies | `/` | |
@ -36,7 +36,7 @@ You can configure the application using environment variables
| YTDL_COOKIES_FROMBROWSER_BROWSER | The name of the browser to load cookies from. (if specified, it disables YTDL_COOKIES_FILEPATH) | ` ` | `brave`, `chrome`, `chromium`, `edge`, `firefox`, `opera`, `safari`, `vivaldi` | | YTDL_COOKIES_FROMBROWSER_BROWSER | The name of the browser to load cookies from. (if specified, it disables YTDL_COOKIES_FILEPATH) | ` ` | `brave`, `chrome`, `chromium`, `edge`, `firefox`, `opera`, `safari`, `vivaldi` |
| YTDL_COOKIES_FROMBROWSER_KEYRING | The name of the keyring for decrypting cookies for the chromium browser on linux | ` ` | `basictext`, `gnomekeyring`, `kwallet` | | YTDL_COOKIES_FROMBROWSER_KEYRING | The name of the keyring for decrypting cookies for the chromium browser on linux | ` ` | `basictext`, `gnomekeyring`, `kwallet` |
| YTDL_COOKIES_FROMBROWSER_PROFILE | The browser profile to load cookies from | ` ` | | | YTDL_COOKIES_FROMBROWSER_PROFILE | The browser profile to load cookies from | ` ` | |
| YTDL_COOKIES_FROMBROWSER_CONTAINER | The container name (if firefox) top load the cookies from | ` ` | | | YTDL_COOKIES_FROMBROWSER_CONTAINER | The container name (if firefox) to load the cookies from | ` ` | |
## Building from source ## Building from source

View File

@ -1,148 +0,0 @@
package controllers
import (
"fmt"
"net/http"
"net/url"
"github.com/go-chi/chi/v5"
"github.com/samber/lo"
"github.com/spf13/viper"
"go.fifitido.net/ytdl-web/app"
"go.fifitido.net/ytdl-web/app/models"
"go.fifitido.net/ytdl-web/pkg/htmx"
"go.fifitido.net/ytdl-web/pkg/httpx"
"go.fifitido.net/ytdl-web/pkg/server"
"go.fifitido.net/ytdl-web/pkg/view"
"go.fifitido.net/ytdl-web/pkg/ytdl"
"go.fifitido.net/ytdl-web/pkg/ytdl/metadata"
"golang.org/x/exp/slog"
)
type DownloadController struct {
ytdl ytdl.Ytdl
}
var _ server.Controller = (*DownloadController)(nil)
func NewDownloadController(ytdl ytdl.Ytdl) *DownloadController {
return &DownloadController{
ytdl: ytdl,
}
}
func (c *DownloadController) Router(r chi.Router) {
r.Get("/", c.ListDownloadLinks)
r.Head("/proxy", c.ProxyDownload)
r.Get("/proxy", c.ProxyDownload)
}
func (c *DownloadController) getUrlParam(r *http.Request) (string, bool) {
urlRaw, err := httpx.Query(r, "url")
if err != nil {
return "", false
}
urlBytes, err := url.QueryUnescape(urlRaw)
if err != nil || len(urlBytes) < 1 {
return "", false
}
return string(urlBytes), true
}
func (c *DownloadController) ListDownloadLinks(w http.ResponseWriter, r *http.Request) {
hx := htmx.New(w, r)
layout := []string{}
if !hx.IsHtmxRequest() {
layout = append(layout, "layouts/main")
}
videoUrl, ok := c.getUrlParam(r)
if !ok {
app.Views.Render(w, "index", view.Data{
"BasePath": viper.GetString("base_path"),
"Error": view.Data{
"Message": "Invalid URL",
},
}, layout...)
return
}
meta, err := c.ytdl.GetMetadata(videoUrl)
if err != nil {
app.Views.Render(w, "index", view.Data{
"BasePath": viper.GetString("base_path"),
"Error": view.Data{
"Message": "Could not find a video at that url",
"RetryUrl": videoUrl,
},
}, layout...)
return
}
if hx.IsHtmxRequest() {
hx.PushUrl("/download?url=" + url.QueryEscape(videoUrl))
}
app.Views.Render(w, "download", view.Data{
"BasePath": viper.GetString("base_path"),
"Url": videoUrl,
"Meta": meta,
"Videos": models.GetVideosFromMetadata(meta),
}, layout...)
}
func (c *DownloadController) ProxyDownload(w http.ResponseWriter, r *http.Request) {
videoUrl, ok := c.getUrlParam(r)
if !ok {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
formatId, err := httpx.Query(r, "format")
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
meta, err := c.ytdl.GetMetadata(videoUrl)
if err != nil {
slog.Error("Failed to get metadata", slog.String("error", err.Error()))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
videos := models.GetVideosFromMetadata(meta)
index, err := httpx.QueryInt(r, "index")
if err != nil || index < 0 || index >= len(videos) {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
video := videos[index]
format, ok := lo.Find(video.Formats, func(format metadata.Format) bool {
return format.FormatID == formatId
})
if !ok {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s-%s.%s\"", meta.ID, format.Resolution, format.Ext))
if format.Filesize != nil {
w.Header().Set("Content-Length", fmt.Sprint(*format.Filesize))
} else if format.FilesizeApprox != nil {
w.Header().Set("Content-Length", fmt.Sprint(*format.FilesizeApprox))
}
if len(videos) == 1 {
index = -1
}
if err := c.ytdl.Download(w, videoUrl, format.FormatID, index); err != nil {
slog.Error("Failed to download", slog.String("error", err.Error()))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}

View File

@ -1,55 +0,0 @@
package controllers
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/spf13/viper"
"go.fifitido.net/ytdl-web/app"
"go.fifitido.net/ytdl-web/pkg/htmx"
"go.fifitido.net/ytdl-web/pkg/server"
"go.fifitido.net/ytdl-web/pkg/view"
"go.fifitido.net/ytdl-web/pkg/ytdl"
"go.fifitido.net/ytdl-web/version"
)
type HomeController struct {
ytdl ytdl.Ytdl
}
var _ server.Controller = (*HomeController)(nil)
func NewHomeController(ytdl ytdl.Ytdl) *HomeController {
return &HomeController{
ytdl: ytdl,
}
}
func (c *HomeController) Router(r chi.Router) {
r.Get("/", c.Index)
}
func (c *HomeController) Index(w http.ResponseWriter, r *http.Request) {
hx := htmx.New(w, r)
isSecure := r.URL.Scheme == "https" || r.Header.Get("X-Forwarded-Proto") == "https"
if hx.IsHtmxRequest() {
hx.PushUrl("/")
app.Views.Render(w, "index", view.Data{
"BasePath": viper.GetString("base_path"),
"Version": version.Version,
"Build": version.Build,
"BinaryVersion": c.ytdl.Version(),
"IsSecure": isSecure,
})
} else {
app.Views.Render(w, "index", view.Data{
"BasePath": viper.GetString("base_path"),
"Version": version.Version,
"Build": version.Build,
"BinaryVersion": c.ytdl.Version(),
"IsSecure": isSecure,
}, "layouts/main")
}
}

View File

@ -1,34 +0,0 @@
package models
import (
"github.com/samber/lo"
"go.fifitido.net/ytdl-web/pkg/ytdl/metadata"
)
type Video struct {
Meta *metadata.Metadata
Formats []metadata.Format
}
func GetVideosFromMetadata(meta *metadata.Metadata) []Video {
if meta.IsPlaylist() {
return lo.Map(meta.Entries, func(video metadata.Metadata, _ int) Video {
return GetVideosFromMetadata(&video)[0]
})
}
formats := lo.Filter(meta.Formats, func(item metadata.Format, _ int) bool {
return item.ACodec != "none" && item.VCodec != "none" && item.Protocol != "m3u8_native"
})
for i, j := 0, len(formats)-1; i < j; i, j = i+1, j-1 {
formats[i], formats[j] = formats[j], formats[i]
}
return []Video{
{
Meta: meta,
Formats: formats,
},
}
}

View File

@ -1,49 +0,0 @@
package app
import (
"embed"
"encoding/json"
"html/template"
"net/url"
"github.com/htfy96/reformism"
"go.fifitido.net/ytdl-web/pkg/view/html"
"go.fifitido.net/ytdl-web/pkg/ytdl/metadata"
)
//go:embed views/*
var viewsfs embed.FS
var Views = html.New(
viewsfs,
html.DefaultOptions().
WithBaseDir("views").
WithFunction("unsafe", func(s string) template.HTML {
return template.HTML(s)
}).
WithFunction("queryEscape", func(s string) string {
return url.QueryEscape(s)
}).
WithFunction("jsonMarshal", func(s any) (string, error) {
j, err := json.MarshalIndent(s, "", " ")
if err != nil {
return "", err
}
return string(j), nil
}).
WithFunction("downloadContext", func(meta metadata.Metadata, url, basePath string, format metadata.Format) map[string]any {
return map[string]any{
"Meta": meta,
"Url": url,
"BasePath": basePath,
"Format": format,
}
}).
WithFunctions(reformism.FuncsHTML),
)
func init() {
if err := Views.Load(); err != nil {
panic(err)
}
}

View File

@ -1,52 +0,0 @@
<div class="d-flex flex-column align-items-center">
<h1>Download Video</h1>
<h2 class="fs-4 text-muted text-center">{{.Meta.Title}}</h2>
<p style="font-size: 0.85rem">{{.Url}}</p>
<a
href="{{.BasePath}}/"
hx-get="{{.BasePath}}/"
hx-trigger="click"
hx-target="#main-content"
class="btn btn-secondary btn-sm mt-3"
style="width: 30rem; max-width: 100%"
>
Download Another Video
</a>
</div>
{{$root := .}} {{range $vidIndex, $video := .Videos}} {{if not (eq $vidIndex
0)}}
<hr class="mt-5" />
{{end}}
<div class="d-flex flex-column flex-lg-row justify-content-center gap-5 mt-5">
<div class="d-flex justify-content-center">
<img
src="{{.Meta.Thumbnail}}"
alt="{{.Meta.Title}}"
style="max-height: 25rem; max-width: 100%; margin: 0 auto"
/>
</div>
<div class="downloads flex-lg-grow-1">
{{range $index, $format := $video.Formats}}
<div style="font-size: smaller">{{$format.Format}}</div>
<div class="flex-grow-1 d-flex gap-3">
<a
class="btn btn-primary flex-grow-1"
download="{{$root.Meta.ID}}-{{$format.Resolution}}.{{$format.Ext}}"
P
href="{{$format.Url}}"
>
Download (direct)
</a>
<a
class="btn btn-primary flex-grow-1"
download="{{$root.Meta.ID}}-{{$format.Resolution}}.{{$format.Ext}}"
href="{{$root.BasePath}}/download/proxy?url={{queryEscape $root.Url}}&format={{$format.FormatID}}&index={{$vidIndex}}"
>
Download (proxied)
</a>
</div>
{{end}}
</div>
</div>
{{end}}

View File

@ -1,91 +0,0 @@
<h1 class="text-center">YTDL Web</h1>
<p class="text-center mb-5">
Download videos from over a thousand websites with the help of
<a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a>, a fork of youtube-dl
with more features and fixes.
<br />
View a complete list of supported websites
<a href="https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md"
>here</a
>.
</p>
<form
hx-get="{{.BasePath}}/download"
hx-trigger="submit"
hx-target="#main-content"
hx-swap="innerHTML"
>
<div class="mb-3">
<label for="url" class="form-label visually-hidden">Url</label>
<div class="input-group">
<input
type="url"
name="url"
id="url"
class="form-control"
required
placeholder="Enter url here then click download"
/>
{{if .IsSecure}}
<button
id="paste-button"
class="btn btn-outline-secondary"
type="button"
title="Paste"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
style="width: 1.5rem; height: 1.5rem"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
</button>
{{end}}
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
Download
<div
class="spinner-border spinner-border-sm htmx-indicator ms-1"
role="status"
>
<span class="visually-hidden">Loading...</span>
</div>
</button>
</div>
</form>
{{if .Error}}
<div class="alert alert-danger mt-4" role="alert">
<span>{{.Error.Message}}</span>
{{if .Error.RetryUrl}}
<button
class="btn btn-link btn-sm pt-0 lh-base text-decoration-none"
hx-get="/download"
hx-trigger="click"
hx-target="#main-content"
hx-swap="innerHTML"
hx-vals='{"url": "{{.Error.RetryUrl}}"}'
>
<span class="text-decoration-underline">Try Again</span>
<div
class="spinner-border spinner-border-sm htmx-indicator ms-1"
role="status"
>
<span class="visually-hidden">Loading...</span>
</div>
</button>
{{end}}
</div>
{{end}}

View File

@ -1,94 +0,0 @@
<!doctype html>
<html lang="en" class="h-100">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YTDL Web</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-KK94CHFLLe+nY2dmCWGMq91rCGa5gtU4mk92HdvYe+M/SXH301p5ILy+dN9+nJOZ"
crossorigin="anonymous"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/toastr@2.1.4/build/toastr.min.css"
integrity="sha256-R91pD48xW+oHbpJYGn5xR0Q7tMhH4xOrWn1QqMRINtA="
crossorigin="anonymous"
/>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-ENjdO4Dr2bkBIFxQpeoTz1HIcje39Wm4jDKdf19U8gI4ddQ3GYNS7NTKfAdVQSZe"
crossorigin="anonymous"
defer
></script>
<script
src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.4/jquery.min.js"
defer
></script>
<script
src="https://cdn.jsdelivr.net/npm/toastr@2.1.4/build/toastr.min.js"
integrity="sha256-Hgwq1OBpJ276HUP9H3VJkSv9ZCGRGQN+JldPJ8pNcUM="
crossorigin="anonymous"
defer
></script>
<script
src="https://cdn.jsdelivr.net/npm/htmx.org@1.9.4/dist/htmx.min.js"
integrity="sha256-XIivRAE99i/eil5P31JNihaDSiix0V40rgmUrCfNTH4="
crossorigin="anonymous"
></script>
<style>
#toast-container > div {
-moz-box-shadow: none !important;
-webkit-box-shadow: none !important;
box-shadow: none !important;
-ms-filter: none;
filter: none;
opacity: 1;
}
.downloads {
display: grid;
grid-template-columns: minmax(auto, max-content) auto;
gap: 1.5rem;
align-items: center;
}
.see-more-btn,
.collapse {
grid-column: span 2;
}
</style>
</head>
<body class="d-flex flex-column h-100" data-bs-theme="dark">
<div class="flex-shrink-0">
{{template "partials/navbar" .}}
<main id="main-content" class="container my-5">{{yield}}</main>
</div>
{{template "partials/footer" .}}
<script>
/**
* @param content {Element}
*/
function setupPaste(content) {
const pasteButton = content.querySelector("#paste-button");
const urlField = content.querySelector("#url");
if (pasteButton) {
pasteButton.addEventListener("click", async () => {
try {
const text = await navigator.clipboard.readText();
urlField.value = text;
} catch (error) {
toastr.error("Failed to paste url from clipboard.");
}
});
}
}
htmx.onLoad(setupPaste);
</script>
</body>
</html>

View File

@ -1,22 +0,0 @@
<footer class="footer mt-auto py-3 bg-body-tertiary" style="font-size: 0.95rem">
<div class="container">
<div class="row gap-2 gap-md-0">
<div class="d-flex gap-4 col-md justify-content-center">
<div class="d-flex gap-1 align-items-baseline">
Version:
<span class="text-muted">{{.Version}}</span>
<span class="text-muted text-nowrap" style="font-size: smaller"
>(Build: {{.Build}})</span
>
</div>
<div class="d-flex gap-1 align-items-baseline text-nowrap">
yt-dlp version: <span class="text-muted">{{.BinaryVersion}}</span>
</div>
</div>
<div class="d-flex gap-2 col-md justify-content-center">
<a href="https://git.fifitido.net/apps/ytdl-web">Git Repository</a>
<span class="text-muted">&copy; 2023 FiFiTiDo</span>
</div>
</div>
</div>
</footer>

View File

@ -1,5 +0,0 @@
<nav class="navbar navbar-dark bg-primary">
<div class="container">
<a href="/" class="navbar-brand">YTDL Web</a>
</div>
</nav>

View File

@ -1,127 +0,0 @@
/*
Copyright © 2023 Evan Fiordeliso <evan.fiordeliso@gmail.com>
*/
package cmd
import (
"os"
"github.com/dgraph-io/badger/v2"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.fifitido.net/ytdl-web/app/controllers"
"go.fifitido.net/ytdl-web/config"
"go.fifitido.net/ytdl-web/pkg/server"
"go.fifitido.net/ytdl-web/pkg/utils"
"go.fifitido.net/ytdl-web/pkg/ytdl"
"go.fifitido.net/ytdl-web/pkg/ytdl/cache"
"golang.org/x/exp/slog"
)
var (
cfgFile string
cfg *config.Config
rootCmd = &cobra.Command{
Use: "ytdl-web",
Short: "A web frontend for yt-dlp",
Long: `YTDL Web
A web application that grabs the links to videos from over a
thousand websites using the yt-dlp project under the hood.`,
RunE: func(cmd *cobra.Command, args []string) error {
logger := slog.Default()
db, err := badger.Open(
badger.
DefaultOptions(cfg.Cache.DirPath).
WithLogger(utils.NewBadgerLogger(logger.With("module", "badger"))),
)
if err != nil {
return err
}
defer db.Close()
cache := cache.NewDefaultMetadataCache(db)
ytdl := ytdl.NewYtdl(cfg, slog.Default(), cache)
s := server.New(
server.
DefaultOptions().
WithListenAddr(viper.GetString("http.listen")).
WithListenPort(viper.GetInt("http.port")).
WithLogger(logger.With("module", "server")),
)
s.MountController("/", controllers.NewHomeController(ytdl))
s.MountController("/download", controllers.NewDownloadController(ytdl))
return s.ListenAndServe()
},
}
)
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $XDG_CONFIG_HOME/ytdl-web/config.yml)")
rootCmd.PersistentFlags().IntP("port", "p", 8080, "port to listen on")
rootCmd.PersistentFlags().StringP("listen", "l", "", "address to listen on")
rootCmd.PersistentFlags().StringP("base-path", "b", "", "the base path, used when behind reverse proxy")
rootCmd.PersistentFlags().StringP("ytdlp-path", "y", "", "the path to the yt-dlp binary, used when it is not in $PATH")
rootCmd.PersistentFlags().BoolP("cookies-enabled", "C", false, "whether cookies are enabled")
rootCmd.PersistentFlags().StringP("cookies", "c", "", "the path to the cookies file")
// trunk-ignore-begin(golangci-lint/errcheck): Ignoring errors
viper.BindPFlag("http.port", rootCmd.PersistentFlags().Lookup("port"))
viper.BindPFlag("http.listen", rootCmd.PersistentFlags().Lookup("listen"))
viper.BindPFlag("http.basePath", rootCmd.PersistentFlags().Lookup("base-path"))
viper.BindPFlag("ytdlp.binaryPath", rootCmd.PersistentFlags().Lookup("ytdlp-path"))
viper.BindPFlag("cookies.enabled", rootCmd.PersistentFlags().Lookup("cookies-enabled"))
viper.BindPFlag("cookies.filePath", rootCmd.PersistentFlags().Lookup("cookies"))
// trunk-ignore-end(golangci-lint/errcheck)
}
func initConfig() {
var err error
if cfgFile != "" {
cfg, err = config.LoadConfig(cfgFile)
} else {
cfg, err = config.LoadConfig()
}
if err != nil {
slog.Error("Error loading configuration", slog.String("error", err.Error()))
os.Exit(1)
}
initLogging()
slog.Info("Configuration loaded")
}
func initLogging() {
var handler slog.Handler
if cfg.IsProduction() {
handler = slog.HandlerOptions{
AddSource: true,
Level: slog.LevelInfo,
}.NewJSONHandler(os.Stdout)
} else {
handler = slog.HandlerOptions{
AddSource: true,
Level: slog.LevelDebug,
}.NewTextHandler(os.Stdout)
}
slog.SetDefault(slog.New(handler))
}

158
cmd/ytdl-web/download.go Normal file
View File

@ -0,0 +1,158 @@
package main
import (
"errors"
"fmt"
"io"
"log/slog"
"mime"
"net/http"
"net/url"
"strconv"
"go.fifitido.net/ytdl-web/pkg/views"
"go.fifitido.net/ytdl-web/pkg/ytdl"
"go.fifitido.net/ytdl-web/pkg/ytdl/metadata"
)
func init() {
mime.AddExtensionType(".mp4", "video/mp4")
mime.AddExtensionType(".m4v", "video/x-m4v")
mime.AddExtensionType(".mkv", "video/x-matroska")
mime.AddExtensionType(".webm", "video/webm")
mime.AddExtensionType(".mov", "video/quicktime")
mime.AddExtensionType(".avi", "video/x-msvideo")
mime.AddExtensionType(".wmv", "video/x-ms-wmv")
mime.AddExtensionType(".mpg", "video/mpeg")
mime.AddExtensionType(".flv", "video/x-flv")
mime.AddExtensionType(".3gp", "video/3gpp")
mime.AddExtensionType(".m3u8", "application/x-mpegURL")
mime.AddExtensionType(".ts", "video/mp2t")
mime.AddExtensionType(".m4a", "audio/mp4")
mime.AddExtensionType(".mp3", "audio/mpeg")
mime.AddExtensionType(".aac", "audio/aac")
mime.AddExtensionType(".ogg", "audio/ogg")
mime.AddExtensionType(".wav", "audio/wav")
mime.AddExtensionType(".opus", "audio/opus")
}
func getUrlParam(r *http.Request) (string, error) {
urlRaw := r.URL.Query().Get("url")
if urlRaw == "" {
return "", errors.New("url param not specified")
}
urlUnescaped, err := url.QueryUnescape(urlRaw)
if err != nil || len(urlUnescaped) < 1 {
return "", errors.New("invalid url")
}
return urlUnescaped, nil
}
func download(w http.ResponseWriter, r *http.Request) {
ytdl := ytdl.Default()
videoUrl, err := getUrlParam(r)
if err != nil {
slog.ErrorContext(r.Context(), "Failed to get video url param", slog.Any("error", err))
views.Render(w, r, views.Home(&views.Error{Message: "Invalid URL"}))
return
}
meta, err := ytdl.GetMetadata(r.Context(), videoUrl)
if err != nil {
views.Render(w, r, views.Home(&views.Error{Message: "Could not find a video at that url", RetryUrl: &videoUrl}))
return
}
views.Render(w, r, views.Downloads(&views.DownloadsViewModel{Url: videoUrl, Meta: meta}))
}
func proxyDownload(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusBadRequest)
return
}
ytdl := ytdl.Default()
videoUrl, err := getUrlParam(r)
if err != nil {
slog.ErrorContext(r.Context(), "Failed to get video url param", slog.Any("error", err))
http.Error(w, "Video URL not specified or invalid URL", http.StatusBadRequest)
return
}
formatId := r.URL.Query().Get("format")
if formatId == "" {
http.Error(w, "Format ID not specified", http.StatusBadRequest)
return
}
meta, err := ytdl.GetMetadata(r.Context(), videoUrl)
if err != nil {
slog.Error("Failed to get metadata", slog.String("error", err.Error()))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
videos := views.GetVideosFromMetadata(meta)
index, err := strconv.Atoi(r.URL.Query().Get("index"))
if err != nil || index < 0 || index >= len(videos) {
slog.Error("Failed to parse index", slog.Any("error", err), slog.Int("index", index), slog.Int("len", len(videos)))
http.Error(w, "Index not specified or invalid index", http.StatusBadRequest)
return
}
video := videos[index]
var format *metadata.Format
for _, f := range video.Formats {
if f.FormatID == formatId {
format = &f
break
}
}
if format == nil {
for _, f := range video.OtherFormats {
if f.FormatID == formatId {
format = &f
break
}
}
}
if format == nil {
slog.Error("Failed to find format", slog.String("format_id", formatId), slog.String("video_id", video.Meta.ID), slog.Int("index", index))
http.Error(w, "Format not found", http.StatusBadRequest)
return
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.%s\"", meta.ID, format.Ext))
w.Header().Set("Transfer-Encoding", "chunked")
w.Header().Set("Content-Type", mime.TypeByExtension("."+format.Ext))
if format.Filesize != nil {
w.Header().Set("Content-Length", fmt.Sprint(*format.Filesize))
}
if len(videos) == 1 {
index = -1
}
read, write := io.Pipe()
defer write.Close()
go func() {
defer write.Close()
ytdl.Download(r.Context(), write, videoUrl, format.FormatID, index)
}()
if _, err := io.Copy(w, read); err != nil {
slog.Error("Failed to copy", slog.String("error", err.Error()))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
flusher.Flush()
}

35
cmd/ytdl-web/logger.go Normal file
View File

@ -0,0 +1,35 @@
package main
import (
"fmt"
"log/slog"
"github.com/dgraph-io/badger/v2"
)
type badgerLogger struct {
logger *slog.Logger
}
var _ badger.Logger = (*badgerLogger)(nil)
// Debugf implements badger.Logger
func (l *badgerLogger) Debugf(f string, a ...any) {
l.logger.Debug(fmt.Sprintf(f, a...))
}
// Errorf implements badger.Logger
func (l *badgerLogger) Errorf(f string, a ...any) {
l.logger.Error(fmt.Sprintf(f, a...))
}
// Infof implements badger.Logger
func (l *badgerLogger) Infof(f string, a ...any) {
l.logger.Info(fmt.Sprintf(f, a...))
}
// Warningf implements badger.Logger
func (l *badgerLogger) Warningf(f string, a ...any) {
l.logger.Warn(fmt.Sprintf(f, a...))
}

104
cmd/ytdl-web/main.go Normal file
View File

@ -0,0 +1,104 @@
/*
Copyright © 2024 Evan Fiordeliso <evan.fiordeliso@gmail.com>
*/
package main
import (
"flag"
"log/slog"
"net/http"
"os"
"time"
"github.com/dgraph-io/badger/v2"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
slogchi "github.com/samber/slog-chi"
"go.fifitido.net/ytdl-web/pkg/config"
"go.fifitido.net/ytdl-web/pkg/serverctx"
"go.fifitido.net/ytdl-web/pkg/views"
"go.fifitido.net/ytdl-web/pkg/ytdl"
"go.fifitido.net/ytdl-web/pkg/ytdl/cache"
)
var (
env = config.String("env", "production", "the environment to run")
listenAddr = config.String("listen", ":8080", "address to listen on")
basePath = config.String("base_path", "/", "the base path, used when behind reverse proxy")
ytdlpPath = config.String("ytdlp.binary", "", "path to yt-dlp executable")
cacheTTL = config.Duration("cache.ttl", time.Hour, "the TTL for the cache")
cacheDir = config.String("cache.dir", "", "the directory to store the cache")
cookies = config.Bool("cookies", false, "whether cookies are enabled")
cookiesPath = config.String("cookies.path", "", "the path to the cookies file")
cookiesBrowser = config.String("cookies.browser", "", "the browser to use for cookies")
cookiesBrowserKeyring = config.String("cookies.browser.keyring", "", "the keyring to use for cookies")
cookiesBrowserProfile = config.String("cookies.browser.profile", "", "the profile to use for cookies")
cookiesBrowserContainer = config.String("cookies.browser.container", "", "the container to use for cookies")
)
func main() {
flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelInfo,
}))
if env() == "development" {
logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelDebug,
}))
}
slog.SetDefault(logger)
db, err := badger.Open(
badger.
DefaultOptions(cacheDir()).
WithLogger(&badgerLogger{logger: logger.With("module", "badger")}),
)
if err != nil {
os.Exit(1)
}
defer db.Close()
cache := cache.NewDefaultMetadataCache(db, cacheTTL())
ytdl.SetDefault(
ytdl.NewYtdl(&ytdl.Config{
BinaryPath: ytdlpPath(),
CookiesEnabled: cookies(),
CookiesFilePath: cookiesPath(),
CookiesBrowser: cookiesBrowser(),
CookiesBrowserKeyring: cookiesBrowserKeyring(),
CookiesBrowserProfile: cookiesBrowserProfile(),
CookiesBrowserContainer: cookiesBrowserContainer(),
}, logger, cache),
)
r := chi.NewRouter()
r.Use(
serverctx.Middleware(basePath()),
middleware.RequestID,
middleware.RealIP,
slogchi.New(logger),
middleware.Recoverer,
)
r.Route(basePath(), func(r chi.Router) {
r.Get("/", views.Handler(views.Home(nil)))
r.Route("/download", func(r chi.Router) {
r.Get("/", download)
r.Head("/proxy", proxyDownload)
r.Get("/proxy", proxyDownload)
})
})
logger.Info("Starting HTTP server", slog.String("listen", listenAddr()))
if err := http.ListenAndServe(listenAddr(), r); err != nil {
logger.Error("failed to serve website", slog.Any("error", err))
os.Exit(1)
}
}

View File

@ -1,46 +0,0 @@
---
# The server environment
# For dev environments use Development
# For prod environments use Production
# For staging envronments use Staging
env: Production
# The path to the yt-dlp binary
# If it is already in your $PATH just yt-dlp will work.
binaryPath: yt-dlp
http:
# The port to listen on
port: 8080
# The address to listen on
# For local only access use 127.0.0.1
# For public access use 0.0.0.0
listen: 0.0.0.0
# The base path of the application, useful for reverse proxies
basePath: ""
# A list of proxy servers to trust for security purposes
# Only needed when accessing app behind a proxy
trustedProxies: []
cookies:
# Whether to use cookies when fetching the video metadata
enabled: false
# The path to the netscape formatted cookies file
# See: https://www.reddit.com/r/youtubedl/wiki/cookies/ for details.
filePath: ~/.cookies
# Settings for using cookies from a browser's cookies store
fromBrowser:
# The name of the browser to load cookies from.
# Currently supported browsers are: brave, chrome, chromium, edge,
# firefox, opera, safari, vivaldi.
browser: firefox
# The keyring used for decrypting Chromium cookies on Linux
# Currently supported keyrings are: basictext, gnomekeyring, kwallet
keyring: basictext
# The profile to load cookies from (Firefox)
profile: default
# The container to load cookies from (Firefox)
container: none

View File

@ -1,123 +0,0 @@
package config
import (
"os"
"strings"
"time"
"github.com/adrg/xdg"
"github.com/spf13/viper"
)
type Config struct {
Env string `mapstructure:"env"`
BinaryPath string `mapstructure:"binaryPath"`
HTTP ConfigHTTP `mapstructure:"http"`
Cache ConfigCache `mapstructure:"cache"`
Cookies ConfigCookies `mapstructure:"cookies"`
}
func (c *Config) IsProduction() bool {
return c.Env == "Production"
}
func (c *Config) IsDevelopment() bool {
return c.Env == "Development"
}
func (c *Config) IsStaging() bool {
return c.Env == "Staging"
}
type ConfigHTTP struct {
Port int `mapstructure:"port"`
Listen string `mapstructure:"listen"`
BasePath string `mapstructure:"basePath"`
TrustedProxies []string `mapstructure:"trustedProxies"`
}
type ConfigCache struct {
TTL time.Duration `mapstructure:"ttl"`
DirPath string `mapstructure:"dirPath"`
}
type ConfigCookies struct {
Enabled bool `mapstructure:"enabled"`
FilePath string `mapstructure:"filePath"`
FromBrowser ConfigCookiesFromBrowser `mapstructure:"fromBrowser"`
}
type ConfigCookiesFromBrowser struct {
Browser string `mapstructure:"browser"`
Keyring string `mapstructure:"keyring"`
Profile string `mapstructure:"profile"`
Container string `mapstructure:"container"`
}
func DefaultConfig() *Config {
return &Config{
Env: "Production",
BinaryPath: "yt-dlp",
HTTP: ConfigHTTP{
Port: 8080,
Listen: "127.0.0.1",
BasePath: "/",
},
Cache: ConfigCache{
TTL: time.Hour,
DirPath: "/tmp/ytdl-web",
},
Cookies: ConfigCookies{
Enabled: false,
FilePath: "./cookies.txt",
FromBrowser: ConfigCookiesFromBrowser{},
},
}
}
func LoadConfig(paths ...string) (*Config, error) {
v := viper.New()
v.SetEnvPrefix("YTDL")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
v.SetConfigName("config")
v.SetConfigType("yaml")
if len(paths) > 0 {
for _, path := range paths {
v.AddConfigPath(path)
}
} else {
envDir := os.Getenv("YTDL_CONFIGDIR")
if envDir != "" {
v.AddConfigPath(envDir)
}
v.AddConfigPath(".")
homeDir, err := os.UserHomeDir()
if err == nil {
v.AddConfigPath(homeDir + "/.config/ytdl-web")
}
v.AddConfigPath(xdg.ConfigHome + "/ytdl-web")
for _, dir := range xdg.ConfigDirs {
v.AddConfigPath(dir + "/ytdl-web")
}
}
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, err
}
}
config := DefaultConfig()
if err := v.Unmarshal(config); err != nil {
return nil, err
}
return config, nil
}

View File

@ -3,11 +3,10 @@
"devenv": { "devenv": {
"locked": { "locked": {
"dir": "src/modules", "dir": "src/modules",
"lastModified": 1689175844, "lastModified": 1762791812,
"narHash": "sha256-+ZAcAnogqNXz5P2/NiZonmgUiv+vCC7/swiSepyTulc=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv", "repo": "devenv",
"rev": "db59403d5bdad71dce137705ed7cb926681e5f95", "rev": "1faab0d28c573f2a8dba2cf457b9d383adba252a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -20,11 +19,10 @@
"flake-compat": { "flake-compat": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1673956053, "lastModified": 1761588595,
"narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
"owner": "edolstra", "owner": "edolstra",
"repo": "flake-compat", "repo": "flake-compat",
"rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -33,37 +31,39 @@
"type": "github" "type": "github"
} }
}, },
"flake-utils": { "git-hooks": {
"inputs": { "inputs": {
"systems": "systems" "flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
}, },
"locked": { "locked": {
"lastModified": 1685518550, "lastModified": 1762441963,
"narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=", "owner": "cachix",
"owner": "numtide", "repo": "git-hooks.nix",
"repo": "flake-utils", "rev": "8e7576e79b88c16d7ee3bbd112c8d90070832885",
"rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "numtide", "owner": "cachix",
"repo": "flake-utils", "repo": "git-hooks.nix",
"type": "github" "type": "github"
} }
}, },
"gitignore": { "gitignore": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
"pre-commit-hooks", "git-hooks",
"nixpkgs" "nixpkgs"
] ]
}, },
"locked": { "locked": {
"lastModified": 1660459072, "lastModified": 1709087332,
"narHash": "sha256-8DFJjXG8zqoONA1vXtgeKXy68KdJL5UaXR8NtVMUbx8=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "gitignore.nix", "repo": "gitignore.nix",
"rev": "a20de23b925fd8264fd7fad6454652e142fd7f73", "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -74,80 +74,27 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1689168768, "lastModified": 1761313199,
"narHash": "sha256-mCw3LPg2jJkapvJpkd1IZ8k0IJlSG2ECvz3vcOAu+Uo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6fd9edc94426a3c050ad589c8f033b5ca55454c7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1685801374,
"narHash": "sha256-otaSUoFEMM+LjBI1XL/xGB5ao6IwnZOXc47qhIgJe8U=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c37ca420157f4abc31e26f436c1145f8951ff373",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-23.05",
"repo": "nixpkgs",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": "flake-utils",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
],
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1688596063,
"narHash": "sha256-9t7RxBiKWHygsqXtiNATTJt4lim/oSYZV3RG8OjDDng=",
"owner": "cachix", "owner": "cachix",
"repo": "pre-commit-hooks.nix", "repo": "devenv-nixpkgs",
"rev": "c8d18ba345730019c3faf412c96a045ade171895", "rev": "d1c30452ebecfc55185ae6d1c983c09da0c274ff",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "cachix", "owner": "cachix",
"repo": "pre-commit-hooks.nix", "ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github" "type": "github"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"devenv": "devenv", "devenv": "devenv",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"pre-commit-hooks": "pre-commit-hooks" "pre-commit-hooks": [
} "git-hooks"
}, ]
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
} }
} }
}, },

View File

@ -1,16 +1,23 @@
{ pkgs, ... }: { pkgs, config, ... }:
{ {
# https://devenv.sh/basics/ # https://devenv.sh/basics/
env.NAME = "ytdl-web"; env = {
env.BINARY_OUT = "./out/ytdl-web"; NAME = "ytdl-web";
env.VERSION = "v1.1.2"; BINARY_OUT = "./out/ytdl-web";
env.VERSION_PKG = "go.fifitido.net/ytdl-web/version"; VERSION = "v1.2.3";
env.DOCKER_REGISTRY = "git.fifitido.net"; VERSION_PKG = "go.fifitido.net/ytdl-web/version";
env.DOCKER_ORG = "apps"; DOCKER_REGISTRY = "git.fifitido.net";
env.DOCKER_PLATFORMS = "linux/amd64,linux/arm64"; DOCKER_ORG = "apps";
DOCKER_PLATFORMS = "linux/amd64,linux/arm64";
env.YTDL_ENV = "Development"; YTDL_WEB_ENV = "development";
YTDL_WEB_YTDLP_BINARY = "/usr/bin/yt-dlp";
YTDL_WEB_LISTEN = ":8989";
YTDL_WEB_CACHE_DIR = config.devenv.runtime + "/cache";
YTDL_WEB_COOKIES = "true";
YTDL_WEB_COOKIES_BROWSER = "firefox";
};
# https://devenv.sh/packages/ # https://devenv.sh/packages/
packages = with pkgs; [ packages = with pkgs; [
@ -22,65 +29,68 @@
buildkit buildkit
docker-buildx docker-buildx
yt-dlp yt-dlp
cobra-cli
]; ];
# https://devenv.sh/scripts/ # https://devenv.sh/scripts/
scripts.deps.exec = "go mod download"; scripts = {
scripts.tidy.exec = "go mod tidy"; deps.exec = "go mod download";
scripts.check.exec = "goreleaser check"; tidy.exec = "go mod tidy";
check.exec = "goreleaser check";
scripts.build-id.exec = "git rev-parse --short HEAD"; build-id.exec = "git rev-parse --short HEAD";
scripts.build-date.exec = "date -Iseconds"; build-date.exec = "date -Iseconds";
scripts.build.exec = '' build.exec = ''
go build -ldflags=" go build -ldflags="
-X $VERSION_PKG.Version=$VERSION -X $VERSION_PKG.Version=$VERSION
-X $VERSION_PKG.Build=$(build-id) -X $VERSION_PKG.Build=$(build-id)
-X $VERSION_PKG.BuildDate=$(build-date) -X $VERSION_PKG.BuildDate=$(build-date)
-X $VERSION_PKG.BuiltBy=manual -X $VERSION_PKG.BuiltBy=manual
" -o $BINARY_OUT . " -o $BINARY_OUT ./cmd/ytdl-web
''; '';
scripts.clean.exec = '' clean.exec = ''
rm -rf ./dist/ ./out/ ./tmp/ rm -rf ./dist/ ./out/ ./tmp/
go clean go clean
''; '';
scripts.release.exec = '' release.exec = ''
clean clean
goreleaser release goreleaser release
docker-build-release docker-build-release
''; '';
scripts.lint.exec = "trunk check"; lint.exec = "trunk check";
scripts.fmt.exec = "trunk fmt"; fmt.exec = "trunk fmt";
scripts.docker-image.exec = "echo $DOCKER_REGISTRY/$DOCKER_ORG/$NAME"; docker-image.exec = "echo $DOCKER_REGISTRY/$DOCKER_ORG/$NAME";
scripts.docker-init.exec = '' docker-init.exec = ''
docker buildx create \ docker buildx create \
--name $NAME \ --name $NAME \
--platform $DOCKER_PLATFORMS --platform $DOCKER_PLATFORMS
''; '';
scripts.docker-login.exec = "docker login $DOCKER_REGISTRY"; docker-login.exec = "docker login $DOCKER_REGISTRY";
scripts.docker-build.exec = '' docker-build.exec = ''
platform=''${1:-linux} platform=''${1:-linux}
output=''${2:-type=docker} output=''${2:-type=docker}
docker-init docker-init
PROGRESS_NO_TRUNC=1 docker buildx build . \ PROGRESS_NO_TRUNC=1 docker buildx build . \
--tag $(docker-image):$VERSION \ --tag $(docker-image):$VERSION \
--tag $(docker-image):latest \ --tag $(docker-image):latest \
--platform $platform \ --platform $platform \
--builder $NAME \ --builder $NAME \
--build-arg VERSION=$VERSION \ --build-arg VERSION=$VERSION \
--build-arg BUILD=$(build-id) \ --build-arg BUILD=$(build-id) \
--build-arg BUILD_DATE=$(build-date) \ --build-arg BUILD_DATE=$(build-date) \
--build-arg BUILT_BY="Devenv Script" \ --build-arg BUILT_BY="Devenv Script" \
--progress plain \ --progress plain \
--output $output --output $output
''; '';
scripts.docker-build-release.exec = "docker-build $DOCKER_PLATFORMS type=image,push=true"; docker-build-release.exec = "docker-build $DOCKER_PLATFORMS type=image,push=true";
};
enterShell = '' enterShell = ''
echo "Welcome to the $NAME development environment." echo "Welcome to the $NAME development environment."
@ -95,9 +105,11 @@
languages.go.enable = true; languages.go.enable = true;
# https://devenv.sh/pre-commit-hooks/ # https://devenv.sh/pre-commit-hooks/
pre-commit.hooks.staticcheck.enable = true; git-hooks.hooks = {
pre-commit.hooks.hadolint.enable = true; staticcheck.enable = true;
pre-commit.hooks.yamllint.enable = true; hadolint.enable = true;
yamllint.enable = true;
};
# https://devenv.sh/processes/ # https://devenv.sh/processes/
processes.web.exec = "air"; processes.web.exec = "air";

View File

@ -1,5 +1,15 @@
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
--- ---
allowUnfree: true
inputs: inputs:
nixpkgs: nixpkgs:
url: github:NixOS/nixpkgs/nixpkgs-unstable url: github:cachix/devenv-nixpkgs/rolling
# If you're using non-OSS software, you can set allowUnfree to true.
# allowUnfree: true
# If you're willing to use a package that's vulnerable
# permittedInsecurePackages:
# - "openssl-1.1.1w"
# If you have more than one devenv you can merge them
# imports:
# - ./backend

52
go.mod
View File

@ -1,45 +1,27 @@
module go.fifitido.net/ytdl-web module go.fifitido.net/ytdl-web
go 1.20 go 1.25.2
require ( require (
github.com/adrg/xdg v0.4.0 github.com/a-h/templ v0.3.960
github.com/dgraph-io/badger/v2 v2.2007.4 github.com/dgraph-io/badger/v2 v2.2007.4
github.com/go-chi/chi/v5 v5.0.10 github.com/go-chi/chi/v5 v5.2.3
github.com/htfy96/reformism v0.0.0-20160819020323-e5bfca398e73 github.com/samber/slog-chi v1.17.0
github.com/samber/lo v1.38.1
github.com/spf13/cobra v1.7.0
github.com/spf13/viper v1.10.0
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53
) )
require ( require (
github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash v1.1.0 // indirect
github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 // indirect github.com/dgraph-io/ristretto v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect
github.com/fsnotify/fsnotify v1.5.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/golang/protobuf v1.5.2 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.3 // indirect github.com/golang/snappy v1.0.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/klauspost/compress v1.18.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/klauspost/compress v1.16.3 // indirect go.opentelemetry.io/otel v1.38.0 // indirect
github.com/kr/text v0.2.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect
github.com/magiconair/properties v1.8.5 // indirect golang.org/x/net v0.46.0 // indirect
github.com/mitchellh/mapstructure v1.4.3 // indirect golang.org/x/sys v0.38.0 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect google.golang.org/protobuf v1.36.10 // indirect
github.com/pelletier/go-toml v1.9.4 // indirect
github.com/pkg/errors v0.8.1 // indirect
github.com/spf13/afero v1.6.0 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.2.0 // indirect
golang.org/x/net v0.8.0 // indirect
golang.org/x/sys v0.7.0 // indirect
golang.org/x/text v0.8.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
gopkg.in/ini.v1 v1.66.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
) )

119
go.sum
View File

@ -1,144 +1,97 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v2 v2.2007.4 h1:TRWBQg8UrlUhaFdco01nO2uXwzKS7zd+HVdwV/GHc4o= github.com/dgraph-io/badger/v2 v2.2007.4 h1:TRWBQg8UrlUhaFdco01nO2uXwzKS7zd+HVdwV/GHc4o=
github.com/dgraph-io/badger/v2 v2.2007.4/go.mod h1:vSw/ax2qojzbN6eXHIx6KPKtCSHJN/Uz0X0VPruTIhk= github.com/dgraph-io/badger/v2 v2.2007.4/go.mod h1:vSw/ax2qojzbN6eXHIx6KPKtCSHJN/Uz0X0VPruTIhk=
github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de h1:t0UHb5vdojIDUqktM6+xJAfScFBsVpXZmqC9dsgJmeA=
github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/htfy96/reformism v0.0.0-20160819020323-e5bfca398e73 h1:Shcv21tstWAyUkKxbn5bTARYej9sgEgFgTRxUPk1J8o=
github.com/htfy96/reformism v0.0.0-20160819020323-e5bfca398e73/go.mod h1:i2jduFeVras6pm8GnBWdfVKj97mGEXJogjMHzyJhukY=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM=
github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/slog-chi v1.17.0 h1:zP66fV4LGF1y1Dg/+uHNY9Uxkmw1YNWaZGws6M6mPCk=
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= github.com/samber/slog-chi v1.17.0/go.mod h1:a1iIuofF2gS1ii8aXIQhC6TEguLOhOvSM958fY5hToU=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY=
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/spf13/viper v1.10.0 h1:mXH0UwHS4D2HwWZa75im4xIQynLfblmWV7qcWpfv0yk=
github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.66.2 h1:XfR1dOYubytKy4Shzc2LHrrGhU0lDCfDGG1yLPmpgsI=
gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

11
main.go
View File

@ -1,11 +0,0 @@
/*
Copyright © 2023 NAME HERE <EMAIL ADDRESS>
*/
package main
import "go.fifitido.net/ytdl-web/cmd"
func main() {
cmd.Execute()
}

229
nix/module.nix Normal file
View File

@ -0,0 +1,229 @@
{ config, lib, pkgs, ... }:
let
inherit (lib.options) mkOption mkEnableOption mkPackageOption;
inherit (lib.modules) mkIf;
inherit (lib.types) str enum int path submodule nullOr bool;
cfg = config.services.ytdl-web;
in
{
options.services.ytdl-web = {
enable = mkEnableOption "ytdl-web";
package = mkPackageOption pkgs "ytdl-web" { };
user = mkOption {
type = str;
default = "ytdl-web";
description = ''
The user account ytdl-web will run under.
'';
};
group = mkOption {
type = str;
default = "ytdl-web";
description = ''
The group ytdl-web will run under.
'';
};
appEnvironment = mkOption {
type = enum [ "Development" "Staging" "Production" ];
default = "Production";
description = ''
The application environment mode.
'';
};
ytdlPackage = mkPackageOption pkgs "yt-dlp" { };
port = mkOption {
type = int;
default = 8080;
description = ''
The tcp port for the web server to listen on.
'';
};
listen = mkOption {
type = str;
default = "0.0.0.0";
example = "127.0.0.1";
description = ''
The address for the web server to listen on.
'';
};
openFirewall = mkOption {
type = bool;
default = false;
example = literalExpression "true";
description = ''
Open ports in the firewall for the ytdl-web server.
'';
};
basePath = mkOption {
type = str;
default = "/";
example = "/ytdl-web";
description = ''
The base path that the web application is hosted under.
Useful for reverse-proxies that proxy the app with a path prefix.
'';
};
cacheTTL = mkOption {
type = str;
default = "1h";
example = "2m";
description = ''
How long to keep cached metadata for.
A duration string is a possibly signed sequence of decimal numbers,
each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m".
Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
'';
};
cacheDir = mkOption {
type = path;
default = "/var/cache/ytdl-web";
example = "/tmp/ytdl-web";
description = ''
The directory containing the ytdl metadata cache.
'';
};
cookies = mkOption {
type = submodule {
options = {
enable = mkEnableOption "ytdl cookies";
file = mkOption {
type = nullOr path;
default = null;
example = "/etc/ytdl-web/cookies.txt";
description = ''
The file that contains the netscape formatted cookies.
'';
};
fromBrowser = mkOption {
description = ''
Settings for obtaining cookies from a local browser's cookie storage.
'';
type = submodule {
options = {
browser = mkOption {
type = nullOr (enum [ "brave" "chrome" "chromium" "edge" "firefox" "opera" "safari" "vivaldi" ]);
default = null;
description = ''
The name of the browser to load cookies from.
'';
};
keyring = mkOption {
type = nullOr (enum [ "basictext" "gnomekeyring" "kwallet" ]);
default = null;
description = ''
The name of the keyring to use to decrypt cookies when using the chromium browser.
'';
};
profile = mkOption {
type = nullOr str;
default = null;
description = ''
The name of the browser profile to load the cookies from.
'';
};
container = mkOption {
type = nullOr str;
default = null;
description = ''
The container name to load the cookies from when using the firefox browser.
'';
};
};
};
};
};
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.cookies.file != null && cfg.cookies.fromBrowser.browser != null;
message = "The `services.ytdl-web.cookies.file` and `services.ytdl-web.cookies.fromBrowser.browser` options are mutually exclusive.";
}
{
assertion = cfg.cookies.fromBrowser.browser != null &&
!(builtins.elem cfg.cookies.fromBrowser.browser [ "brave" "chrome" "chromium" "edge" "opera" "vivaldi" ]) &&
cfg.cookies.fromBrowser.keyring != null;
message = "The `services.ytdl-web.cookies.fromBrowser.keyring` only functions with a chromium-based browser.";
}
{
assertion = cfg.cookies.fromBrowser.browser != null &&
cfg.cookies.fromBrowser.browser != "firefox" &&
cfg.cookies.fromBrowser.container != null;
message = "The `services.ytdl-web.cookies.fromBrowser.container` only functions with the firefox browser.";
}
];
systemd.services.ytdl-web = {
description = "ytdl-web";
after = [ "networking.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
YTDL_ENV = cfg.appEnvironment;
YTDL_BINARY_PATH = toString cfg.ytdlPackage;
YTDL_HTTP_PORT = toString cfg.port;
YTDL_HTTP_LISTEN = cfg.listen;
YTDL_HTTP_BASEPATH = cfg.basePath;
YTDL_CACHE_TTL = cfg.cacheTTL;
YTDL_CACHE_DIRPATH = cfg.cacheDir;
YTDL_COOKIES_ENABLED = if cfg.cookies.enable then "true" else "false";
YTDL_COOKIES_FILEPATH = cfg.cookies.file;
YTDL_COOKIES_FROMBROWSER_BROWSER = cfg.cookies.fromBrowser.browser;
YTDL_COOKIES_FROMBROWSER_KEYRING = cfg.cookies.fromBrowser.keyring;
YTDL_COOKIES_FROMBROWSER_PROFILE = cfg.cookies.fromBrowser.profile;
YTDL_COOKIES_FROMBROWSER_CONTAINER = cfg.cookies.fromBrowser.container;
};
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart = "${lib.getExe cfg.package} serve";
Restart = "on-failure";
};
};
systemd.tmpfiles.rules = [
"d ${cfg.cacheDir} 0770 ${cfg.user} ${cfg.group} -"
];
networking.firewall = mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
};
users = {
users = mkIf (cfg.user == "ytdl-web") {
ytdl-web = {
isSystemUser = true;
group = cfg.group;
};
};
groups = mkIf (cfg.group == "ytdl-web") {
ytdl-web = { };
};
};
};
}

31
nix/package.nix Normal file
View File

@ -0,0 +1,31 @@
{ buildGoModule
, installShellFiles
, lib
, rev
, version
, ...
}:
buildGoModule rec {
pname = "ytdl-web";
inherit version;
src = ./..;
vendorHash = "sha256-Rqh5tGcSey53e0Ln3u5agvOwRJ6/I1eUpzRylwtjhQo=";
ldflags = [
"-s"
"-w"
"-X $VERSION_PKG.Version=${version}"
"-X $VERSION_PKG.Build=${rev}"
"-X $VERSION_PKG.BuildDate=1970-01-01T0:00:00+0000"
"-X $VERSION_PKG.BuiltBy=nix"
];
meta = with lib; {
description = "Yet another yt-dlp web frontend written in Go.";
homepage = "https://git.fifitido.net/apps/ytdl-web";
license = licenses.gpl3Only;
mainProgram = "ytdl-web";
};
}

View File

@ -0,0 +1,29 @@
package components
import (
"go.fifitido.net/ytdl-web/pkg/ytdl"
"go.fifitido.net/ytdl-web/version"
)
templ Footer() {
<footer class="footer mt-auto py-3 bg-body-tertiary" style="font-size: 0.95rem">
<div class="container">
<div class="row gap-2 gap-md-0">
<div class="d-flex gap-4 col-md justify-content-center">
<div class="d-flex gap-1 align-items-baseline">
Version:
<span class="text-muted">{ version.Version }</span>
<span class="text-muted text-nowrap" style="font-size: smaller">(Build: { version.Build })</span>
</div>
<div class="d-flex gap-1 align-items-baseline text-nowrap">
yt-dlp version: <span class="text-muted">{ ytdl.Default().Version() }</span>
</div>
</div>
<div class="d-flex gap-2 col-md justify-content-center">
<a href="https://git.fifitido.net/apps/ytdl-web">Git Repository</a>
<span class="text-muted">&copy; 2024 FiFiTiDo</span>
</div>
</div>
</div>
</footer>
}

View File

@ -0,0 +1,84 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"go.fifitido.net/ytdl-web/pkg/ytdl"
"go.fifitido.net/ytdl-web/version"
)
func Footer() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<footer class=\"footer mt-auto py-3 bg-body-tertiary\" style=\"font-size: 0.95rem\"><div class=\"container\"><div class=\"row gap-2 gap-md-0\"><div class=\"d-flex gap-4 col-md justify-content-center\"><div class=\"d-flex gap-1 align-items-baseline\">Version: <span class=\"text-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/components/footer.templ`, Line: 15, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span> <span class=\"text-muted text-nowrap\" style=\"font-size: smaller\">(Build: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(version.Build)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/components/footer.templ`, Line: 16, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ")</span></div><div class=\"d-flex gap-1 align-items-baseline text-nowrap\">yt-dlp version: <span class=\"text-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(ytdl.Default().Version())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/components/footer.templ`, Line: 19, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</span></div></div><div class=\"d-flex gap-2 col-md justify-content-center\"><a href=\"https://git.fifitido.net/apps/ytdl-web\">Git Repository</a> <span class=\"text-muted\">&copy; 2024 FiFiTiDo</span></div></div></div></footer>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -0,0 +1,9 @@
package components
templ Navbar() {
<nav class="navbar navbar-dark bg-primary">
<div class="container">
<a href="/" class="navbar-brand">YTDL Web</a>
</div>
</nav>
}

View File

@ -0,0 +1,40 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func Navbar() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<nav class=\"navbar navbar-dark bg-primary\"><div class=\"container\"><a href=\"/\" class=\"navbar-brand\">YTDL Web</a></div></nav>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -0,0 +1,36 @@
package components
import "go.fifitido.net/ytdl-web/pkg/serverctx"
script pasteClipboard() {
const pasteButton = content.querySelector("#paste-button");
const urlField = content.querySelector("#url");
try {
const text = await navigator.clipboard.readText();
urlField.value = text;
} catch (error) {
toastr.error("Failed to paste url from clipboard.");
}
}
templ PasteButton() {
if serverctx.IsHTTPS(ctx) {
<button id="paste-button" class="btn btn-outline-secondary" type="button" title="Paste" onclick={ pasteClipboard() }>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
style="width: 1.5rem; height: 1.5rem"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
></path>
</svg>
</button>
}
}

View File

@ -0,0 +1,75 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "go.fifitido.net/ytdl-web/pkg/serverctx"
func pasteClipboard() templ.ComponentScript {
return templ.ComponentScript{
Name: `__templ_pasteClipboard_2c01`,
Function: `function __templ_pasteClipboard_2c01(){const pasteButton = content.querySelector("#paste-button");
const urlField = content.querySelector("#url");
try {
const text = await navigator.clipboard.readText();
urlField.value = text;
} catch (error) {
toastr.error("Failed to paste url from clipboard.");
}
}`,
Call: templ.SafeScript(`__templ_pasteClipboard_2c01`),
CallInline: templ.SafeScriptInline(`__templ_pasteClipboard_2c01`),
}
}
func PasteButton() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if serverctx.IsHTTPS(ctx) {
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, pasteClipboard())
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<button id=\"paste-button\" class=\"btn btn-outline-secondary\" type=\"button\" title=\"Paste\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 templ.ComponentScript = pasteClipboard()
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"><svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" style=\"width: 1.5rem; height: 1.5rem\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184\"></path></svg></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

104
pkg/config/var.go Normal file
View File

@ -0,0 +1,104 @@
package config
import (
"flag"
"fmt"
"os"
"strconv"
"strings"
"time"
)
func getEnvKey(key string) string {
return fmt.Sprintf("YTDL_WEB_%s", strings.ReplaceAll(strings.ToUpper(key), ".", "_"))
}
func String(key string, defaultValue string, usage string) func() string {
flagValue := flag.String(key, defaultValue, usage)
return func() string {
if flagValue != nil && *flagValue != defaultValue {
return *flagValue
}
envValue, ok := os.LookupEnv(getEnvKey(key))
if ok {
return envValue
}
return defaultValue
}
}
func Int(key string, defaultValue int, usage string) func() int {
flagValue := flag.Int(key, defaultValue, usage)
return func() int {
if flagValue != nil && *flagValue != defaultValue {
return *flagValue
}
envValue, ok := os.LookupEnv(getEnvKey(key))
if ok {
value, err := strconv.Atoi(envValue)
if err == nil {
return value
}
}
return defaultValue
}
}
func Bool(key string, defaultValue bool, usage string) func() bool {
flagValue := flag.Bool(key, defaultValue, usage)
return func() bool {
if flagValue != nil && *flagValue != defaultValue {
return *flagValue
}
envValue, ok := os.LookupEnv(getEnvKey(key))
if ok {
value, err := strconv.ParseBool(envValue)
if err == nil {
return value
}
}
return defaultValue
}
}
func StringSlice(key string, defaultValue []string, usage string) func() []string {
defaultString := strings.Join(defaultValue, ",")
flagValue := flag.String(key, defaultString, usage)
return func() []string {
if flagValue != nil && *flagValue != defaultString {
return strings.Split(*flagValue, ",")
}
envValue, ok := os.LookupEnv(getEnvKey(key))
if ok {
return strings.Split(envValue, ",")
}
return defaultValue
}
}
func Duration(key string, defaultValue time.Duration, usage string) func() time.Duration {
flagValue := flag.Duration(key, defaultValue, usage)
return func() time.Duration {
if flagValue != nil && *flagValue != defaultValue {
return *flagValue
}
envValue, ok := os.LookupEnv(getEnvKey(key))
if ok {
value, err := time.ParseDuration(envValue)
if err == nil {
return value
}
}
return defaultValue
}
}

View File

@ -1,120 +0,0 @@
package htmx
import "net/http"
type HTMX struct {
w http.ResponseWriter
r *http.Request
}
func New(w http.ResponseWriter, r *http.Request) *HTMX {
return &HTMX{
w: w,
r: r,
}
}
//
// Request header methods
//
// True when the request is an HTMX request
func (h *HTMX) IsHtmxRequest() bool {
return h.r.Header.Get("HX-Request") == "true"
}
// Indicates that the request is via an element using hx-boost
func (h *HTMX) IsBoosted() bool {
return h.r.Header.Get("HX-Boosted") == "true"
}
// The current URL of the browser
func (h *HTMX) CurrentUrl() string {
return h.r.Header.Get("HX-Current-URL")
}
// If the request is for history restoration after a miss in the local history cache
func (h *HTMX) IsHistoryRestore() bool {
return h.r.Header.Get("HX-History-Restore") == "true"
}
// The id of the element that triggered the request
func (h *HTMX) TriggerId() string {
return h.r.Header.Get("HX-Trigger")
}
// The name of the element that triggered the request
func (h *HTMX) TriggerName() string {
return h.r.Header.Get("HX-Trigger-Name")
}
// The id of the target element
func (h *HTMX) TargetId() string {
return h.r.Header.Get("HX-Target")
}
// The value entered by the user when prompted via hx-prompt
func (h *HTMX) Prompt() string {
return h.r.Header.Get("HX-Prompt")
}
//
// Response header methods
//
// Pushe a new url into the history stack
func (h *HTMX) PushUrl(url string) {
h.w.Header().Set("HX-Push", url)
}
// Trigger a client-side redirect to a new location
func (h *HTMX) RedirectTo(url string) {
h.w.Header().Set("HX-Redirect", url)
}
// Triggers a client-side redirect to a new location that acts as a swap
func (h *HTMX) Location(url string) {
h.w.Header().Set("Location", url)
}
// Replace the current URL in the location bar
func (h *HTMX) ReplaceUrl(url string) {
h.w.Header().Set("HX-Replace-Url", url)
}
// Sets refresh to true causing the client side to do a full refresh of the page
func (h *HTMX) Refresh() {
h.w.Header().Set("HX-Refresh", "true")
}
// Trigger client side events
func (h *HTMX) Trigger(name string) {
h.w.Header().Set("HX-Trigger", name)
}
// Trigger client side events after the swap step
func (h *HTMX) TriggerAfterSwap(name string) {
h.w.Header().Set("HX-Trigger-After-Swap", name)
}
// Trigger client side events after the settle step
func (h *HTMX) TriggerAfterSettle(name string) {
h.w.Header().Set("HX-Trigger-After-Settle", name)
}
// Specify how the response will be swapped. See hx-swap for possible values
func (h *HTMX) Reswap(value string) {
h.w.Header().Set("HX-Reswap", value)
}
// Update the target of the content update to a different element on the page
// Value should be a CSS selector.
func (h *HTMX) Retarget(value string) {
h.w.Header().Set("HX-Retarget", value)
}
// Choose which part of the response is used to be swapped in. Overrides an existing hx-select on the triggering element.
// Value should be a CSS selector.
func (h *HTMX) Reselect(value string) {
h.w.Header().Set("HX-Reselect", value)
}

View File

@ -1,29 +0,0 @@
package httpx
import (
"errors"
"net/http"
"strconv"
)
var (
ErrQueryKeyNotFound = errors.New("query key not found")
)
func Query(r *http.Request, key string) (string, error) {
values, ok := r.URL.Query()[key]
if !ok || len(values) == 0 {
return "", ErrQueryKeyNotFound
}
return values[0], nil
}
func QueryInt(r *http.Request, key string) (int, error) {
value, err := Query(r, key)
if err != nil {
return 0, err
}
return strconv.Atoi(value)
}

View File

@ -1,37 +0,0 @@
package middlewarex
import (
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
"golang.org/x/exp/slog"
)
func SlogRequestLogger(logger *slog.Logger) func(next http.Handler) http.Handler {
if logger == nil {
return func(next http.Handler) http.Handler { return next }
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
ti := time.Now()
defer func() {
reqLogger := logger.With(
slog.String("proto", r.Proto),
slog.String("path", r.URL.Path),
slog.String("reqId", middleware.GetReqID(r.Context())),
slog.Duration("lat", time.Since(ti)),
slog.Int("status", ww.Status()),
slog.Int("size", ww.BytesWritten()),
)
reqLogger.Info("Served")
}()
next.ServeHTTP(ww, r)
})
}
}

View File

@ -1,7 +0,0 @@
package server
import "github.com/go-chi/chi/v5"
type Controller interface {
Router(r chi.Router)
}

View File

@ -1,32 +0,0 @@
package server
import "golang.org/x/exp/slog"
type Options struct {
ListenAddr string
ListenPort int
Logger *slog.Logger
}
func DefaultOptions() *Options {
return &Options{
ListenAddr: "127.0.0.1",
ListenPort: 8080,
Logger: slog.Default(),
}
}
func (o *Options) WithListenAddr(addr string) *Options {
o.ListenAddr = addr
return o
}
func (o *Options) WithListenPort(port int) *Options {
o.ListenPort = port
return o
}
func (o *Options) WithLogger(logger *slog.Logger) *Options {
o.Logger = logger
return o
}

View File

@ -1,54 +0,0 @@
package server
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"go.fifitido.net/ytdl-web/pkg/middlewarex"
"golang.org/x/exp/slog"
)
type Server interface {
MountController(path string, controller Controller)
ListenAndServe() error
}
type DefaultServer struct {
r chi.Router
opts *Options
}
var _ Server = (*DefaultServer)(nil)
func New(options ...*Options) *DefaultServer {
var opts *Options
if len(options) > 0 {
opts = options[0]
} else {
opts = DefaultOptions()
}
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middlewarex.SlogRequestLogger(opts.Logger))
r.Use(middleware.Recoverer)
return &DefaultServer{
r: r,
opts: opts,
}
}
func (s *DefaultServer) MountController(path string, controller Controller) {
s.r.Route(path, controller.Router)
}
func (s *DefaultServer) ListenAndServe() error {
listenAddr := fmt.Sprintf("%s:%d", s.opts.ListenAddr, s.opts.ListenPort)
s.opts.Logger.Info("Starting HTTP server", slog.String("addr", s.opts.ListenAddr), slog.Int("port", s.opts.ListenPort))
return http.ListenAndServe(listenAddr, s.r)
}

37
pkg/serverctx/context.go Normal file
View File

@ -0,0 +1,37 @@
package serverctx
import (
"context"
"net/http"
)
type contextKey string
const (
isHTTPSKey contextKey = "isHTTPS"
basePathKey contextKey = "basePath"
)
func Middleware(basePath string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
isHttps := r.URL.Scheme == "https" || r.Header.Get("X-Forwarded-Proto") == "https"
ctx := context.WithValue(r.Context(), isHTTPSKey, isHttps)
ctx = context.WithValue(ctx, basePathKey, basePath)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func IsHTTPS(ctx context.Context) bool {
isHttps, ok := ctx.Value(isHTTPSKey).(bool)
return ok && isHttps
}
func BasePath(ctx context.Context) string {
basePath, ok := ctx.Value(basePathKey).(string)
if !ok {
return ""
}
return basePath
}

View File

@ -1,40 +0,0 @@
package utils
import (
"fmt"
"github.com/dgraph-io/badger/v2"
"golang.org/x/exp/slog"
)
type SlogLogger struct {
logger *slog.Logger
}
// Debugf implements badger.Logger
func (l *SlogLogger) Debugf(f string, a ...interface{}) {
l.logger.Debug(fmt.Sprintf(f, a...))
}
// Errorf implements badger.Logger
func (l *SlogLogger) Errorf(f string, a ...interface{}) {
l.logger.Error(fmt.Sprintf(f, a...))
}
// Infof implements badger.Logger
func (l *SlogLogger) Infof(f string, a ...interface{}) {
l.logger.Info(fmt.Sprintf(f, a...))
}
// Warningf implements badger.Logger
func (l *SlogLogger) Warningf(f string, a ...interface{}) {
l.logger.Warn(fmt.Sprintf(f, a...))
}
var _ badger.Logger = (*SlogLogger)(nil)
func NewBadgerLogger(logger *slog.Logger) badger.Logger {
return &SlogLogger{
logger: logger,
}
}

View File

@ -1,22 +0,0 @@
package utils
import (
"context"
"io"
"golang.org/x/exp/slog"
)
type loggerWriter struct {
logger *slog.Logger
logLevel slog.Level
}
func (lw *loggerWriter) Write(p []byte) (n int, err error) {
lw.logger.Log(context.Background(), lw.logLevel, string(p))
return len(p), nil
}
func LoggerWriter(logger *slog.Logger, level slog.Level) io.Writer {
return &loggerWriter{logger: logger, logLevel: level}
}

View File

@ -1,10 +0,0 @@
package view
import "net/http"
type Data map[string]interface{}
type Engine interface {
Load() error
Render(w http.ResponseWriter, view string, data Data, layout ...string)
}

View File

@ -1,125 +0,0 @@
package html
import (
"embed"
"html/template"
"io/fs"
"net/http"
"path/filepath"
"strings"
"sync"
"go.fifitido.net/ytdl-web/pkg/view"
)
type Engine struct {
fs embed.FS
tpl *template.Template
opts *Options
loadOnce sync.Once
mu sync.Mutex
}
var _ view.Engine = (*Engine)(nil)
func New(fs embed.FS, options ...*Options) *Engine {
var opts *Options
if len(options) > 0 && options[0] != nil {
opts = options[0]
} else {
opts = DefaultOptions()
}
tpl := template.New("/")
tpl.Delims(opts.LeftDelim, opts.RightDelim)
tpl.Funcs(opts.FuncMap)
return &Engine{
fs: fs,
tpl: tpl,
opts: opts,
}
}
func (e *Engine) Load() error {
var err error
e.loadOnce.Do(func() {
err = e.doLoad()
})
return err
}
func (e *Engine) doLoad() error {
e.mu.Lock()
defer e.mu.Unlock()
return fs.WalkDir(e.fs, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d == nil || d.IsDir() {
return nil
}
if len(path) < len(e.opts.Extension) || path[len(path)-len(e.opts.Extension):] != e.opts.Extension {
return nil
}
rel, err := filepath.Rel(e.opts.BaseDir, path)
if err != nil {
return err
}
name := filepath.ToSlash(rel)
name = strings.TrimSuffix(name, e.opts.Extension)
buf, err := e.fs.ReadFile(path)
if err != nil {
return err
}
_, err = e.tpl.New(name).Parse(string(buf))
if err != nil {
return err
}
return nil
})
}
func (e *Engine) Render(w http.ResponseWriter, view string, data view.Data, layout ...string) {
tmpl := e.tpl.Lookup(view)
if tmpl == nil {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if len(layout) > 0 && layout[0] != "" {
lay := e.tpl.Lookup(layout[0])
if lay == nil {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
e.mu.Lock()
defer e.mu.Unlock()
lay.Funcs(map[string]interface{}{
"yield": func() error {
return tmpl.Execute(w, data)
},
})
if err := lay.Execute(w, data); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
if err := tmpl.Execute(w, data); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}

View File

@ -1,54 +0,0 @@
package html
import (
"fmt"
"html/template"
)
type Options struct {
LeftDelim string
RightDelim string
Extension string
BaseDir string
FuncMap template.FuncMap
}
func DefaultOptions() *Options {
return &Options{
LeftDelim: "{{",
RightDelim: "}}",
Extension: ".html",
BaseDir: "/",
FuncMap: template.FuncMap{
"yield": func() error { return fmt.Errorf("yield called outside of a layout") },
},
}
}
func (o *Options) WithDelimiters(left, right string) *Options {
o.LeftDelim = left
o.RightDelim = right
return o
}
func (o *Options) WithExtension(ext string) *Options {
o.Extension = ext
return o
}
func (o *Options) WithBaseDir(dir string) *Options {
o.BaseDir = dir
return o
}
func (o *Options) WithFunction(name string, fn interface{}) *Options {
o.FuncMap[name] = fn
return o
}
func (o *Options) WithFunctions(funcs template.FuncMap) *Options {
for k, v := range funcs {
o.FuncMap[k] = v
}
return o
}

122
pkg/views/downloads.templ Normal file
View File

@ -0,0 +1,122 @@
package views
import (
"fmt"
"go.fifitido.net/ytdl-web/pkg/ytdl/metadata"
)
type DownloadsViewModel struct {
Url string
Meta *metadata.Metadata
}
templ videoFormatView(vm *DownloadsViewModel, vidIndex int, format metadata.Format, label string) {
{{ filename := fmt.Sprintf("%s-%s.%s", vm.Meta.ID, format.Resolution, format.Ext) }}
<div style="font-size: smaller">{ label }</div>
<div class="flex-grow-1 d-flex gap-3">
<style>
.dl-direct:after {
content: 'Download (direct)';
}
</style>
<a
class="btn btn-primary flex-grow-1 dl-direct"
download={ filename }
href={ templ.SafeURL(format.Url) }
aria-label="Direct Download"
>
// Workaround for browsers that use the content of the link as the filename
<span class="d-none">{ filename }</span>
</a>
<a
class="btn btn-primary flex-grow-1"
download={ filename }
href={ pathTo(ctx, fmt.Sprintf("/download/proxy?url=%s&format=%s&index=%d", vm.Url, format.FormatID, vidIndex)) }
aria-label="Proxied Download"
>
Download (proxied)
</a>
</div>
}
templ videoView(vm *DownloadsViewModel, index int, video *VideoViewModel) {
if index != 0 {
<hr class="mt-5"/>
}
<div class="d-flex flex-column flex-lg-row justify-content-center gap-5 mt-5">
{{
thumbnail := video.Meta.Thumbnail
thumbnailAlt := video.Meta.ID
if video.Meta.Title != nil {
thumbnailAlt = *video.Meta.Title
}
}}
if thumbnail != nil {
<div class="d-flex justify-content-center">
<img src={ *thumbnail } alt={ thumbnailAlt } style="max-height: 25rem; max-width: 100%; margin: 0 auto"/>
</div>
}
<div class={ downloads(), "flex-lg-grow-1" }>
for _, format := range video.Formats {
@videoFormatView(vm, index, format, format.Format)
}
</div>
</div>
if len(video.OtherFormats) > 0 {
{{ collapseId := fmt.Sprintf("collapse-%s-%d", vm.Meta.ID, index) }}
<div class="d-grid my-3">
<button
type="button"
class="btn btn-secondary"
data-bs-toggle="collapse"
data-bs-target={ "#" + collapseId }
aria-expanded="false"
aria-controls={ collapseId }
>
See More Formats
</button>
</div>
<div id={ collapseId } class={ fullWidth(), "collapse" }>
<div class={ downloads(), "d-flex d-md-grid flex-column" }>
for _, format := range video.OtherFormats {
{{ label := fmt.Sprintf("ext: %s, resolution: %s, filesize: %d, note: %s", format.Ext, format.Resolution, format.Filesize, format.FormatNote) }}
@videoFormatView(vm, index, format, label)
}
</div>
</div>
}
}
css downloads() {
display: grid;
grid-template-columns: minmax(auto, max-content) auto;
gap: 1.5rem;
align-items: center;
}
css fullWidth() {
grid-column: span 2;
}
templ Downloads(vm *DownloadsViewModel) {
{{ videos := GetVideosFromMetadata(vm.Meta) }}
@Layout() {
<div class="d-flex flex-column align-items-center">
<h1>Download Video</h1>
<h2 class="fs-4 text-muted text-center">{ *vm.Meta.Title }</h2>
<p style="font-size: 0.85rem">{ vm.Url }</p>
<a
href={ pathTo(ctx) }
hx-get={ pathTo(ctx) }
hx-trigger="click"
class="btn btn-secondary btn-sm mt-3"
style="width: 30rem; max-width: 100%"
>
Download Another Video
</a>
</div>
for index, video := range videos {
@videoView(vm, index, video)
}
}
}

View File

@ -0,0 +1,463 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.960
package views
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"go.fifitido.net/ytdl-web/pkg/ytdl/metadata"
)
type DownloadsViewModel struct {
Url string
Meta *metadata.Metadata
}
func videoFormatView(vm *DownloadsViewModel, vidIndex int, format metadata.Format, label string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
filename := fmt.Sprintf("%s-%s.%s", vm.Meta.ID, format.Resolution, format.Ext)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div style=\"font-size: smaller\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 15, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><div class=\"flex-grow-1 d-flex gap-3\"><style>\n\t\t .dl-direct:after {\n\t\t content: 'Download (direct)';\n\t\t }\n\t\t</style><a class=\"btn btn-primary flex-grow-1 dl-direct\" download=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(filename)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 24, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 templ.SafeURL
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(format.Url))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 25, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" aria-label=\"Direct Download\"><span class=\"d-none\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(filename)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 29, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span></a> <a class=\"btn btn-primary flex-grow-1\" download=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(filename)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 33, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 templ.SafeURL
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(pathTo(ctx, fmt.Sprintf("download/proxy?url=%s&format=%s&index=%d", vm.Url, format.FormatID, vidIndex)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 34, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" aria-label=\"Proxied Download\">Download (proxied)</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func videoView(vm *DownloadsViewModel, index int, video *VideoViewModel) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
if templ_7745c5c3_Var8 == nil {
templ_7745c5c3_Var8 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if index != 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<hr class=\"mt-5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"d-flex flex-column flex-lg-row justify-content-center gap-5 mt-5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
thumbnail := video.Meta.Thumbnail
thumbnailAlt := video.Meta.ID
if video.Meta.Title != nil {
thumbnailAlt = *video.Meta.Title
}
if thumbnail != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"d-flex justify-content-center\"><img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(*thumbnail)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 56, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(thumbnailAlt)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 56, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" style=\"max-height: 25rem; max-width: 100%; margin: 0 auto\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
var templ_7745c5c3_Var11 = []any{downloads(), "flex-lg-grow-1"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var11).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, format := range video.Formats {
templ_7745c5c3_Err = videoFormatView(vm, index, format, format.Format).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(video.OtherFormats) > 0 {
collapseId := fmt.Sprintf("collapse-%s-%d", vm.Meta.ID, index)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"d-grid my-3\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-toggle=\"collapse\" data-bs-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs("#" + collapseId)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 72, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" aria-expanded=\"false\" aria-controls=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(collapseId)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 74, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">See More Formats</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 = []any{fullWidth(), "collapse"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(collapseId)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 79, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var15).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 = []any{downloads(), "d-flex d-md-grid flex-column"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var18).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, format := range video.OtherFormats {
label := fmt.Sprintf("ext: %s, resolution: %s, filesize: %d, note: %s", format.Ext, format.Resolution, format.Filesize, format.FormatNote)
templ_7745c5c3_Err = videoFormatView(vm, index, format, label).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func downloads() templ.CSSClass {
templ_7745c5c3_CSSBuilder := templruntime.GetBuilder()
templ_7745c5c3_CSSBuilder.WriteString(`display:grid;`)
templ_7745c5c3_CSSBuilder.WriteString(`grid-template-columns:minmax(auto, max-content) auto;`)
templ_7745c5c3_CSSBuilder.WriteString(`gap:1.5rem;`)
templ_7745c5c3_CSSBuilder.WriteString(`align-items:center;`)
templ_7745c5c3_CSSID := templ.CSSID(`downloads`, templ_7745c5c3_CSSBuilder.String())
return templ.ComponentCSSClass{
ID: templ_7745c5c3_CSSID,
Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
}
}
func fullWidth() templ.CSSClass {
templ_7745c5c3_CSSBuilder := templruntime.GetBuilder()
templ_7745c5c3_CSSBuilder.WriteString(`grid-column:span 2;`)
templ_7745c5c3_CSSID := templ.CSSID(`fullWidth`, templ_7745c5c3_CSSBuilder.String())
return templ.ComponentCSSClass{
ID: templ_7745c5c3_CSSID,
Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
}
}
func Downloads(vm *DownloadsViewModel) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var20 := templ.GetChildren(ctx)
if templ_7745c5c3_Var20 == nil {
templ_7745c5c3_Var20 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
videos := GetVideosFromMetadata(vm.Meta)
templ_7745c5c3_Var21 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"d-flex flex-column align-items-center\"><h1>Download Video</h1><h2 class=\"fs-4 text-muted text-center\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(*vm.Meta.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 106, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</h2><p style=\"font-size: 0.85rem\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Url)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 107, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 templ.SafeURL
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(pathTo(ctx))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 109, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(pathTo(ctx))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/downloads.templ`, Line: 110, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" hx-trigger=\"click\" class=\"btn btn-secondary btn-sm mt-3\" style=\"width: 30rem; max-width: 100%\">Download Another Video</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for index, video := range videos {
templ_7745c5c3_Err = videoView(vm, index, video).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
templ_7745c5c3_Err = Layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var21), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

13
pkg/views/helpers.go Normal file
View File

@ -0,0 +1,13 @@
package views
import (
"context"
"strings"
"github.com/a-h/templ"
"go.fifitido.net/ytdl-web/pkg/serverctx"
)
func pathTo(ctx context.Context, path ...string) templ.SafeURL {
return templ.SafeURL(strings.TrimSuffix(serverctx.BasePath(ctx), "/") + "/" + strings.TrimPrefix(strings.Join(path, "/"), "/"))
}

66
pkg/views/home.templ Normal file
View File

@ -0,0 +1,66 @@
package views
import "go.fifitido.net/ytdl-web/pkg/components"
type Error struct {
Message string
RetryUrl *string
}
templ Home(err *Error) {
@Layout() {
<h1 class="text-center">YTDL Web</h1>
<p class="text-center mb-5">
Download videos from over a thousand websites with the help of
<a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a>, a fork of youtube-dl
with more features and fixes.
<br/>
View a complete list of supported websites
<a href="https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md">here</a>.
</p>
<form hx-get={ pathTo(ctx, "/download") } hx-trigger="submit">
<div class="mb-3">
<label for="url" class="form-label visually-hidden">Url</label>
<div class="input-group">
<input
type="url"
name="url"
id="url"
class="form-control"
required
placeholder="Enter url here then click download"
/>
@components.PasteButton()
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
Download
<div class="spinner-border spinner-border-sm htmx-indicator ms-1" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</button>
</div>
</form>
if err != nil {
<div class="alert alert-danger mt-4" role="alert">
<span>{ err.Message }</span>
if err.RetryUrl != nil {
<button
class="btn btn-link btn-sm pt-0 lh-base text-decoration-none"
hx-get={ pathTo(ctx, "/download") }
hx-trigger="click"
hx-target="#main-content"
hx-swap="innerHTML"
hx-vals={ templ.JSONString(map[string]any{"url": *err.RetryUrl}) }
>
<span class="text-decoration-underline">Try Again</span>
<div class="spinner-border spinner-border-sm htmx-indicator ms-1" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</button>
}
</div>
}
}
}

141
pkg/views/home_templ.go Normal file
View File

@ -0,0 +1,141 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.960
package views
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "go.fifitido.net/ytdl-web/pkg/components"
type Error struct {
Message string
RetryUrl *string
}
func Home(err *Error) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"text-center\">YTDL Web</h1><p class=\"text-center mb-5\">Download videos from over a thousand websites with the help of <a href=\"https://github.com/yt-dlp/yt-dlp\">yt-dlp</a>, a fork of youtube-dl with more features and fixes.<br>View a complete list of supported websites <a href=\"https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md\">here</a>.</p><form hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(pathTo(ctx, "/download"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/home.templ`, Line: 21, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" hx-trigger=\"submit\"><div class=\"mb-3\"><label for=\"url\" class=\"form-label visually-hidden\">Url</label><div class=\"input-group\"><input type=\"url\" name=\"url\" id=\"url\" class=\"form-control\" required placeholder=\"Enter url here then click download\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = components.PasteButton().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div><div class=\"d-grid\"><button type=\"submit\" class=\"btn btn-primary\">Download<div class=\"spinner-border spinner-border-sm htmx-indicator ms-1\" role=\"status\"><span class=\"visually-hidden\">Loading...</span></div></button></div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if err != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"alert alert-danger mt-4\" role=\"alert\"><span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(err.Message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/home.templ`, Line: 47, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if err.RetryUrl != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<button class=\"btn btn-link btn-sm pt-0 lh-base text-decoration-none\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(pathTo(ctx, "/download"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/home.templ`, Line: 51, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" hx-trigger=\"click\" hx-target=\"#main-content\" hx-swap=\"innerHTML\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.JSONString(map[string]any{"url": *err.RetryUrl}))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/views/home.templ`, Line: 55, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"><span class=\"text-decoration-underline\">Try Again</span><div class=\"spinner-border spinner-border-sm htmx-indicator ms-1\" role=\"status\"><span class=\"visually-hidden\">Loading...</span></div></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
templ_7745c5c3_Err = Layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

52
pkg/views/layout.templ Normal file
View File

@ -0,0 +1,52 @@
package views
import "go.fifitido.net/ytdl-web/pkg/components"
templ Layout() {
<!DOCTYPE html>
<html lang="en" class="h-100">
<head>
<meta charset="UTF-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>YTDL Web</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
crossorigin="anonymous"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/toastr@2.1.4/build/toastr.min.css"
integrity="sha256-R91pD48xW+oHbpJYGn5xR0Q7tMhH4xOrWn1QqMRINtA="
crossorigin="anonymous"
/>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" defer></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.4/jquery.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/toastr@2.1.4/build/toastr.min.js" integrity="sha256-Hgwq1OBpJ276HUP9H3VJkSv9ZCGRGQN+JldPJ8pNcUM=" crossorigin="anonymous" defer></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@1.9.4/dist/htmx.min.js" integrity="sha256-XIivRAE99i/eil5P31JNihaDSiix0V40rgmUrCfNTH4=" crossorigin="anonymous"></script>
<style>
#toast-container>div {
-moz-box-shadow: none !important;
-webkit-box-shadow: none !important;
box-shadow: none !important;
-ms-filter: none;
filter: none;
opacity: 1;
}
</style>
</head>
<body class="d-flex flex-column h-100" data-bs-theme="dark">
<div class="flex-shrink-0">
@components.Navbar()
@templ.Fragment("main-content") {
<main id="main-content" class="container my-5" hx-target="#main-content" hx-swap="outerHTML">
{ children... }
</main>
}
</div>
@components.Footer()
</body>
</html>
}

88
pkg/views/layout_templ.go Normal file
View File

@ -0,0 +1,88 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.960
package views
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "go.fifitido.net/ytdl-web/pkg/components"
func Layout() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\" class=\"h-100\"><head><meta charset=\"UTF-8\"><meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>YTDL Web</title><link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css\" rel=\"stylesheet\" integrity=\"sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN\" crossorigin=\"anonymous\"><link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/toastr@2.1.4/build/toastr.min.css\" integrity=\"sha256-R91pD48xW+oHbpJYGn5xR0Q7tMhH4xOrWn1QqMRINtA=\" crossorigin=\"anonymous\"><script src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js\" integrity=\"sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL\" crossorigin=\"anonymous\" defer></script><script src=\"https://ajax.googleapis.com/ajax/libs/jquery/3.6.4/jquery.min.js\" defer></script><script src=\"https://cdn.jsdelivr.net/npm/toastr@2.1.4/build/toastr.min.js\" integrity=\"sha256-Hgwq1OBpJ276HUP9H3VJkSv9ZCGRGQN+JldPJ8pNcUM=\" crossorigin=\"anonymous\" defer></script><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@1.9.4/dist/htmx.min.js\" integrity=\"sha256-XIivRAE99i/eil5P31JNihaDSiix0V40rgmUrCfNTH4=\" crossorigin=\"anonymous\"></script><style>\n \t #toast-container>div {\n -moz-box-shadow: none !important;\n -webkit-box-shadow: none !important;\n box-shadow: none !important;\n -ms-filter: none;\n filter: none;\n opacity: 1;\n \t }\n </style></head><body class=\"d-flex flex-column h-100\" data-bs-theme=\"dark\"><div class=\"flex-shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = components.Navbar().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main id=\"main-content\" class=\"container my-5\" hx-target=\"#main-content\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = templ.Fragment("main-content").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = components.Footer().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

28
pkg/views/render.go Normal file
View File

@ -0,0 +1,28 @@
package views
import (
"log/slog"
"net/http"
"github.com/a-h/templ"
)
func Handler(component templ.Component) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
Render(w, r, component)
}
}
func Render(w http.ResponseWriter, r *http.Request, component templ.Component) {
isHtmx := r.Header.Get("HX-Request") == "true"
if isHtmx {
if err := templ.RenderFragments(r.Context(), w, component, "main-content"); err != nil {
slog.ErrorContext(r.Context(), "failed to render page", slog.Any("error", err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
component.Render(r.Context(), w)
}

46
pkg/views/video.go Normal file
View File

@ -0,0 +1,46 @@
package views
import (
"go.fifitido.net/ytdl-web/pkg/ytdl/metadata"
)
type VideoViewModel struct {
Meta *metadata.Metadata
Formats []metadata.Format
OtherFormats []metadata.Format
}
func GetVideosFromMetadata(meta *metadata.Metadata) []*VideoViewModel {
if meta.IsPlaylist() {
videos := make([]*VideoViewModel, 0, len(meta.Entries))
for _, entry := range meta.Entries {
videos = append(videos, GetVideosFromMetadata(&entry)...)
}
return videos
}
formats := []metadata.Format{}
otherFormats := []metadata.Format{}
for _, format := range meta.Formats {
if format.ACodec != "none" && format.VCodec != "none" && format.Protocol != "m3u8_native" {
formats = append(formats, format)
} else {
otherFormats = append(otherFormats, format)
}
}
for i, j := 0, len(formats)-1; i < j; i, j = i+1, j-1 {
formats[i], formats[j] = formats[j], formats[i]
}
return []*VideoViewModel{
{
Meta: meta,
Formats: formats,
OtherFormats: otherFormats,
},
}
}

View File

@ -10,18 +10,21 @@ import (
type MetadataCache interface { type MetadataCache interface {
Get(key string) (*metadata.Metadata, error) Get(key string) (*metadata.Metadata, error)
Set(key string, value *metadata.Metadata, ttl time.Duration) error Set(key string, value *metadata.Metadata) error
} }
type DefaultMetadataCache struct { type DefaultMetadataCache struct {
db *badger.DB db *badger.DB
ttl time.Duration
} }
func NewDefaultMetadataCache(db *badger.DB) *DefaultMetadataCache { func NewDefaultMetadataCache(db *badger.DB, ttl time.Duration) *DefaultMetadataCache {
return &DefaultMetadataCache{ return &DefaultMetadataCache{
db: db, db: db,
ttl: ttl,
} }
} }
func (c *DefaultMetadataCache) Get(key string) (*metadata.Metadata, error) { func (c *DefaultMetadataCache) Get(key string) (*metadata.Metadata, error) {
value := &metadata.Metadata{} value := &metadata.Metadata{}
err := c.db.View(func(txn *badger.Txn) error { err := c.db.View(func(txn *badger.Txn) error {
@ -37,13 +40,13 @@ func (c *DefaultMetadataCache) Get(key string) (*metadata.Metadata, error) {
return value, err return value, err
} }
func (c *DefaultMetadataCache) Set(key string, value *metadata.Metadata, ttl time.Duration) error { func (c *DefaultMetadataCache) Set(key string, value *metadata.Metadata) error {
return c.db.Update(func(txn *badger.Txn) error { return c.db.Update(func(txn *badger.Txn) error {
data, err := json.Marshal(value) data, err := json.Marshal(value)
if err != nil { if err != nil {
return err return err
} }
e := badger.NewEntry([]byte(key), data).WithTTL(ttl) e := badger.NewEntry([]byte(key), data).WithTTL(c.ttl)
return txn.SetEntry(e) return txn.SetEntry(e)
}) })
} }

View File

@ -1,52 +0,0 @@
package ytdl
import (
"bytes"
"encoding/json"
"os/exec"
"go.fifitido.net/ytdl-web/pkg/ytdl/metadata"
)
func Exec(binary, url string, options ...Option) error {
opts := Options{
args: []string{url},
stdin: nil,
stdout: nil,
stderr: nil,
output: nil,
}
for _, opt := range options {
if err := opt(&opts); err != nil {
return err
}
}
cmd := exec.Command(binary, opts.args...)
if opts.stdin != nil {
cmd.Stdin = opts.stdin
}
if opts.stdout != nil {
cmd.Stdout = opts.stdout
}
if opts.stderr != nil {
cmd.Stderr = opts.stderr
}
if err := cmd.Run(); err != nil {
return err
}
buf, bufOk := opts.stdout.(*bytes.Buffer)
meta, metaOk := opts.output.(*metadata.Metadata)
if bufOk && metaOk {
if err := json.Unmarshal(buf.Bytes(), meta); err != nil {
return err
}
}
return nil
}

View File

@ -1,109 +0,0 @@
package ytdl
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
"go.fifitido.net/ytdl-web/pkg/utils"
"go.fifitido.net/ytdl-web/pkg/ytdl/metadata"
"golang.org/x/exp/slog"
)
type Options struct {
args []string
stdin io.Reader
stdout io.Writer
stderr io.Writer
output interface{}
}
type Option func(*Options) error
func WithFormat(format string) Option {
return func(opts *Options) error {
opts.args = append(opts.args, "--format", format)
return nil
}
}
func WithCookieFile(cookieFile string) Option {
return func(opts *Options) error {
opts.args = append(opts.args, "--cookies", cookieFile)
return nil
}
}
func WithDumpJson(meta *metadata.Metadata) Option {
return func(opts *Options) error {
opts.args = append(opts.args, "--dump-single-json")
opts.stdout = new(bytes.Buffer)
opts.output = meta
return nil
}
}
func WithLoadJson(metadata *metadata.Metadata) Option {
return func(opts *Options) error {
opts.args = append(opts.args, "--load-info-json", "-")
json, err := json.Marshal(metadata)
if err != nil {
return err
}
opts.stdin = bytes.NewBuffer(json)
return nil
}
}
func WithBrowserCookies(browser, keyring, profile, container string) Option {
return func(opts *Options) error {
var sb strings.Builder
sb.WriteString(browser)
if keyring != "" {
sb.WriteByte('+')
sb.WriteString(keyring)
}
if profile != "" {
sb.WriteByte(':')
sb.WriteString(profile)
}
if container != "" {
sb.WriteByte(':')
sb.WriteByte(':')
sb.WriteString(container)
}
opts.args = append(opts.args, "--cookies-from-browser", sb.String())
return nil
}
}
func WithStreamOutput(output io.Writer) Option {
return func(opts *Options) error {
opts.args = append(opts.args, "--output", "-")
opts.stdout = output
return nil
}
}
func WithDebug() Option {
return func(opts *Options) error {
opts.stdout = utils.LoggerWriter(slog.With("module", "ytdl"), slog.LevelDebug)
opts.stderr = utils.LoggerWriter(slog.With("module", "ytdl"), slog.LevelDebug)
return nil
}
}
func WithPlaylistIndex(index int) Option {
return func(opts *Options) error {
opts.args = append(opts.args, "--playlist-items", fmt.Sprint(index+1))
return nil
}
}

24
pkg/ytdl/config.go Normal file
View File

@ -0,0 +1,24 @@
package ytdl
type CookiesFromBrowserConfig struct {
Browser string
Keyring string
Profile string
Container string
}
type CookiesConfig struct {
Enabled bool
FilePath string
FromBrowser CookiesFromBrowserConfig
}
type Config struct {
BinaryPath string
CookiesEnabled bool
CookiesFilePath string
CookiesBrowser string
CookiesBrowserKeyring string
CookiesBrowserProfile string
CookiesBrowserContainer string
}

19
pkg/ytdl/error.go Normal file
View File

@ -0,0 +1,19 @@
package ytdl
type Error struct {
stdout string
stderr string
child error
}
func (e *Error) Error() string {
return e.child.Error()
}
func (e *Error) Stdout() string {
return e.stdout
}
func (e *Error) Stderr() string {
return e.stderr
}

View File

@ -2,30 +2,44 @@ package ytdl
import ( import (
"bytes" "bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io" "io"
"os/exec" "os/exec"
"strings" "strings"
"go.fifitido.net/ytdl-web/config" "log/slog"
"go.fifitido.net/ytdl-web/pkg/ytdl/cache" "go.fifitido.net/ytdl-web/pkg/ytdl/cache"
"go.fifitido.net/ytdl-web/pkg/ytdl/metadata" "go.fifitido.net/ytdl-web/pkg/ytdl/metadata"
"golang.org/x/exp/slog"
) )
type Ytdl interface { type Ytdl interface {
GetMetadata(url string) (*metadata.Metadata, error) GetMetadata(ctx context.Context, url string) (*metadata.Metadata, error)
Download(w io.Writer, url, format string, index int) error Download(ctx context.Context, w io.Writer, url, format string, index int) error
Version() string Version() string
} }
type ytdlImpl struct { type ytdlImpl struct {
cfg *config.Config cfg *Config
logger *slog.Logger logger *slog.Logger
cache cache.MetadataCache cache cache.MetadataCache
version string version string
} }
func NewYtdl(cfg *config.Config, logger *slog.Logger, cache cache.MetadataCache) Ytdl { var defaultYtdl Ytdl = &ytdlImpl{}
func SetDefault(y Ytdl) {
defaultYtdl = y
}
func Default() Ytdl {
return defaultYtdl
}
func NewYtdl(cfg *Config, logger *slog.Logger, cache cache.MetadataCache) Ytdl {
cmd := exec.Command( cmd := exec.Command(
cfg.BinaryPath, cfg.BinaryPath,
"--version", "--version",
@ -42,32 +56,44 @@ func NewYtdl(cfg *config.Config, logger *slog.Logger, cache cache.MetadataCache)
} }
} }
func (y *ytdlImpl) baseOptions(url string) []Option { func buildBrowserCookieString(browser, keyring, profile, container string) string {
options := []Option{} var sb strings.Builder
sb.WriteString(browser)
metadata, err := y.cache.Get(url) if keyring != "" {
if err == nil { sb.WriteByte('+')
options = append(options, WithLoadJson(metadata)) sb.WriteString(keyring)
} }
if y.cfg.Cookies.Enabled { if profile != "" {
if y.cfg.Cookies.FromBrowser.Browser != "" { sb.WriteByte(':')
options = append(options, WithBrowserCookies( sb.WriteString(profile)
y.cfg.Cookies.FromBrowser.Browser, }
y.cfg.Cookies.FromBrowser.Keyring,
y.cfg.Cookies.FromBrowser.Profile, if container != "" {
y.cfg.Cookies.FromBrowser.Container, sb.WriteByte(':')
sb.WriteByte(':')
sb.WriteString(container)
}
return sb.String()
}
func (y *ytdlImpl) appendCookieArgs(args []string) []string {
if y.cfg.CookiesEnabled {
if y.cfg.CookiesBrowser != "" {
args = append(args, "--cookies-from-browser", buildBrowserCookieString(
y.cfg.CookiesBrowser,
y.cfg.CookiesBrowserKeyring,
y.cfg.CookiesBrowserProfile,
y.cfg.CookiesBrowserContainer,
)) ))
} else { } else {
options = append(options, WithCookieFile(y.cfg.Cookies.FilePath)) args = append(args, "--cookies", y.cfg.CookiesFilePath)
} }
} }
if y.cfg.IsDevelopment() { return args
options = append(options, WithDebug())
}
return options
} }
func (y *ytdlImpl) Version() string { func (y *ytdlImpl) Version() string {
@ -75,24 +101,49 @@ func (y *ytdlImpl) Version() string {
} }
// GetMetadata implements Ytdl // GetMetadata implements Ytdl
func (y *ytdlImpl) GetMetadata(url string) (*metadata.Metadata, error) { func (y *ytdlImpl) GetMetadata(ctx context.Context, url string) (*metadata.Metadata, error) {
meta, err := y.cache.Get(url) meta, err := y.cache.Get(url)
if err == nil { if err == nil {
return meta, nil return meta, nil
} }
meta = &metadata.Metadata{} args := []string{
options := append( url,
y.baseOptions(url), "--dump-single-json",
WithDumpJson(meta), }
)
if err := Exec(y.cfg.BinaryPath, url, options...); err != nil { args = y.appendCookieArgs(args)
y.logger.Error("failed to get metadata", slog.String("url", url), slog.String("error", err.Error()))
fmt.Printf("ytdlp args: %#v\n", args)
cmd := exec.CommandContext(ctx, y.cfg.BinaryPath, args...)
out, err := cmd.Output()
if err != nil {
attrs := []any{
slog.String("url", url),
}
exiterr := &exec.ExitError{}
if errors.As(err, &exiterr) {
attrs = append(attrs, slog.Int("code", exiterr.ExitCode()))
attrs = append(attrs, slog.String("stderr", string(exiterr.Stderr)))
attrs = append(attrs, slog.String("error", exiterr.Error()))
} else {
attrs = append(attrs, slog.String("error", err.Error()))
}
y.logger.Error("failed to get metadata", attrs...)
return nil, err return nil, err
} }
if err := y.cache.Set(url, meta, y.cfg.Cache.TTL); err != nil { meta = &metadata.Metadata{}
if err := json.Unmarshal(out, meta); err != nil {
y.logger.Error("failed to unmarshal metadata", slog.String("url", url), slog.String("error", err.Error()))
return nil, err
}
if err := y.cache.Set(url, meta); err != nil {
y.logger.Warn("failed to cache metadata", slog.String("url", url), slog.String("error", err.Error())) y.logger.Warn("failed to cache metadata", slog.String("url", url), slog.String("error", err.Error()))
} }
@ -100,18 +151,55 @@ func (y *ytdlImpl) GetMetadata(url string) (*metadata.Metadata, error) {
} }
// Download implements Ytdl // Download implements Ytdl
func (y *ytdlImpl) Download(w io.Writer, url, format string, index int) error { func (y *ytdlImpl) Download(ctx context.Context, w io.Writer, url, format string, index int) error {
options := append( args := []string{
y.baseOptions(url), url,
WithFormat(format), "--format", format,
WithStreamOutput(w), "--output", "-",
)
if index >= 0 {
options = append(options, WithPlaylistIndex(index))
} }
if err := Exec(y.cfg.BinaryPath, url, options...); err != nil { if index >= 0 {
args = append(args, "--playlist-ites", fmt.Sprint(index+1))
}
args = y.appendCookieArgs(args)
metadata, err := y.cache.Get(url)
if err == nil {
args = append(args, "--load-info-json", "-")
}
cmd := exec.CommandContext(ctx, y.cfg.BinaryPath, args...)
cmd.Stdout = w
if err == nil {
json, err := json.Marshal(metadata)
if err != nil {
return err
}
cmd.Stdin = bytes.NewReader(json)
}
if err := cmd.Run(); err != nil {
exitErr := &exec.ExitError{}
if errors.As(err, &exitErr) {
if exitErr.ExitCode() == -1 {
// Handle the case where the process was terminated by a signal
return nil
}
attrs := []any{
slog.Int("code", exitErr.ExitCode()),
slog.String("stderr", string(exitErr.Stderr)),
slog.String("error", exitErr.Error()),
}
y.logger.Error("failed to download", attrs...)
return err
}
y.logger.Error("failed to download", slog.String("url", url), slog.String("error", err.Error()))
return err return err
} }