Skip to content

Commit

Permalink
refactor(podcast)!: make podcasts global not per user, to match spec
Browse files Browse the repository at this point in the history
Release-As: 0.15.0
  • Loading branch information
brian-doherty authored and sentriz committed May 3, 2022
1 parent e883de8 commit 182c96e
Show file tree
Hide file tree
Showing 9 changed files with 121 additions and 112 deletions.
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -15,3 +15,4 @@ gonicscan
gonicembed
.vscode
*.swp
.tags*
2 changes: 1 addition & 1 deletion README.md
Expand Up @@ -17,7 +17,7 @@
- browsing by tags (using [taglib](https://taglib.org/) - supports mp3, opus, flac, ape, m4a, wav, etc.)
- on-the-fly audio transcoding and caching (requires [ffmpeg](https://ffmpeg.org/)) (thank you [spijet](https://github.com/spijet/))
- jukebox mode (thank you [lxea](https://github.com/lxea/))
- support for per-user podcasts (thank you [lxea](https://github.com/lxea/))
- support for podcasts (thank you [lxea](https://github.com/lxea/))
- pretty fast scanning (with my library of ~27k tracks, initial scan takes about 10m, and about 5s after incrementally)
- multiple users, each with their own transcoding preferences, playlists, top tracks, top artists, etc.
- [last.fm](https://www.last.fm/) scrobbling
Expand Down
24 changes: 24 additions & 0 deletions db/migrations.go
Expand Up @@ -41,6 +41,7 @@ func (db *DB) Migrate(ctx MigrationContext) error {
construct(ctx, "202202092013", migrateArtistCover),
construct(ctx, "202202121809", migrateAlbumRootDirAgain),
construct(ctx, "202202241218", migratePublicPlaylist),
construct(ctx, "202204270903", migratePodcastDropUserID),
}

return gormigrate.
Expand Down Expand Up @@ -332,3 +333,26 @@ func migrateAlbumRootDirAgain(tx *gorm.DB, ctx MigrationContext) error {
func migratePublicPlaylist(tx *gorm.DB, ctx MigrationContext) error {
return tx.AutoMigrate(Playlist{}).Error
}

func migratePodcastDropUserID(tx *gorm.DB, _ MigrationContext) error {
step := tx.AutoMigrate(
Podcast{},
)
if err := step.Error; err != nil {
return fmt.Errorf("step auto migrate: %w", err)
}

if !tx.Dialect().HasColumn("podcasts", "user_id") {
return nil
}


step = tx.Exec(`
ALTER TABLE podcasts DROP COLUMN user_id;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step migrate podcasts drop user_id: %w", err)
}
return nil
}

9 changes: 1 addition & 8 deletions db/model.go
Expand Up @@ -7,15 +7,14 @@ package db

import (
"path"
"path/filepath"
"strconv"
"strings"
"time"

// TODO: remove this dep

"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
"go.senan.xyz/gonic/mime"
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
)

func splitInt(in, sep string) []int {
Expand Down Expand Up @@ -315,7 +314,6 @@ type Podcast struct {
ID int `gorm:"primary_key"`
UpdatedAt time.Time
ModifiedAt time.Time
UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
URL string
Title string
Description string
Expand All @@ -326,11 +324,6 @@ type Podcast struct {
AutoDownload PodcastAutoDownload
}

func (p *Podcast) Fullpath(podcastPath string) string {
sanitizedTitle := strings.ReplaceAll(p.Title, "/", "_")
return filepath.Join(podcastPath, filepath.Clean(sanitizedTitle))
}

func (p *Podcast) SID() *specid.ID {
return &specid.ID{Type: specid.Podcast, Value: p.ID}
}
Expand Down
87 changes: 31 additions & 56 deletions podcasts/podcasts.go
Expand Up @@ -40,13 +40,15 @@ func New(db *db.DB, base string, tagger tags.Reader) *Podcasts {
}
}

func (p *Podcasts) GetPodcastOrAll(userID int, id int, includeEpisodes bool) ([]*db.Podcast, error) {
func (p *Podcasts) GetPodcastOrAll(id int, includeEpisodes bool) ([]*db.Podcast, error) {
var err error
podcasts := []*db.Podcast{}
q := p.db.Where("user_id=?", userID)
if id != 0 {
q = q.Where("id=?", id)
err = p.db.Where("id=?", id).Find(&podcasts).Error
} else {
err = p.db.Find(&podcasts).Error
}
if err := q.Find(&podcasts).Error; err != nil {
if err != nil {
return nil, fmt.Errorf("finding podcasts: %w", err)
}
if !includeEpisodes {
Expand Down Expand Up @@ -88,16 +90,14 @@ func (p *Podcasts) GetNewestPodcastEpisodes(count int) ([]*db.PodcastEpisode, er
return episodes, nil
}

func (p *Podcasts) AddNewPodcast(rssURL string, feed *gofeed.Feed,
userID int) (*db.Podcast, error) {
func (p *Podcasts) AddNewPodcast(rssURL string, feed *gofeed.Feed) (*db.Podcast, error) {
podcast := db.Podcast{
Description: feed.Description,
ImageURL: feed.Image.URL,
UserID: userID,
Title: feed.Title,
URL: rssURL,
}
podPath := podcast.Fullpath(p.baseDir)
podPath := absPath(p.baseDir, &podcast)
err := os.Mkdir(podPath, 0755)
if err != nil && !os.IsExist(err) {
return nil, err
Expand Down Expand Up @@ -252,8 +252,7 @@ func itemToEpisode(podcastID, size, duration int, audio string,
}
}

func (p *Podcasts) findEnclosureAudio(podcastID, duration int,
item *gofeed.Item) (*db.PodcastEpisode, bool) {
func (p *Podcasts) findEnclosureAudio(podcastID, duration int, item *gofeed.Item) (*db.PodcastEpisode, bool) {
for _, enc := range item.Enclosures {
if !isAudio(enc.Type, enc.URL) {
continue
Expand All @@ -264,8 +263,7 @@ func (p *Podcasts) findEnclosureAudio(podcastID, duration int,
return nil, false
}

func (p *Podcasts) findMediaAudio(podcastID, duration int,
item *gofeed.Item) (*db.PodcastEpisode, bool) {
func (p *Podcasts) findMediaAudio(podcastID, duration int, item *gofeed.Item) (*db.PodcastEpisode, bool) {
extensions, ok := item.Extensions["media"]["content"]
if !ok {
return nil, false
Expand All @@ -291,22 +289,6 @@ func (p *Podcasts) RefreshPodcasts() error {
return nil
}

func (p *Podcasts) RefreshPodcastsForUser(userID int) error {
podcasts := []*db.Podcast{}
err := p.db.
Where("user_id=?", userID).
Find(&podcasts).
Error
if err != nil {
return fmt.Errorf("find podcasts: %w", err)
}
var errs *multierr.Err
if errors.As(p.refreshPodcasts(podcasts), &errs) && errs.Len() > 0 {
return fmt.Errorf("refresh podcasts: %w", errs)
}
return nil
}

func (p *Podcasts) refreshPodcasts(podcasts []*db.Podcast) error {
errs := &multierr.Err{}
for _, podcast := range podcasts {
Expand Down Expand Up @@ -387,13 +369,12 @@ func (p *Podcasts) DownloadEpisode(episodeID int) error {
filename = path.Base(audioURL.Path)
}
filename = p.findUniqueEpisodeName(&podcast, &podcastEpisode, filename)
audioFile, err := os.Create(path.Join(podcast.Fullpath(p.baseDir), filename))
audioFile, err := os.Create(path.Join(absPath(p.baseDir, &podcast), filename))
if err != nil {
return fmt.Errorf("create audio file: %w", err)
}
podcastEpisode.Filename = filename
sanTitle := strings.ReplaceAll(podcast.Title, "/", "_")
podcastEpisode.Path = path.Join(sanTitle, filename)
podcastEpisode.Path = path.Join(pathSafe(podcast.Title), filename)
p.db.Save(&podcastEpisode)
go func() {
if err := p.doPodcastDownload(&podcastEpisode, audioFile, resp.Body); err != nil {
Expand All @@ -403,22 +384,18 @@ func (p *Podcasts) DownloadEpisode(episodeID int) error {
return nil
}

func (p *Podcasts) findUniqueEpisodeName(
podcast *db.Podcast,
podcastEpisode *db.PodcastEpisode,
filename string) string {
podcastPath := path.Join(podcast.Fullpath(p.baseDir), filename)
func (p *Podcasts) findUniqueEpisodeName(podcast *db.Podcast, podcastEpisode *db.PodcastEpisode, filename string) string {
podcastPath := path.Join(absPath(p.baseDir, podcast), filename)
if _, err := os.Stat(podcastPath); os.IsNotExist(err) {
return filename
}
sanitizedTitle := strings.ReplaceAll(podcastEpisode.Title, "/", "_")
titlePath := fmt.Sprintf("%s%s", sanitizedTitle, filepath.Ext(filename))
podcastPath = path.Join(podcast.Fullpath(p.baseDir), titlePath)
titlePath := fmt.Sprintf("%s%s", pathSafe(podcastEpisode.Title), filepath.Ext(filename))
podcastPath = path.Join(absPath(p.baseDir, podcast), titlePath)
if _, err := os.Stat(podcastPath); os.IsNotExist(err) {
return titlePath
}
// try to find a filename like FILENAME (1).mp3 incrementing
return findEpisode(podcast.Fullpath(p.baseDir), filename, 1)
return findEpisode(absPath(p.baseDir, podcast), filename, 1)
}

func findEpisode(base, filename string, count int) string {
Expand Down Expand Up @@ -462,9 +439,7 @@ func (p *Podcasts) downloadPodcastCover(podPath string, podcast *db.Podcast) err
if _, err := io.Copy(coverFile, resp.Body); err != nil {
return fmt.Errorf("writing podcast cover: %w", err)
}
podcastPath := filepath.Clean(strings.ReplaceAll(podcast.Title, "/", "_"))
podcastFilename := fmt.Sprintf("cover%s", ext)
podcast.ImagePath = path.Join(podcastPath, podcastFilename)
podcast.ImagePath = path.Join(pathSafe(podcast.Title), fmt.Sprintf("cover%s", ext))
if err := p.db.Save(podcast).Error; err != nil {
return fmt.Errorf("save podcast: %w", err)
}
Expand Down Expand Up @@ -492,28 +467,20 @@ func (p *Podcasts) doPodcastDownload(podcastEpisode *db.PodcastEpisode, file *os
return p.db.Save(podcastEpisode).Error
}

func (p *Podcasts) DeletePodcast(userID, podcastID int) error {
func (p *Podcasts) DeletePodcast(podcastID int) error {
podcast := db.Podcast{}
err := p.db.
Where("id=? AND user_id=?", podcastID, userID).
Where("id=?", podcastID).
First(&podcast).
Error
if err != nil {
return err
}
var userCount int
p.db.
Model(&db.Podcast{}).
Where("title=?", podcast.Title).
Count(&userCount)
if userCount == 1 {
// only delete the folder if there are not multiple listeners
if err = os.RemoveAll(podcast.Fullpath(p.baseDir)); err != nil {
return fmt.Errorf("delete podcast directory: %w", err)
}
if err := os.RemoveAll(absPath(p.baseDir, &podcast)); err != nil {
return fmt.Errorf("delete podcast directory: %w", err)
}
err = p.db.
Where("id=? AND user_id=?", podcastID, userID).
Where("id=?", podcastID).
Delete(db.Podcast{}).
Error
if err != nil {
Expand All @@ -535,3 +502,11 @@ func (p *Podcasts) DeletePodcastEpisode(podcastEpisodeID int) error {
}
return err
}

func pathSafe(in string) string {
return filepath.Clean(strings.ReplaceAll(in, string(filepath.Separator), "_"))
}

func absPath(base string, p *db.Podcast) string {
return filepath.Join(base, pathSafe(p.Title))
}
70 changes: 36 additions & 34 deletions server/assets/pages/home.tmpl
Expand Up @@ -169,43 +169,45 @@
</table>
</div>
</div>
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-rss-box"></i> podcasts
</div>
<div class="box-description text-light">
<p>you can add podcasts rss feeds here</p>
</div>
<div class="block-right">
<table id="podcast-preferences">
{{ range $pref := .Podcasts }}
{{ if .User.IsAdmin }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-rss-box"></i> podcasts
</div>
<div class="box-description text-light">
<p>you can add podcasts rss feeds here</p>
</div>
<div class="block-right">
<table id="podcast-preferences">
{{ range $pref := .Podcasts }}
<tr>
<form id="podcast-{{ $pref.ID }}-download" action="{{ printf "/admin/download_podcast_do?id=%d" $pref.ID | path }}" method="post"></form>
<form id="podcast-{{ $pref.ID }}-auto-download" action="{{ printf "/admin/update_podcast_do?id=%d" $pref.ID | path }}" method="post"></form>
<form id="podcast-{{ $pref.ID }}-delete" action="{{ printf "/admin/delete_podcast_do?id=%d" $pref.ID | path }}" method="post"></form>
<td>{{ $pref.Title }}</td>
<td><select form="podcast-{{ $pref.ID }}-auto-download" name="setting">
{{ if eq $pref.AutoDownload "latest" }}
<option value="latest" selected="selected">download latest</option>
<option value="none">no auto download</option>
{{ else }}
<option value="none" selected="selected" >no auto download</option>
<option value="latest">download latest</option>
{{ end }}
</select></td>
<td><input form="podcast-{{ $pref.ID }}-download" type="submit" value="download all"></td>
<td><input form="podcast-{{ $pref.ID }}-auto-download" type="submit" value="save"></td>
<td><input form="podcast-{{ $pref.ID }}-delete" type="submit" value="delete"></td>
</tr>
{{ end }}
<tr>
<form id="podcast-{{ $pref.ID }}-download" action="{{ printf "/admin/download_podcast_do?id=%d" $pref.ID | path }}" method="post"></form>
<form id="podcast-{{ $pref.ID }}-auto-download" action="{{ printf "/admin/update_podcast_do?id=%d" $pref.ID | path }}" method="post"></form>
<form id="podcast-{{ $pref.ID }}-delete" action="{{ printf "/admin/delete_podcast_do?id=%d" $pref.ID | path }}" method="post"></form>
<td>{{ $pref.Title }}</td>
<td><select form="podcast-{{ $pref.ID }}-auto-download" name="setting">
{{ if eq $pref.AutoDownload "latest" }}
<option value="latest" selected="selected">download latest</option>
<option value="none">no auto download</option>
{{ else }}
<option value="none" selected="selected" >no auto download</option>
<option value="latest">download latest</option>
{{ end }}
</select></td>
<td><input form="podcast-{{ $pref.ID }}-download" type="submit" value="download all"></td>
<td><input form="podcast-{{ $pref.ID }}-auto-download" type="submit" value="save"></td>
<td><input form="podcast-{{ $pref.ID }}-delete" type="submit" value="delete"></td>
<form id="podcast-add" action="{{ path "/admin/add_podcast_do" }}" method="post"></form>
<td><input form="podcast-add" type="text" name="feed" placeholder="rss feed url"></td>
<td><input form="podcast-add" type="submit" value="save"></td>
</tr>
{{ end }}
<tr>
<form id="podcast-add" action="{{ path "/admin/add_podcast_do" }}" method="post"></form>
<td><input form="podcast-add" type="text" name="feed" placeholder="rss feed url"></td>
<td><input form="podcast-add" type="submit" value="save"></td>
</tr>
</table>
</table>
</div>
</div>
</div>
{{ end }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-playlist-music"></i> playlists
Expand Down
6 changes: 2 additions & 4 deletions server/ctrladmin/handlers.go
Expand Up @@ -391,7 +391,6 @@ func (c *Controller) ServeDeleteTranscodePrefDo(r *http.Request) *Response {
}

func (c *Controller) ServePodcastAddDo(r *http.Request) *Response {
user := r.Context().Value(CtxUser).(*db.User)
rssURL := r.FormValue("feed")
fp := gofeed.NewParser()
feed, err := fp.ParseURL(rssURL)
Expand All @@ -401,7 +400,7 @@ func (c *Controller) ServePodcastAddDo(r *http.Request) *Response {
flashW: []string{fmt.Sprintf("could not create feed: %v", err)},
}
}
if _, err = c.Podcasts.AddNewPodcast(rssURL, feed, user.ID); err != nil {
if _, err = c.Podcasts.AddNewPodcast(rssURL, feed); err != nil {
return &Response{
redirect: "/admin/home",
flashW: []string{fmt.Sprintf("could not create feed: %v", err)},
Expand Down Expand Up @@ -454,12 +453,11 @@ func (c *Controller) ServePodcastUpdateDo(r *http.Request) *Response {
}

func (c *Controller) ServePodcastDeleteDo(r *http.Request) *Response {
user := r.Context().Value(CtxUser).(*db.User)
id, err := strconv.Atoi(r.URL.Query().Get("id"))
if err != nil {
return &Response{code: 400, err: "please provide a valid podcast id"}
}
if err := c.Podcasts.DeletePodcast(user.ID, id); err != nil {
if err := c.Podcasts.DeletePodcast(id); err != nil {
return &Response{code: 400, err: "please provide a valid podcast id"}
}
return &Response{
Expand Down

0 comments on commit 182c96e

Please sign in to comment.