hmn/src/website/podcast.go

604 lines
17 KiB
Go

package website
import (
"errors"
"fmt"
"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 c.ErrorResponse(http.StatusInternalServerError, err)
}
if podcastResult.Podcast == nil {
return FourOhFour(c)
}
canEdit := c.CurrentUserCanEditCurrentProject
baseData := getBaseDataAutocrumb(c, podcastResult.Podcast.Title)
podcastIndexData := PodcastIndexData{
BaseData: baseData,
Podcast: templates.PodcastToTemplate(podcastResult.Podcast, podcastResult.ImageFile),
}
if canEdit {
podcastIndexData.EditUrl = hmnurl.BuildPodcastEdit()
podcastIndexData.NewEpisodeUrl = hmnurl.BuildPodcastEpisodeNew()
}
for _, episode := range podcastResult.Episodes {
podcastIndexData.Episodes = append(podcastIndexData.Episodes, templates.PodcastEpisodeToTemplate(episode, 0, podcastResult.ImageFile))
}
var res ResponseData
err = res.WriteTemplate("podcast_index.html", podcastIndexData, c.Perf)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast index page"))
}
return res
}
type PodcastEditData struct {
templates.BaseData
Podcast templates.Podcast
}
func PodcastEdit(c *RequestContext) ResponseData {
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, false, "")
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
canEdit := c.CurrentUserCanEditCurrentProject
if podcastResult.Podcast == nil || !canEdit {
return FourOhFour(c)
}
podcast := templates.PodcastToTemplate(podcastResult.Podcast, podcastResult.ImageFile)
baseData := getBaseData(
c,
fmt.Sprintf("Edit %s", podcast.Title),
[]templates.Breadcrumb{{Name: podcast.Title, Url: podcast.Url}},
)
podcastEditData := PodcastEditData{
BaseData: baseData,
Podcast: podcast,
}
var res ResponseData
err = res.WriteTemplate("podcast_edit.html", podcastEditData, c.Perf)
if err != nil {
return c.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 c.ErrorResponse(http.StatusInternalServerError, err)
}
canEdit := c.CurrentUserCanEditCurrentProject
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 c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form"))
}
title := c.Req.Form.Get("title")
if len(strings.TrimSpace(title)) == 0 {
return c.RejectRequest("Podcast title is empty")
}
description := c.Req.Form.Get("description")
if len(strings.TrimSpace(description)) == 0 {
return c.RejectRequest("Podcast description is empty")
}
c.Perf.StartBlock("SQL", "Updating podcast")
tx, err := c.Conn.Begin(c)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
}
defer tx.Rollback(c)
imageSaveResult := SaveImageFile(c, tx, "podcast_image", maxFileSize, fmt.Sprintf("podcast/%s/logo%d", c.CurrentProject.Slug, time.Now().UTC().Unix()))
if imageSaveResult.ValidationError != "" {
return c.RejectRequest(imageSaveResult.ValidationError)
} else if imageSaveResult.FatalError != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(imageSaveResult.FatalError, "Failed to save podcast image"))
}
if imageSaveResult.ImageFile != nil {
_, err = tx.Exec(c,
`
UPDATE podcast
SET
title = $1,
description = $2,
image_id = $3
WHERE id = $4
`,
title,
description,
imageSaveResult.ImageFile.ID,
podcastResult.Podcast.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to update podcast"))
}
} else {
_, err = tx.Exec(c,
`
UPDATE podcast
SET
title = $1,
description = $2
WHERE id = $3
`,
title,
description,
podcastResult.Podcast.ID,
)
}
err = tx.Commit(c)
c.Perf.EndBlock()
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to commit db transaction"))
}
res := c.Redirect(hmnurl.BuildPodcastEdit(), http.StatusSeeOther)
res.AddFutureNotice("success", "Podcast updated successfully.")
return res
}
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 c.ErrorResponse(http.StatusInternalServerError, err)
}
if podcastResult.Podcast == nil || len(podcastResult.Episodes) == 0 {
return FourOhFour(c)
}
canEdit := c.CurrentUserCanEditCurrentProject
editUrl := ""
if canEdit {
editUrl = hmnurl.BuildPodcastEpisodeEdit(podcastResult.Episodes[0].GUID.String())
}
podcast := templates.PodcastToTemplate(podcastResult.Podcast, podcastResult.ImageFile)
episode := templates.PodcastEpisodeToTemplate(podcastResult.Episodes[0], 0, podcastResult.ImageFile)
baseData := getBaseData(
c,
fmt.Sprintf("%s | %s", episode.Title, podcast.Title),
[]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 c.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
}
func PodcastEpisodeNew(c *RequestContext) ResponseData {
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, false, "")
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
canEdit := c.CurrentUserCanEditCurrentProject
if podcastResult.Podcast == nil || !canEdit {
return FourOhFour(c)
}
episodeFiles, err := GetEpisodeFiles(c.CurrentProject.Slug)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch podcast episode file list"))
}
podcast := templates.PodcastToTemplate(podcastResult.Podcast, "")
var res ResponseData
baseData := getBaseData(
c,
fmt.Sprintf("New episode | %s", podcast.Title),
[]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 c.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 c.ErrorResponse(http.StatusInternalServerError, err)
}
canEdit := c.CurrentUserCanEditCurrentProject
if podcastResult.Podcast == nil || len(podcastResult.Episodes) == 0 || !canEdit {
return FourOhFour(c)
}
episodeFiles, err := GetEpisodeFiles(c.CurrentProject.Slug)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch podcast episode file list"))
}
episode := podcastResult.Episodes[0]
podcast := templates.PodcastToTemplate(podcastResult.Podcast, "")
podcastEpisode := templates.PodcastEpisodeToTemplate(episode, 0, "")
baseData := getBaseData(
c,
fmt.Sprintf("Edit episode %s | %s", podcastEpisode.Title, podcast.Title),
[]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,
}
var res ResponseData
err = res.WriteTemplate("podcast_episode_edit.html", podcastEpisodeEditData, c.Perf)
if err != nil {
return c.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 c.ErrorResponse(http.StatusInternalServerError, err)
}
canEdit := c.CurrentUserCanEditCurrentProject
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 c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch podcast episode file list"))
}
c.Req.ParseForm()
title := c.Req.Form.Get("title")
if len(strings.TrimSpace(title)) == 0 {
return c.RejectRequest("Episode title is empty")
}
description := c.Req.Form.Get("description")
if len(strings.TrimSpace(description)) == 0 {
return c.RejectRequest("Episode description is empty")
}
episodeNumberStr := c.Req.Form.Get("episode_number")
episodeNumber, err := strconv.Atoi(episodeNumberStr)
if err != nil {
return c.RejectRequest("Episode number can't be parsed")
}
episodeFile := c.Req.Form.Get("episode_file")
found = false
for _, ef := range episodeFiles {
if episodeFile == ef {
found = true
break
}
}
if !found {
return c.RejectRequest("Requested episode file not found")
}
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 c.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 c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to decode mp3 file"))
}
c.Perf.StartBlock("MARKDOWN", "Parsing description")
descriptionRendered := parsing.ParseMarkdown(description, parsing.ForumRealMarkdown)
c.Perf.EndBlock()
guidStr := ""
if isEdit {
guidStr = podcastResult.Episodes[0].GUID.String()
c.Perf.StartBlock("SQL", "Updating podcast episode")
_, err := c.Conn.Exec(c,
`
UPDATE podcast_episode
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 c.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,
`
INSERT INTO podcast_episode
(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 c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to create podcast episode"))
}
}
res := c.Redirect(hmnurl.BuildPodcastEpisodeEdit(guidStr), http.StatusSeeOther)
res.AddFutureNotice("success", "Podcast episode updated successfully.")
return res
}
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 c.ErrorResponse(http.StatusInternalServerError, err)
}
if podcastResult.Podcast == nil {
return FourOhFour(c)
}
podcastRSSData := PodcastRSSData{
Podcast: templates.PodcastToTemplate(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(episode, filesize, podcastResult.ImageFile))
}
var res ResponseData
err = res.WriteTemplate("podcast.xml", podcastRSSData, c.Perf)
if err != nil {
return c.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[podcastQuery](c, c.Conn,
`
SELECT $columns
FROM
podcast
LEFT JOIN image_file 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.NotFound) {
return result, nil
} else {
return result, oops.New(err, "failed to fetch podcast")
}
}
podcast := podcastQueryResult.Podcast
podcastImageFilename := podcastQueryResult.ImageFilename
result.Podcast = &podcast
result.ImageFile = podcastImageFilename
if fetchEpisodes {
if episodeGUID == "" {
c.Perf.StartBlock("SQL", "Fetch podcast episodes")
episodes, err := db.Query[models.PodcastEpisode](c, c.Conn,
`
SELECT $columns
FROM podcast_episode 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")
}
result.Episodes = episodes
} else {
guid, err := uuid.Parse(episodeGUID)
if err != nil {
return result, err
}
c.Perf.StartBlock("SQL", "Fetch podcast episode")
episode, err := db.QueryOne[models.PodcastEpisode](c, c.Conn,
`
SELECT $columns
FROM podcast_episode 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.NotFound) {
return result, nil
} else {
return result, oops.New(err, "failed to fetch podcast episode")
}
}
result.Episodes = append(result.Episodes, episode)
}
}
return result, nil
}