diff --git a/.gitignore b/.gitignore index 1c2d1939..9d3b79fb 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ gonicscan gonicembed .vscode *.swp +.tags* diff --git a/README.md b/README.md index 7118ea17..60cbb7d0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/db/migrations.go b/db/migrations.go index 6434bec6..a663320e 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -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. @@ -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 +} + diff --git a/db/model.go b/db/model.go index a97ba321..867b0066 100644 --- a/db/model.go +++ b/db/model.go @@ -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 { @@ -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 @@ -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} } diff --git a/podcasts/podcasts.go b/podcasts/podcasts.go index 12b919a3..774a440d 100644 --- a/podcasts/podcasts.go +++ b/podcasts/podcasts.go @@ -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 { @@ -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 @@ -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 @@ -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 @@ -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 { @@ -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 { @@ -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 { @@ -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) } @@ -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 { @@ -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)) +} diff --git a/server/assets/pages/home.tmpl b/server/assets/pages/home.tmpl index aa7727ef..da2484cf 100644 --- a/server/assets/pages/home.tmpl +++ b/server/assets/pages/home.tmpl @@ -169,43 +169,45 @@ -
-
- podcasts -
-
-

you can add podcasts rss feeds here

-
-
- - {{ range $pref := .Podcasts }} +{{ if .User.IsAdmin }} +
+
+ podcasts +
+
+

you can add podcasts rss feeds here

+
+
+
+ {{ range $pref := .Podcasts }} + + + + + + + + + + + {{ end }} - - - - - - - - + + + - {{ end }} - - - - - -
{{ $pref.Title }}
{{ $pref.Title }}
+ +
- +{{ end }}
playlists diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go index 2be7c292..68b74e90 100644 --- a/server/ctrladmin/handlers.go +++ b/server/ctrladmin/handlers.go @@ -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) @@ -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)}, @@ -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{ diff --git a/server/ctrlsubsonic/handlers_podcast.go b/server/ctrlsubsonic/handlers_podcast.go index 67cb7e7f..44c45783 100644 --- a/server/ctrlsubsonic/handlers_podcast.go +++ b/server/ctrlsubsonic/handlers_podcast.go @@ -14,9 +14,8 @@ import ( func (c *Controller) ServeGetPodcasts(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) isIncludeEpisodes := params.GetOrBool("includeEpisodes", true) - user := r.Context().Value(CtxUser).(*db.User) id, _ := params.GetID("id") - podcasts, err := c.Podcasts.GetPodcastOrAll(user.ID, id.Value, isIncludeEpisodes) + podcasts, err := c.Podcasts.GetPodcastOrAll(id.Value, isIncludeEpisodes) if err != nil { return spec.NewError(10, "failed get podcast(s): %s", err) } @@ -45,6 +44,10 @@ func (c *Controller) ServeGetNewestPodcasts(r *http.Request) *spec.Response { } func (c *Controller) ServeDownloadPodcastEpisode(r *http.Request) *spec.Response { + user := r.Context().Value(CtxUser).(*db.User) + if (!user.IsAdmin) { + return spec.NewError(10, "user not admin") + } params := r.Context().Value(CtxParams).(params.Params) id, err := params.GetID("id") if err != nil || id.Type != specid.PodcastEpisode { @@ -58,6 +61,9 @@ func (c *Controller) ServeDownloadPodcastEpisode(r *http.Request) *spec.Response func (c *Controller) ServeCreatePodcastChannel(r *http.Request) *spec.Response { user := r.Context().Value(CtxUser).(*db.User) + if (!user.IsAdmin) { + return spec.NewError(10, "user not admin") + } params := r.Context().Value(CtxParams).(params.Params) rssURL, _ := params.Get("url") fp := gofeed.NewParser() @@ -65,7 +71,7 @@ func (c *Controller) ServeCreatePodcastChannel(r *http.Request) *spec.Response { if err != nil { return spec.NewError(10, "failed to parse feed: %s", err) } - if _, err = c.Podcasts.AddNewPodcast(rssURL, feed, user.ID); err != nil { + if _, err = c.Podcasts.AddNewPodcast(rssURL, feed); err != nil { return spec.NewError(10, "failed to add feed: %s", err) } return spec.NewResponse() @@ -73,7 +79,10 @@ func (c *Controller) ServeCreatePodcastChannel(r *http.Request) *spec.Response { func (c *Controller) ServeRefreshPodcasts(r *http.Request) *spec.Response { user := r.Context().Value(CtxUser).(*db.User) - if err := c.Podcasts.RefreshPodcastsForUser(user.ID); err != nil { + if (!user.IsAdmin) { + return spec.NewError(10, "user not admin") + } + if err := c.Podcasts.RefreshPodcasts(); err != nil { return spec.NewError(10, "failed to refresh feeds: %s", err) } return spec.NewResponse() @@ -81,18 +90,25 @@ func (c *Controller) ServeRefreshPodcasts(r *http.Request) *spec.Response { func (c *Controller) ServeDeletePodcastChannel(r *http.Request) *spec.Response { user := r.Context().Value(CtxUser).(*db.User) + if (!user.IsAdmin) { + return spec.NewError(10, "user not admin") + } params := r.Context().Value(CtxParams).(params.Params) id, err := params.GetID("id") if err != nil || id.Type != specid.Podcast { return spec.NewError(10, "please provide a valid podcast id") } - if err := c.Podcasts.DeletePodcast(user.ID, id.Value); err != nil { + if err := c.Podcasts.DeletePodcast(id.Value); err != nil { return spec.NewError(10, "failed to delete podcast: %s", err) } return spec.NewResponse() } func (c *Controller) ServeDeletePodcastEpisode(r *http.Request) *spec.Response { + user := r.Context().Value(CtxUser).(*db.User) + if (!user.IsAdmin) { + return spec.NewError(10, "user not admin") + } params := r.Context().Value(CtxParams).(params.Params) id, err := params.GetID("id") if err != nil || id.Type != specid.PodcastEpisode { diff --git a/server/server.go b/server/server.go index fac2ec3d..fbc6a858 100644 --- a/server/server.go +++ b/server/server.go @@ -169,10 +169,6 @@ func setupAdmin(r *mux.Router, ctrl *ctrladmin.Controller) { routUser.Handle("/delete_playlist_do", ctrl.H(ctrl.ServeDeletePlaylistDo)) routUser.Handle("/create_transcode_pref_do", ctrl.H(ctrl.ServeCreateTranscodePrefDo)) routUser.Handle("/delete_transcode_pref_do", ctrl.H(ctrl.ServeDeleteTranscodePrefDo)) - routUser.Handle("/add_podcast_do", ctrl.H(ctrl.ServePodcastAddDo)) - routUser.Handle("/delete_podcast_do", ctrl.H(ctrl.ServePodcastDeleteDo)) - routUser.Handle("/download_podcast_do", ctrl.H(ctrl.ServePodcastDownloadDo)) - routUser.Handle("/update_podcast_do", ctrl.H(ctrl.ServePodcastUpdateDo)) // admin routes (if session is valid, and is admin) routAdmin := routUser.NewRoute().Subrouter() @@ -189,6 +185,10 @@ func setupAdmin(r *mux.Router, ctrl *ctrladmin.Controller) { routAdmin.Handle("/update_lastfm_api_key_do", ctrl.H(ctrl.ServeUpdateLastFMAPIKeyDo)) routAdmin.Handle("/start_scan_inc_do", ctrl.H(ctrl.ServeStartScanIncDo)) routAdmin.Handle("/start_scan_full_do", ctrl.H(ctrl.ServeStartScanFullDo)) + routAdmin.Handle("/add_podcast_do", ctrl.H(ctrl.ServePodcastAddDo)) + routAdmin.Handle("/delete_podcast_do", ctrl.H(ctrl.ServePodcastDeleteDo)) + routAdmin.Handle("/download_podcast_do", ctrl.H(ctrl.ServePodcastDownloadDo)) + routAdmin.Handle("/update_podcast_do", ctrl.H(ctrl.ServePodcastUpdateDo)) // middlewares should be run for not found handler // https://github.com/gorilla/mux/issues/416