Add the YouTube submission path
Step 5. A URL goes through the same pipeline as an upload — it just gains a download step and a source_url. - The host allowlist is checked on the parsed hostname before yt-dlp is invoked, so lookalikes and userinfo tricks are refused too - yt-dlp -J reads metadata synchronously with a 15s timeout; a timeout leaves the fields blank rather than failing the submission - Over-long tracks are refused from that metadata, before a byte is downloaded - Failed URL submissions offer Yritä uudelleen with the typed text intact; uploads cannot retry, so they offer re-upload Prefill takes track then title, and artist then creator then uploader, and leaves a field blank rather than inventing one. testdata/ytdlp-noose.json is a real dump of an ordinary upload, which has none of the music fields. Also fixes a URL-only submit being blocked by the file input's required attribute — HTML cannot express "one of these two", so the server says it. The image now takes yt-dlp from Alpine 3.24 instead of pip, which drops python3 and pip entirely; see decision 19.
This commit is contained in:
+5
-5
@@ -1,14 +1,14 @@
|
|||||||
FROM golang:1.24-alpine AS build
|
FROM golang:1.26-alpine AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 go build -o /levyraati .
|
RUN CGO_ENABLED=0 go build -o /levyraati .
|
||||||
|
|
||||||
FROM alpine:3.21
|
FROM alpine:3.24
|
||||||
# yt-dlp rots against YouTube, so it is installed unpinned at build time and updated by rebuilding.
|
# yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current
|
||||||
RUN apk add --no-cache ffmpeg python3 py3-pip ca-certificates \
|
# release), so a rebuild is the update — and this avoids python3 + pip in the image entirely.
|
||||||
&& pip install --break-system-packages --no-cache-dir -U yt-dlp
|
RUN apk add --no-cache ffmpeg yt-dlp ca-certificates
|
||||||
COPY --from=build /levyraati /usr/local/bin/levyraati
|
COPY --from=build /levyraati /usr/local/bin/levyraati
|
||||||
ENV STORAGE_DIR=/storage
|
ENV STORAGE_DIR=/storage
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ endpoint and no recovery key — the credentials are the environment.
|
|||||||
|
|
||||||
### yt-dlp goes stale
|
### yt-dlp goes stale
|
||||||
|
|
||||||
yt-dlp needs regular updates to keep working against YouTube. It is installed with
|
yt-dlp needs regular updates to keep working against YouTube. It comes from Alpine's community
|
||||||
`pip install -U yt-dlp` at image build time, so rebuilding is how you update it:
|
repository, whose active branch tracks upstream closely, so rebuilding is how you update it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose build --no-cache app && docker compose up -d app
|
docker compose build --no-cache app && docker compose up -d app
|
||||||
|
|||||||
+6
-2
@@ -66,8 +66,12 @@ and `storage` was test data, so **the schema has no legacy to respect.**
|
|||||||
18. **The issue reporter ships in v1.** One table and two handlers, and the month it is most needed
|
18. **The issue reporter ships in v1.** One table and two handlers, and the month it is most needed
|
||||||
is the first one. Members never touch the Gitea tracker; the admin transcribes anything worth
|
is the first one. Members never touch the Gitea tracker; the admin transcribes anything worth
|
||||||
tracking.
|
tracking.
|
||||||
19. **yt-dlp: `pip install -U yt-dlp` at image build, rebuild monthly.** Pinning only schedules the
|
19. **yt-dlp is updated by rebuilding the image, monthly.** Pinning a version only schedules the
|
||||||
breakage for a moment you did not choose.
|
breakage for a moment you did not choose. Originally `pip install -U yt-dlp`; changed on
|
||||||
|
2026-07-31 to `apk add yt-dlp` once Alpine 3.24 turned out to carry the current release
|
||||||
|
(2026.07.04, four weeks old) — which drops python3 and pip from the image entirely. The apk
|
||||||
|
route inherits Alpine's packaging lag, so pip is the fallback if it ever goes stale at a bad
|
||||||
|
moment. Note that this only holds on the *active* branch: 3.21 was 16 months behind.
|
||||||
20. **Parity plus YouTube, then iterate.** Nothing from the old `docs/IDEAS.md` and nothing from the
|
20. **Parity plus YouTube, then iterate.** Nothing from the old `docs/IDEAS.md` and nothing from the
|
||||||
unbuilt-stats list ships in v1.
|
unbuilt-stats list ships in v1.
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -288,7 +288,9 @@ but its submitter, so a broken pipeline has no other way of announcing itself.
|
|||||||
|
|
||||||
### 4.7 Operational notes
|
### 4.7 Operational notes
|
||||||
|
|
||||||
- **yt-dlp rots.** `pip install -U yt-dlp` at image build; rebuild monthly.
|
- **yt-dlp rots.** Installed with `apk add yt-dlp` from Alpine's active branch, which tracks
|
||||||
|
upstream closely; rebuild monthly. If the packaged version ever lags at a bad moment,
|
||||||
|
`pip install -U yt-dlp` is the fallback — at the cost of python3 and pip in the image.
|
||||||
- Downloading YouTube audio is against YouTube's ToS. This is a private app among friends; the
|
- Downloading YouTube audio is against YouTube's ToS. This is a private app among friends; the
|
||||||
decision is deliberate rather than accidental.
|
decision is deliberate rather than accidental.
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ func (a *app) memberMux() *http.ServeMux {
|
|||||||
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
|
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
|
||||||
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
|
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
|
||||||
mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish))
|
mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish))
|
||||||
|
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
|
||||||
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
|
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -95,6 +96,86 @@ func clean(s string, max int) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hosts yt-dlp is allowed to see. Validated before the URL goes anywhere near a subprocess
|
||||||
|
// argument list — and it never goes through a shell.
|
||||||
|
var allowedHosts = map[string]bool{
|
||||||
|
"youtube.com": true, "www.youtube.com": true, "m.youtube.com": true,
|
||||||
|
"youtu.be": true, "www.youtu.be": true, "music.youtube.com": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowedYouTubeURL(raw string) (string, bool) {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if !allowedHosts[strings.ToLower(u.Hostname())] {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return u.String(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
type ytOutput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Track string `json:"track"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Creator string `json:"creator"`
|
||||||
|
Uploader string `json:"uploader"`
|
||||||
|
Duration float64 `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// youtubeMeta asks yt-dlp for metadata only — no download. A 1–3 s network call, so the handler
|
||||||
|
// gives it 15 s and renders blank fields on timeout rather than failing the submission.
|
||||||
|
func youtubeMeta(ctx context.Context, url string) (probeResult, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := exec.CommandContext(ctx, "yt-dlp", "-J", "--no-playlist", "--no-warnings", url).Output()
|
||||||
|
if err != nil {
|
||||||
|
return probeResult{}, err
|
||||||
|
}
|
||||||
|
return parseYouTubeMeta(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// track/artist exist only for Topic channels, YouTube Music entries and videos with a "Music in
|
||||||
|
// this video" panel. An ordinary upload gives a title and an uploader and nothing else — and if a
|
||||||
|
// field resolves to empty it stays empty, because a blank field prompts the submitter while a
|
||||||
|
// plausible "Unknown" does not.
|
||||||
|
func parseYouTubeMeta(jsonBytes []byte) (probeResult, error) {
|
||||||
|
var y ytOutput
|
||||||
|
if err := json.Unmarshal(jsonBytes, &y); err != nil {
|
||||||
|
return probeResult{}, err
|
||||||
|
}
|
||||||
|
title := y.Track
|
||||||
|
if title == "" {
|
||||||
|
title = y.Title
|
||||||
|
}
|
||||||
|
artist := firstOf(map[string]string{
|
||||||
|
"artist": y.Artist, "creator": y.Creator, "uploader": y.Uploader,
|
||||||
|
}, "artist", "creator", "uploader")
|
||||||
|
return probeResult{
|
||||||
|
Title: clean(title, 100),
|
||||||
|
Artist: clean(artist, 100),
|
||||||
|
Duration: time.Duration(y.Duration * float64(time.Second)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// download fetches the best audio-only stream. The extension is whatever YouTube served, so the
|
||||||
|
// caller globs for it — ffmpeg does not care which container it gets.
|
||||||
|
func downloadYouTube(ctx context.Context, url, outTemplate string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "yt-dlp",
|
||||||
|
"-f", "bestaudio", "--no-playlist", "--max-filesize", "100M",
|
||||||
|
"--no-warnings", "-o", outTemplate, url)
|
||||||
|
var stderr strings.Builder
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return tail(stderr.String(), 400), err
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio.
|
// convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio.
|
||||||
// No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth
|
// No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth
|
||||||
// showing — "Invalid data found when processing input" beats "submission failed".
|
// showing — "Invalid data found when processing input" beats "submission failed".
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAllowedYouTubeURL(t *testing.T) {
|
||||||
|
for _, ok := range []string{
|
||||||
|
"https://www.youtube.com/watch?v=XnfMBo4IQ-g",
|
||||||
|
"https://youtu.be/XnfMBo4IQ-g",
|
||||||
|
"https://music.youtube.com/watch?v=XnfMBo4IQ-g",
|
||||||
|
"http://m.youtube.com/watch?v=XnfMBo4IQ-g",
|
||||||
|
} {
|
||||||
|
if _, allowed := allowedYouTubeURL(ok); !allowed {
|
||||||
|
t.Errorf("%q was rejected", ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []string{
|
||||||
|
"",
|
||||||
|
"not a url",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"https://evil.example.com/watch?v=x",
|
||||||
|
// The allowlist is on the host, so a lookalike path or userinfo must not pass.
|
||||||
|
"https://evil.example.com/www.youtube.com/watch?v=x",
|
||||||
|
"https://youtube.com.evil.example.com/watch?v=x",
|
||||||
|
"https://[email protected]/",
|
||||||
|
"-oExecuteMe",
|
||||||
|
} {
|
||||||
|
if _, allowed := allowedYouTubeURL(bad); allowed {
|
||||||
|
t.Errorf("%q was allowed", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real dump of an ordinary upload: no track, no artist, no creator, no album — just a title with
|
||||||
|
// double spaces and an uploader. This is why prefill must never invent an "Unknown".
|
||||||
|
func TestYouTubeMetaFromOrdinaryUpload(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("testdata/ytdlp-noose.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("fixture missing: %v", err)
|
||||||
|
}
|
||||||
|
meta, err := parseYouTubeMeta(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta.Title != "Sentenced Noose" {
|
||||||
|
t.Errorf("title = %q, want the cleaned video title", meta.Title)
|
||||||
|
}
|
||||||
|
if meta.Artist != "Heikki Rokkonen" {
|
||||||
|
t.Errorf("artist = %q, want the uploader as the last fallback", meta.Artist)
|
||||||
|
}
|
||||||
|
if meta.Duration != 245*time.Second {
|
||||||
|
t.Errorf("duration = %v, want 4m5s", meta.Duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYouTubeMetaPrefersMusicFields(t *testing.T) {
|
||||||
|
meta, err := parseYouTubeMeta([]byte(`{
|
||||||
|
"title": "Sentenced - Noose (Official Video)",
|
||||||
|
"track": "Noose", "artist": "Sentenced", "creator": "ignored",
|
||||||
|
"uploader": "SentencedVEVO", "duration": 245.0}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta.Title != "Noose" || meta.Artist != "Sentenced" {
|
||||||
|
t.Errorf("got %q by %q, want the track/artist fields to win", meta.Title, meta.Artist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty stays empty: a blank field prompts the submitter, a plausible "Unknown" does not.
|
||||||
|
func TestYouTubeMetaLeavesBlanksBlank(t *testing.T) {
|
||||||
|
meta, err := parseYouTubeMeta([]byte(`{"duration": 10.0}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta.Title != "" || meta.Artist != "" {
|
||||||
|
t.Errorf("got %q by %q, want both empty", meta.Title, meta.Artist)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -228,3 +228,6 @@ button.danger:hover { background: #e53935; }
|
|||||||
padding: 0.8rem 1rem; margin-bottom: 1.2rem; }
|
padding: 0.8rem 1rem; margin-bottom: 1.2rem; }
|
||||||
.status p { margin: 0 0 0.5rem; }
|
.status p { margin: 0 0 0.5rem; }
|
||||||
button:disabled { background: var(--surface-2); color: var(--muted); cursor: not-allowed; }
|
button:disabled { background: var(--surface-2); color: var(--muted); cursor: not-allowed; }
|
||||||
|
|
||||||
|
.or { text-align: center; color: var(--muted); text-transform: uppercase;
|
||||||
|
font-family: var(--font-head); margin: 0; }
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ func (s *submission) Label() string {
|
|||||||
|
|
||||||
func (s *submission) Genres() []genre { return genres }
|
func (s *submission) Genres() []genre { return genres }
|
||||||
|
|
||||||
|
// Only URL submissions can retry: an upload's temp file is gone, so that case offers re-upload.
|
||||||
|
func (s *submission) CanRetry() bool { return s.Failed() && s.SourceURL != nil }
|
||||||
|
|
||||||
// The chosen genre's Finnish label, for pages that show it rather than offer it.
|
// The chosen genre's Finnish label, for pages that show it rather than offer it.
|
||||||
func (s *submission) GenreLabel() string { return genreLabel(s.Genre) }
|
func (s *submission) GenreLabel() string { return genreLabel(s.Genre) }
|
||||||
|
|
||||||
@@ -169,8 +172,21 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A YouTube song is not a different kind of song — it just has an extra download step and a
|
||||||
|
// source_url. Both paths converge on the same worker.
|
||||||
|
if raw := r.FormValue("url"); strings.TrimSpace(raw) != "" {
|
||||||
|
a.submitURL(w, r, m.ID, raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
||||||
file, header, err := r.FormFile("audio")
|
file, header, err := r.FormFile("audio")
|
||||||
|
if errors.Is(err, http.ErrMissingFile) {
|
||||||
|
// Neither field filled. HTML cannot express "one of these two", so the server says it.
|
||||||
|
a.submitError(w, r, http.StatusUnprocessableEntity,
|
||||||
|
"Valitse äänitiedosto tai anna YouTube-linkki.")
|
||||||
|
return
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.submitError(w, r, http.StatusRequestEntityTooLarge,
|
a.submitError(w, r, http.StatusRequestEntityTooLarge,
|
||||||
"Tiedostoa ei voitu lukea. Enintään 50 MB.")
|
"Tiedostoa ei voitu lukea. Enintään 50 MB.")
|
||||||
@@ -226,10 +242,68 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("submission received", "ctx", "submissions", "submission", subID, "user", m.ID)
|
slog.Info("submission received", "ctx", "submissions", "submission", subID, "user", m.ID)
|
||||||
go a.convert(subID, src)
|
go a.process(subID, "", src)
|
||||||
http.Redirect(w, r, fmt.Sprintf("/submit/%d", subID), http.StatusSeeOther)
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", subID), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *app) submitURL(w http.ResponseWriter, r *http.Request, userID int64, raw string) {
|
||||||
|
// The allowlist is checked before yt-dlp is invoked at all.
|
||||||
|
link, ok := allowedYouTubeURL(raw)
|
||||||
|
if !ok {
|
||||||
|
a.submitError(w, r, http.StatusUnprocessableEntity,
|
||||||
|
"Vain YouTube-linkit kelpaavat (youtube.com, youtu.be, music.youtube.com).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata first, so an over-long track is refused before a byte is downloaded. A timeout is
|
||||||
|
// not fatal: blank fields are a fine outcome, since the submitter fills them in anyway.
|
||||||
|
meta, err := youtubeMeta(r.Context(), link)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("yt-dlp metadata", "ctx", "submissions", "error", err)
|
||||||
|
}
|
||||||
|
if meta.Duration > maxDuration {
|
||||||
|
a.submitError(w, r, http.StatusUnprocessableEntity, "Kappale on yli 15 minuuttia pitkä.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var subID int64
|
||||||
|
err = a.pool.QueryRow(r.Context(), `
|
||||||
|
insert into submissions (user_id, status, source_url, title, artist)
|
||||||
|
values ($1, 'queued', $2, nullif($3, ''), nullif($4, '')) returning id`,
|
||||||
|
userID, link, meta.Title, meta.Artist).Scan(&subID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("create submission", "ctx", "submissions", "error", err)
|
||||||
|
a.submitError(w, r, http.StatusInternalServerError, "Jokin meni pieleen.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("url submission received", "ctx", "submissions", "submission", subID, "user", userID)
|
||||||
|
go a.process(subID, link, "")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", subID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry re-queues a failed URL submission with the typed title and introduction intact. An upload
|
||||||
|
// cannot retry — its temp file is gone — so that case offers re-upload instead.
|
||||||
|
func (a *app) retry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s := a.loadSubmission(w, r)
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.CanRetry() {
|
||||||
|
http.Error(w, "ei uudelleenyritettävissä", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.pool.Exec(r.Context(),
|
||||||
|
`update submissions set status = 'queued', status_msg = null where id = $1`, s.ID); err != nil {
|
||||||
|
slog.Error("retry", "ctx", "submissions", "error", err, "submission", s.ID)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("submission retried", "ctx", "submissions", "submission", s.ID)
|
||||||
|
go a.process(s.ID, *s.SourceURL, "")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *app) discardSubmission(ctx context.Context, subID int64, path string) {
|
func (a *app) discardSubmission(ctx context.Context, subID int64, path string) {
|
||||||
if path != "" {
|
if path != "" {
|
||||||
os.Remove(path)
|
os.Remove(path)
|
||||||
@@ -241,12 +315,44 @@ func (a *app) discardSubmission(ctx context.Context, subID int64, path string) {
|
|||||||
|
|
||||||
// --- convert ---
|
// --- convert ---
|
||||||
|
|
||||||
func (a *app) convert(subID int64, src string) {
|
// process is the whole background half of the pipeline: download when the source is a URL, then
|
||||||
|
// convert. Everything past the two slots waits in `queued`.
|
||||||
|
func (a *app) process(subID int64, sourceURL, src string) {
|
||||||
slots <- struct{}{}
|
slots <- struct{}{}
|
||||||
defer func() { <-slots }()
|
defer func() { <-slots }()
|
||||||
|
|
||||||
// Detached from the request: the submitter's browser is long gone by now.
|
// Detached from the request: the submitter's browser is long gone by now.
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if sourceURL != "" {
|
||||||
|
a.setStatus(ctx, subID, "downloading", "")
|
||||||
|
msg, err := downloadYouTube(ctx, sourceURL, a.tmpPath(subID, ".%(ext)s"))
|
||||||
|
if err != nil {
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
a.setStatus(ctx, subID, "failed", msg)
|
||||||
|
slog.Warn("download failed", "ctx", "submissions", "submission", subID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// yt-dlp names the file after whatever container YouTube served.
|
||||||
|
matches, _ := filepath.Glob(a.tmpPath(subID, ".*"))
|
||||||
|
for _, m := range matches {
|
||||||
|
if filepath.Ext(m) != ".ogg" {
|
||||||
|
src = m
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if src == "" {
|
||||||
|
a.setStatus(ctx, subID, "failed", "lataus ei tuottanut tiedostoa")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`update submissions set tmp_path = $2 where id = $1`, subID, src); err != nil {
|
||||||
|
slog.Error("save tmp path", "ctx", "submissions", "error", err, "submission", subID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a.setStatus(ctx, subID, "converting", "")
|
a.setStatus(ctx, subID, "converting", "")
|
||||||
|
|
||||||
out := a.tmpPath(subID, ".ogg")
|
out := a.tmpPath(subID, ".ogg")
|
||||||
|
|||||||
@@ -3,9 +3,17 @@
|
|||||||
<p class="{{if .Failed}}error{{end}}"><strong>{{.Label}}</strong></p>
|
<p class="{{if .Failed}}error{{end}}"><strong>{{.Label}}</strong></p>
|
||||||
{{if .Failed}}
|
{{if .Failed}}
|
||||||
{{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}}
|
{{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}}
|
||||||
<form method="post" action="/submit/{{.ID}}/discard">
|
<div class="actions">
|
||||||
<button type="submit">Poista lähetys</button>
|
{{if .CanRetry}}
|
||||||
|
<form method="post" action="/submit/{{.ID}}/retry">
|
||||||
|
<button type="submit">Yritä uudelleen</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/submit/{{.ID}}/discard">
|
||||||
|
<button type="submit" class="danger">Poista lähetys</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{if not .CanRetry}}<p class="muted small">Lataa tiedosto uudelleen, jos haluat yrittää toisen kerran.</p>{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<!-- Outside the form and attached to it with form=, so one button both submits the metadata
|
<!-- Outside the form and attached to it with form=, so one button both submits the metadata
|
||||||
and publishes. Disabled until the audio has finished converting. -->
|
and publishes. Disabled until the audio has finished converting. -->
|
||||||
|
|||||||
@@ -5,11 +5,15 @@
|
|||||||
|
|
||||||
<form method="post" action="/submit" enctype="multipart/form-data" class="stack">
|
<form method="post" action="/submit" enctype="multipart/form-data" class="stack">
|
||||||
<label class="dropzone" id="dropzone" for="audio">
|
<label class="dropzone" id="dropzone" for="audio">
|
||||||
<input type="file" id="audio" name="audio" accept="audio/*" required>
|
<input type="file" id="audio" name="audio" accept="audio/*">
|
||||||
<span class="dz-title">Raahaa äänitiedosto tähän</span>
|
<span class="dz-title">Raahaa äänitiedosto tähän</span>
|
||||||
<span class="muted small">tai valitse napsauttamalla</span>
|
<span class="muted small">tai valitse napsauttamalla</span>
|
||||||
<span class="filename" id="filename"></span>
|
<span class="filename" id="filename"></span>
|
||||||
</label>
|
</label>
|
||||||
|
<p class="or">tai</p>
|
||||||
|
<label>YouTube-linkki
|
||||||
|
<input type="url" name="url" placeholder="https://www.youtube.com/watch?v=…">
|
||||||
|
</label>
|
||||||
<button type="submit">Lähetä</button>
|
<button type="submit">Lähetä</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user