680 lines
21 KiB
Go
680 lines
21 KiB
Go
|
package website
|
||
|
|
||
|
import (
|
||
|
"crypto/sha1"
|
||
|
"encoding/hex"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"image"
|
||
|
"io"
|
||
|
"io/fs"
|
||
|
"net/http"
|
||
|
"os"
|
||
|
"strconv"
|
||
|
"strings"
|
||
|
"time"
|
||
|
|
||
|
"git.handmade.network/hmn/hmn/src/db"
|
||
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||
|
"git.handmade.network/hmn/hmn/src/models"
|
||
|
"git.handmade.network/hmn/hmn/src/oops"
|
||
|
"git.handmade.network/hmn/hmn/src/parsing"
|
||
|
"git.handmade.network/hmn/hmn/src/templates"
|
||
|
"github.com/google/uuid"
|
||
|
"github.com/tcolgate/mp3"
|
||
|
)
|
||
|
|
||
|
type PodcastIndexData struct {
|
||
|
templates.BaseData
|
||
|
Podcast templates.Podcast
|
||
|
Episodes []templates.PodcastEpisode
|
||
|
EditUrl string
|
||
|
NewEpisodeUrl string
|
||
|
}
|
||
|
|
||
|
func PodcastIndex(c *RequestContext) ResponseData {
|
||
|
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, "")
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||
|
}
|
||
|
|
||
|
if podcastResult.Podcast == nil {
|
||
|
return FourOhFour(c)
|
||
|
}
|
||
|
|
||
|
canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||
|
}
|
||
|
|
||
|
baseData := getBaseData(c)
|
||
|
baseData.Title = podcastResult.Podcast.Title
|
||
|
|
||
|
podcastIndexData := PodcastIndexData{
|
||
|
BaseData: baseData,
|
||
|
Podcast: templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, podcastResult.ImageFile),
|
||
|
}
|
||
|
|
||
|
if canEdit {
|
||
|
podcastIndexData.EditUrl = hmnurl.BuildPodcastEdit(c.CurrentProject.Slug)
|
||
|
podcastIndexData.NewEpisodeUrl = hmnurl.BuildPodcastEpisodeNew(c.CurrentProject.Slug)
|
||
|
}
|
||
|
|
||
|
for _, episode := range podcastResult.Episodes {
|
||
|
podcastIndexData.Episodes = append(podcastIndexData.Episodes, templates.PodcastEpisodeToTemplate(c.CurrentProject.Slug, episode, 0, podcastResult.ImageFile))
|
||
|
}
|
||
|
var res ResponseData
|
||
|
err = res.WriteTemplate("podcast_index.html", podcastIndexData, c.Perf)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast index page"))
|
||
|
}
|
||
|
return res
|
||
|
}
|
||
|
|
||
|
type PodcastEditData struct {
|
||
|
templates.BaseData
|
||
|
Podcast templates.Podcast
|
||
|
Notices []templates.Notice
|
||
|
}
|
||
|
|
||
|
func PodcastEdit(c *RequestContext) ResponseData {
|
||
|
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, false, "")
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||
|
}
|
||
|
|
||
|
canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||
|
}
|
||
|
|
||
|
if podcastResult.Podcast == nil || !canEdit {
|
||
|
return FourOhFour(c)
|
||
|
}
|
||
|
|
||
|
podcast := templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, podcastResult.ImageFile)
|
||
|
baseData := getBaseData(c)
|
||
|
baseData.Breadcrumbs = []templates.Breadcrumb{{Name: podcast.Title, Url: podcast.Url}}
|
||
|
podcastEditData := PodcastEditData{
|
||
|
BaseData: baseData,
|
||
|
Podcast: podcast,
|
||
|
}
|
||
|
|
||
|
success := c.URL().Query().Get("success")
|
||
|
if success != "" {
|
||
|
podcastEditData.Notices = append(podcastEditData.Notices, templates.Notice{Class: "success", Content: "Podcast updated successfully."})
|
||
|
}
|
||
|
|
||
|
var res ResponseData
|
||
|
err = res.WriteTemplate("podcast_edit.html", podcastEditData, c.Perf)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast edit page"))
|
||
|
}
|
||
|
return res
|
||
|
}
|
||
|
|
||
|
func PodcastEditSubmit(c *RequestContext) ResponseData {
|
||
|
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, false, "")
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||
|
}
|
||
|
|
||
|
canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||
|
}
|
||
|
|
||
|
if podcastResult.Podcast == nil || !canEdit {
|
||
|
return FourOhFour(c)
|
||
|
}
|
||
|
|
||
|
c.Perf.StartBlock("PODCAST", "Handling file upload")
|
||
|
c.Perf.StartBlock("PODCAST", "Parsing form")
|
||
|
maxFileSize := int64(2 * 1024 * 1024)
|
||
|
maxBodySize := maxFileSize + 1024*1024
|
||
|
c.Req.Body = http.MaxBytesReader(c.Res, c.Req.Body, maxBodySize)
|
||
|
err = c.Req.ParseMultipartForm(maxBodySize)
|
||
|
c.Perf.EndBlock()
|
||
|
if err != nil {
|
||
|
// NOTE(asaf): The error for exceeding the max filesize doesn't have a special type, so we can't easily detect it here.
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form"))
|
||
|
}
|
||
|
|
||
|
title := c.Req.Form.Get("title")
|
||
|
if len(strings.TrimSpace(title)) == 0 {
|
||
|
// TODO(asaf): Report this back to the user
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(nil, "Missing title"))
|
||
|
}
|
||
|
description := c.Req.Form.Get("description")
|
||
|
if len(strings.TrimSpace(description)) == 0 {
|
||
|
// TODO(asaf): Report this back to the user
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(nil, "Missing description"))
|
||
|
}
|
||
|
podcastImage, header, err := c.Req.FormFile("podcast_image")
|
||
|
imageFilename := ""
|
||
|
imageWidth := 0
|
||
|
imageHeight := 0
|
||
|
if err != nil && !errors.Is(err, http.ErrMissingFile) {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read uploaded file"))
|
||
|
}
|
||
|
if header != nil {
|
||
|
if header.Size > maxFileSize {
|
||
|
// TODO(asaf): Report this back to the user
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(nil, "Filesize too big"))
|
||
|
} else {
|
||
|
c.Perf.StartBlock("PODCAST", "Decoding image")
|
||
|
config, format, err := image.DecodeConfig(podcastImage)
|
||
|
c.Perf.EndBlock()
|
||
|
if err != nil {
|
||
|
// TODO(asaf): Report this back to the user
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Can't parse podcast logo"))
|
||
|
}
|
||
|
imageWidth = config.Width
|
||
|
imageHeight = config.Height
|
||
|
if imageWidth == 0 || imageHeight == 0 {
|
||
|
// TODO(asaf): Report this back to the user
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Invalid image size"))
|
||
|
}
|
||
|
|
||
|
imageFilename = fmt.Sprintf("podcast/%s/logo%d.%s", c.CurrentProject.Slug, time.Now().UTC().Unix(), format)
|
||
|
storageFilename := fmt.Sprintf("public/media/%s", imageFilename)
|
||
|
c.Perf.StartBlock("PODCAST", "Writing image file")
|
||
|
file, err := os.Create(storageFilename)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to create local image file"))
|
||
|
}
|
||
|
podcastImage.Seek(0, io.SeekStart)
|
||
|
_, err = io.Copy(file, podcastImage)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to write image to file"))
|
||
|
}
|
||
|
file.Close()
|
||
|
podcastImage.Close()
|
||
|
c.Perf.EndBlock()
|
||
|
}
|
||
|
}
|
||
|
c.Perf.EndBlock()
|
||
|
|
||
|
c.Perf.StartBlock("SQL", "Updating podcast")
|
||
|
tx, err := c.Conn.Begin(c.Context())
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
|
||
|
}
|
||
|
defer tx.Rollback(c.Context())
|
||
|
if imageFilename != "" {
|
||
|
hasher := sha1.New()
|
||
|
podcastImage.Seek(0, io.SeekStart)
|
||
|
io.Copy(hasher, podcastImage) // NOTE(asaf): Writing to hash.Hash never returns an error according to the docs
|
||
|
sha1sum := hasher.Sum(nil)
|
||
|
var imageId int
|
||
|
err = tx.QueryRow(c.Context(),
|
||
|
`
|
||
|
INSERT INTO handmade_imagefile (file, size, sha1sum, protected, width, height)
|
||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||
|
RETURNING id
|
||
|
`,
|
||
|
imageFilename, header.Size, hex.EncodeToString(sha1sum), false, imageWidth, imageHeight,
|
||
|
).Scan(&imageId)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert image file row"))
|
||
|
}
|
||
|
_, err = tx.Exec(c.Context(),
|
||
|
`
|
||
|
UPDATE handmade_podcast
|
||
|
SET
|
||
|
title = $1,
|
||
|
description = $2,
|
||
|
image_id = $3
|
||
|
WHERE id = $4
|
||
|
`,
|
||
|
title,
|
||
|
description,
|
||
|
imageId,
|
||
|
podcastResult.Podcast.ID,
|
||
|
)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to update podcast"))
|
||
|
}
|
||
|
} else {
|
||
|
_, err = tx.Exec(c.Context(),
|
||
|
`
|
||
|
UPDATE handmade_podcast
|
||
|
SET
|
||
|
title = $1,
|
||
|
description = $2
|
||
|
WHERE id = $3
|
||
|
`,
|
||
|
title,
|
||
|
description,
|
||
|
podcastResult.Podcast.ID,
|
||
|
)
|
||
|
}
|
||
|
err = tx.Commit(c.Context())
|
||
|
c.Perf.EndBlock()
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to commit db transaction"))
|
||
|
}
|
||
|
|
||
|
return c.Redirect(hmnurl.BuildPodcastEditSuccess(c.CurrentProject.Slug), http.StatusSeeOther)
|
||
|
}
|
||
|
|
||
|
type PodcastEpisodeData struct {
|
||
|
templates.BaseData
|
||
|
Podcast templates.Podcast
|
||
|
Episode templates.PodcastEpisode
|
||
|
EditUrl string
|
||
|
}
|
||
|
|
||
|
func PodcastEpisode(c *RequestContext) ResponseData {
|
||
|
episodeGUIDStr := c.PathParams["episodeid"]
|
||
|
|
||
|
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, episodeGUIDStr)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||
|
}
|
||
|
|
||
|
if podcastResult.Podcast == nil || len(podcastResult.Episodes) == 0 {
|
||
|
return FourOhFour(c)
|
||
|
}
|
||
|
|
||
|
canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID)
|
||
|
if err != nil {
|
||
|
c.Logger.Error().Err(err).Msg("Failed to check if user can edit podcast. Assuming they can't.") // NOTE(asaf): No need to return an error response here if it failed.
|
||
|
canEdit = false
|
||
|
}
|
||
|
|
||
|
editUrl := ""
|
||
|
if canEdit {
|
||
|
editUrl = hmnurl.BuildPodcastEpisodeEdit(c.CurrentProject.Slug, podcastResult.Episodes[0].GUID.String())
|
||
|
}
|
||
|
|
||
|
podcast := templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, podcastResult.ImageFile)
|
||
|
episode := templates.PodcastEpisodeToTemplate(c.CurrentProject.Slug, podcastResult.Episodes[0], 0, podcastResult.ImageFile)
|
||
|
baseData := getBaseData(c)
|
||
|
baseData.Title = podcastResult.Podcast.Title
|
||
|
baseData.Breadcrumbs = []templates.Breadcrumb{{Name: podcast.Title, Url: podcast.Url}}
|
||
|
|
||
|
podcastEpisodeData := PodcastEpisodeData{
|
||
|
BaseData: baseData,
|
||
|
Podcast: podcast,
|
||
|
Episode: episode,
|
||
|
EditUrl: editUrl,
|
||
|
}
|
||
|
|
||
|
var res ResponseData
|
||
|
err = res.WriteTemplate("podcast_episode.html", podcastEpisodeData, c.Perf)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast episode page"))
|
||
|
}
|
||
|
return res
|
||
|
}
|
||
|
|
||
|
type PodcastEpisodeEditData struct {
|
||
|
templates.BaseData
|
||
|
IsEdit bool
|
||
|
Title string
|
||
|
Description string
|
||
|
EpisodeNumber int
|
||
|
CurrentFile string
|
||
|
EpisodeFiles []string
|
||
|
Notices []templates.Notice
|
||
|
}
|
||
|
|
||
|
func PodcastEpisodeNew(c *RequestContext) ResponseData {
|
||
|
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, false, "")
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||
|
}
|
||
|
|
||
|
canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||
|
}
|
||
|
|
||
|
if podcastResult.Podcast == nil || !canEdit {
|
||
|
return FourOhFour(c)
|
||
|
}
|
||
|
|
||
|
episodeFiles, err := GetEpisodeFiles(c.CurrentProject.Slug)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch podcast episode file list"))
|
||
|
}
|
||
|
|
||
|
podcast := templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, "")
|
||
|
var res ResponseData
|
||
|
baseData := getBaseData(c)
|
||
|
baseData.Breadcrumbs = []templates.Breadcrumb{{Name: podcast.Title, Url: podcast.Url}}
|
||
|
err = res.WriteTemplate("podcast_episode_edit.html", PodcastEpisodeEditData{
|
||
|
BaseData: baseData,
|
||
|
IsEdit: false,
|
||
|
EpisodeFiles: episodeFiles,
|
||
|
}, c.Perf)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast episode new page"))
|
||
|
}
|
||
|
return res
|
||
|
}
|
||
|
|
||
|
func PodcastEpisodeEdit(c *RequestContext) ResponseData {
|
||
|
episodeGUIDStr, found := c.PathParams["episodeid"]
|
||
|
if !found || episodeGUIDStr == "" {
|
||
|
return FourOhFour(c)
|
||
|
}
|
||
|
|
||
|
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, episodeGUIDStr)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||
|
}
|
||
|
|
||
|
canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||
|
}
|
||
|
|
||
|
if podcastResult.Podcast == nil || len(podcastResult.Episodes) == 0 || !canEdit {
|
||
|
return FourOhFour(c)
|
||
|
}
|
||
|
|
||
|
episodeFiles, err := GetEpisodeFiles(c.CurrentProject.Slug)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch podcast episode file list"))
|
||
|
}
|
||
|
episode := podcastResult.Episodes[0]
|
||
|
|
||
|
podcast := templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, "")
|
||
|
podcastEpisode := templates.PodcastEpisodeToTemplate(c.CurrentProject.Slug, episode, 0, "")
|
||
|
baseData := getBaseData(c)
|
||
|
baseData.Breadcrumbs = []templates.Breadcrumb{{Name: podcast.Title, Url: podcast.Url}, {Name: podcastEpisode.Title, Url: podcastEpisode.Url}}
|
||
|
podcastEpisodeEditData := PodcastEpisodeEditData{
|
||
|
BaseData: baseData,
|
||
|
IsEdit: true,
|
||
|
Title: episode.Title,
|
||
|
Description: episode.Description,
|
||
|
EpisodeNumber: episode.EpisodeNumber,
|
||
|
CurrentFile: episode.AudioFile,
|
||
|
EpisodeFiles: episodeFiles,
|
||
|
}
|
||
|
|
||
|
success := c.URL().Query().Get("success")
|
||
|
if success != "" {
|
||
|
podcastEpisodeEditData.Notices = append(podcastEpisodeEditData.Notices, templates.Notice{Class: "success", Content: "Podcast episode updated successfully."})
|
||
|
}
|
||
|
|
||
|
var res ResponseData
|
||
|
err = res.WriteTemplate("podcast_episode_edit.html", podcastEpisodeEditData, c.Perf)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast episode edit page"))
|
||
|
}
|
||
|
return res
|
||
|
}
|
||
|
|
||
|
func PodcastEpisodeSubmit(c *RequestContext) ResponseData {
|
||
|
episodeGUIDStr, found := c.PathParams["episodeid"]
|
||
|
|
||
|
isEdit := found && episodeGUIDStr != ""
|
||
|
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, isEdit, episodeGUIDStr)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||
|
}
|
||
|
|
||
|
canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||
|
}
|
||
|
|
||
|
if podcastResult.Podcast == nil || (isEdit && len(podcastResult.Episodes) == 0) || !canEdit {
|
||
|
return FourOhFour(c)
|
||
|
}
|
||
|
|
||
|
c.Perf.StartBlock("OS", "Fetching podcast episode files")
|
||
|
episodeFiles, err := GetEpisodeFiles(c.CurrentProject.Slug)
|
||
|
c.Perf.EndBlock()
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch podcast episode file list"))
|
||
|
}
|
||
|
|
||
|
c.Req.ParseForm()
|
||
|
title := c.Req.Form.Get("title")
|
||
|
description := c.Req.Form.Get("description")
|
||
|
episodeNumberStr := c.Req.Form.Get("episode_number")
|
||
|
episodeNumber, err := strconv.Atoi(episodeNumberStr)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to parse episode number"))
|
||
|
}
|
||
|
episodeFile := c.Req.Form.Get("episode_file")
|
||
|
found = false
|
||
|
for _, ef := range episodeFiles {
|
||
|
if episodeFile == ef {
|
||
|
found = true
|
||
|
break
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if !found {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "User-provided episode filename doesn't match existing files"))
|
||
|
}
|
||
|
|
||
|
c.Perf.StartBlock("MP3", "Parsing mp3 file for duration")
|
||
|
file, err := os.Open(fmt.Sprintf("public/media/podcast/%s/%s", c.CurrentProject.Slug, episodeFile))
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to open podcast file"))
|
||
|
}
|
||
|
|
||
|
mp3Decoder := mp3.NewDecoder(file)
|
||
|
var duration float64
|
||
|
skipped := 0
|
||
|
var decodingError error
|
||
|
var f mp3.Frame
|
||
|
for {
|
||
|
if err = mp3Decoder.Decode(&f, &skipped); err != nil {
|
||
|
if err == io.EOF {
|
||
|
break
|
||
|
} else {
|
||
|
decodingError = err
|
||
|
break
|
||
|
}
|
||
|
}
|
||
|
duration = duration + f.Duration().Seconds()
|
||
|
}
|
||
|
file.Close()
|
||
|
c.Perf.EndBlock()
|
||
|
if decodingError != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to decode mp3 file"))
|
||
|
}
|
||
|
|
||
|
c.Perf.StartBlock("MARKDOWN", "Parsing description")
|
||
|
descriptionRendered := parsing.ParsePostInput(description, parsing.RealMarkdown)
|
||
|
c.Perf.EndBlock()
|
||
|
|
||
|
guidStr := ""
|
||
|
if isEdit {
|
||
|
guidStr = podcastResult.Episodes[0].GUID.String()
|
||
|
c.Perf.StartBlock("SQL", "Updating podcast episode")
|
||
|
_, err := c.Conn.Exec(c.Context(),
|
||
|
`
|
||
|
UPDATE handmade_podcastepisode
|
||
|
SET
|
||
|
title = $1,
|
||
|
description = $2,
|
||
|
description_rendered = $3,
|
||
|
audio_filename = $4,
|
||
|
duration = $5,
|
||
|
episode_number = $6
|
||
|
WHERE
|
||
|
guid = $7
|
||
|
`,
|
||
|
title,
|
||
|
description,
|
||
|
descriptionRendered,
|
||
|
episodeFile,
|
||
|
duration,
|
||
|
episodeNumber,
|
||
|
podcastResult.Episodes[0].GUID,
|
||
|
)
|
||
|
c.Perf.EndBlock()
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to update podcast episode"))
|
||
|
}
|
||
|
} else {
|
||
|
guid := uuid.New()
|
||
|
guidStr = guid.String()
|
||
|
c.Perf.StartBlock("SQL", "Creating new podcast episode")
|
||
|
_, err := c.Conn.Exec(c.Context(),
|
||
|
`
|
||
|
INSERT INTO handmade_podcastepisode
|
||
|
(guid, title, description, description_rendered, audio_filename, duration, pub_date, episode_number, podcast_id)
|
||
|
VALUES
|
||
|
($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||
|
`,
|
||
|
guid,
|
||
|
title,
|
||
|
description,
|
||
|
descriptionRendered,
|
||
|
episodeFile,
|
||
|
duration,
|
||
|
time.Now(),
|
||
|
episodeNumber,
|
||
|
podcastResult.Podcast.ID,
|
||
|
)
|
||
|
c.Perf.EndBlock()
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to create podcast episode"))
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return c.Redirect(hmnurl.BuildPodcastEpisodeEditSuccess(c.CurrentProject.Slug, guidStr), http.StatusSeeOther)
|
||
|
}
|
||
|
|
||
|
func GetEpisodeFiles(projectSlug string) ([]string, error) {
|
||
|
folderStr := fmt.Sprintf("public/media/podcast/%s/", projectSlug)
|
||
|
folder := os.DirFS(folderStr)
|
||
|
files, err := fs.Glob(folder, "*.mp3")
|
||
|
return files, err
|
||
|
}
|
||
|
|
||
|
type PodcastRSSData struct {
|
||
|
Podcast templates.Podcast
|
||
|
Episodes []templates.PodcastEpisode
|
||
|
}
|
||
|
|
||
|
func PodcastRSS(c *RequestContext) ResponseData {
|
||
|
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, "")
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||
|
}
|
||
|
|
||
|
if podcastResult.Podcast == nil {
|
||
|
return FourOhFour(c)
|
||
|
}
|
||
|
|
||
|
podcastRSSData := PodcastRSSData{
|
||
|
Podcast: templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, podcastResult.ImageFile),
|
||
|
}
|
||
|
|
||
|
for _, episode := range podcastResult.Episodes {
|
||
|
var filesize int64
|
||
|
stat, err := os.Stat(fmt.Sprintf("./public/media/podcast/%s/%s", c.CurrentProject.Slug, episode.AudioFile))
|
||
|
if err != nil {
|
||
|
c.Logger.Err(err).Msg("Couldn't get filesize for podcast episode")
|
||
|
} else {
|
||
|
filesize = stat.Size()
|
||
|
}
|
||
|
podcastRSSData.Episodes = append(podcastRSSData.Episodes, templates.PodcastEpisodeToTemplate(c.CurrentProject.Slug, episode, filesize, podcastResult.ImageFile))
|
||
|
}
|
||
|
|
||
|
var res ResponseData
|
||
|
err = res.WriteTemplate("podcast.xml", podcastRSSData, c.Perf)
|
||
|
if err != nil {
|
||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast RSS"))
|
||
|
}
|
||
|
return res
|
||
|
}
|
||
|
|
||
|
type PodcastResult struct {
|
||
|
Podcast *models.Podcast
|
||
|
ImageFile string
|
||
|
Episodes []*models.PodcastEpisode
|
||
|
}
|
||
|
|
||
|
func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeGUID string) (PodcastResult, error) {
|
||
|
var result PodcastResult
|
||
|
c.Perf.StartBlock("SQL", "Fetch podcast")
|
||
|
type podcastQuery struct {
|
||
|
Podcast models.Podcast `db:"podcast"`
|
||
|
ImageFilename string `db:"imagefile.file"`
|
||
|
}
|
||
|
podcastQueryResult, err := db.QueryOne(c.Context(), c.Conn, podcastQuery{},
|
||
|
`
|
||
|
SELECT $columns
|
||
|
FROM handmade_podcast AS podcast
|
||
|
LEFT JOIN handmade_imagefile AS imagefile ON imagefile.id = podcast.image_id
|
||
|
WHERE podcast.project_id = $1
|
||
|
`,
|
||
|
projectId,
|
||
|
)
|
||
|
c.Perf.EndBlock()
|
||
|
if err != nil {
|
||
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||
|
return result, nil
|
||
|
} else {
|
||
|
return result, oops.New(err, "failed to fetch podcast")
|
||
|
}
|
||
|
}
|
||
|
podcast := podcastQueryResult.(*podcastQuery).Podcast
|
||
|
podcastImageFilename := podcastQueryResult.(*podcastQuery).ImageFilename
|
||
|
result.Podcast = &podcast
|
||
|
result.ImageFile = podcastImageFilename
|
||
|
|
||
|
if fetchEpisodes {
|
||
|
type podcastEpisodeQuery struct {
|
||
|
Episode models.PodcastEpisode `db:"episode"`
|
||
|
}
|
||
|
if episodeGUID == "" {
|
||
|
c.Perf.StartBlock("SQL", "Fetch podcast episodes")
|
||
|
podcastEpisodeQueryResult, err := db.Query(c.Context(), c.Conn, podcastEpisodeQuery{},
|
||
|
`
|
||
|
SELECT $columns
|
||
|
FROM handmade_podcastepisode AS episode
|
||
|
WHERE episode.podcast_id = $1
|
||
|
ORDER BY episode.season_number DESC, episode.episode_number DESC
|
||
|
`,
|
||
|
podcast.ID,
|
||
|
)
|
||
|
c.Perf.EndBlock()
|
||
|
if err != nil {
|
||
|
return result, oops.New(err, "failed to fetch podcast episodes")
|
||
|
}
|
||
|
for _, episodeRow := range podcastEpisodeQueryResult.ToSlice() {
|
||
|
result.Episodes = append(result.Episodes, &episodeRow.(*podcastEpisodeQuery).Episode)
|
||
|
}
|
||
|
} else {
|
||
|
guid, err := uuid.Parse(episodeGUID)
|
||
|
if err != nil {
|
||
|
return result, err
|
||
|
}
|
||
|
c.Perf.StartBlock("SQL", "Fetch podcast episode")
|
||
|
podcastEpisodeQueryResult, err := db.QueryOne(c.Context(), c.Conn, podcastEpisodeQuery{},
|
||
|
`
|
||
|
SELECT $columns
|
||
|
FROM handmade_podcastepisode AS episode
|
||
|
WHERE episode.podcast_id = $1 AND episode.guid = $2
|
||
|
`,
|
||
|
podcast.ID,
|
||
|
guid,
|
||
|
)
|
||
|
c.Perf.EndBlock()
|
||
|
if err != nil {
|
||
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||
|
return result, nil
|
||
|
} else {
|
||
|
return result, oops.New(err, "failed to fetch podcast episode")
|
||
|
}
|
||
|
}
|
||
|
episode := podcastEpisodeQueryResult.(*podcastEpisodeQuery).Episode
|
||
|
result.Episodes = append(result.Episodes, &episode)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return result, nil
|
||
|
}
|