feat(podcasts): add an option to purge old episodes

* Record podcast episode plays in ModifiedAt field.

* Added podcast purger.
This commit is contained in:
brian-doherty
2022-11-07 17:29:53 -06:00
committed by sentriz
parent 064bd587a8
commit 85cb0feb5a
5 changed files with 144 additions and 58 deletions

View File

@@ -30,7 +30,6 @@
- newer salt and token auth
- tested on [symfonium](https://symfonium.app), [dsub](https://f-droid.org/en/packages/github.daneren2005.dsub/), [jamstash](http://jamstash.com/), [sublime music](https://gitlab.com/sublime-music/sublime-music/), [soundwaves](https://apps.apple.com/us/app/soundwaves/id736139596), and [stmp](https://github.com/wildeyedskies/stmp)
## installation
the default login is **admin**/**admin**.
@@ -54,6 +53,7 @@ $ gonic -h # or see "configuration options below"
the image is available on dockerhub as [sentriz/gonic](https://hub.docker.com/r/sentriz/gonic)
available architectures are
- `linux/amd64`
- `linux/arm/v6`
- `linux/arm/v7`
@@ -62,7 +62,7 @@ available architectures are
```yaml
# example docker-compose.yml
version: '2.4'
version: "2.4"
services:
gonic:
image: sentriz/gonic:latest
@@ -141,7 +141,7 @@ $ journalctl --follow --unit gonic # check logs
```
should be installed and running on boot now 👍
view the admin UI at http://localhost:4747
view the admin UI at <http://localhost:4747>
### ...elsewhere
@@ -150,7 +150,7 @@ view the admin UI at http://localhost:4747
## configuration options
| env var | command line arg | description |
| ----------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------- |
| ------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------- |
| `GONIC_MUSIC_PATH` | `-music-path` | path to your music collection (see also multi-folder support below) |
| `GONIC_PODCAST_PATH` | `-podcast-path` | path to a podcasts directory |
| `GONIC_CACHE_PATH` | `-cache-path` | path to store audio transcodes, covers, etc |
@@ -163,29 +163,33 @@ view the admin UI at http://localhost:4747
| `GONIC_SCAN_INTERVAL` | `-scan-interval` | **optional** interval (in minutes) to check for new music (automatic scanning disabled if omitted) |
| `GONIC_SCAN_WATCHER` | `-scan-watcher-enabled` | **optional** whether to watch file system for new music and rescan |
| `GONIC_JUKEBOX_ENABLED` | `-jukebox-enabled` | **optional** whether the subsonic [jukebox api](https://airsonic.github.io/docs/jukebox/) should be enabled |
| `GONIC_PODCAST_PURGE_AGE` | `-podcast-purge-age` | **optional** age (in days) to purge podcast episodes if not accessed |
| `GONIC_GENRE_SPLIT` | `-genre-split` | **optional** a string or character to split genre tags on for multi-genre support (eg. `;`) |
## screenshots
| | | | | |
|:-:|:-:|:-:|:-:|:-:|
![](https://raw.githubusercontent.com/sentriz/gonic/master/.github/scrot_1.png)|![](https://raw.githubusercontent.com/sentriz/gonic/master/.github/scrot_2.png)|![](https://raw.githubusercontent.com/sentriz/gonic/master/.github/scrot_3.png)|![](https://raw.githubusercontent.com/sentriz/gonic/master/.github/scrot_4.png)|![](https://raw.githubusercontent.com/sentriz/gonic/master/.github/scrot_5.png)|
| :-----------------------------------------------------------------------------: | :-----------------------------------------------------------------------------: | :-----------------------------------------------------------------------------: | :-----------------------------------------------------------------------------: | :-----------------------------------------------------------------------------: |
| ![](https://raw.githubusercontent.com/sentriz/gonic/master/.github/scrot_1.png) | ![](https://raw.githubusercontent.com/sentriz/gonic/master/.github/scrot_2.png) | ![](https://raw.githubusercontent.com/sentriz/gonic/master/.github/scrot_3.png) | ![](https://raw.githubusercontent.com/sentriz/gonic/master/.github/scrot_4.png) | ![](https://raw.githubusercontent.com/sentriz/gonic/master/.github/scrot_5.png) |
## multiple folders support (v0.15+)
gonic supports multiple music folders. this can be handy if you have your music separated by albums, compilations, singles. or maybe 70s, 80s, 90s. whatever.
if you're running gonic with the command line, stack the `-music-path` arg
```shell
$ gonic -music-path /path/to/albums -music-path /path/to/compilations
```
if you're running gonic with ENV_VARS, or docker, try separate with a comma
```shell
GONIC_MUSIC_PATH=/path/to/albums,/path/to/compilations
```
if you're running gonic with the config file, you can repeat the `music-path` option
```shell
music-path /path/to/albums
music-path /path/to/compilations
@@ -209,6 +213,7 @@ queries like show me "recently played compilations" or "recently added albums" a
## directory structure
when browsing by folder, any arbitrary and nested folder layout is supported, with the following caveats:
- Files from the same album must all be in the same folder
- All files in a folder must be from the same album

View File

@@ -1,4 +1,5 @@
// Package main is the gonic server entrypoint
//
//nolint:lll // flags help strings
package main
@@ -35,9 +36,10 @@ func main() {
confPodcastPath := set.String("podcast-path", "", "path to podcasts")
confCachePath := set.String("cache-path", "", "path to cache")
confDBPath := set.String("db-path", "gonic.db", "path to database (optional)")
confScanInterval := set.Int("scan-interval", 0, "interval (in minutes) to automatically scan music (optional)")
confScanIntervalMins := set.Int("scan-interval", 0, "interval (in minutes) to automatically scan music (optional)")
confScanWatcher := set.Bool("scan-watcher-enabled", false, "whether to watch file system for new music and rescan (optional)")
confJukeboxEnabled := set.Bool("jukebox-enabled", false, "whether the subsonic jukebox api should be enabled (optional)")
confPodcastPurgeAgeDays := set.Int("podcast-purge-age", 0, "age (in days) to purge podcast episodes if not accessed (optional)")
confProxyPrefix := set.String("proxy-prefix", "", "url path prefix to use if behind proxy. eg '/gonic' (optional)")
confGenreSplit := set.String("genre-split", "\n", "character or string to split genre tag data on (optional)")
confHTTPLog := set.Bool("http-log", true, "http request logging (optional)")
@@ -131,8 +133,8 @@ func main() {
g.Add(server.StartHTTP(*confListenAddr, *confTLSCert, *confTLSKey))
g.Add(server.StartSessionClean(cleanTimeDuration))
g.Add(server.StartPodcastRefresher(time.Hour))
if *confScanInterval > 0 {
tickerDur := time.Duration(*confScanInterval) * time.Minute
if *confScanIntervalMins > 0 {
tickerDur := time.Duration(*confScanIntervalMins) * time.Minute
g.Add(server.StartScanTicker(tickerDur))
}
if *confScanWatcher {
@@ -141,6 +143,9 @@ func main() {
if *confJukeboxEnabled {
g.Add(server.StartJukebox())
}
if *confPodcastPurgeAgeDays > 0 {
g.Add(server.StartPodcastPurger(time.Duration(*confPodcastPurgeAgeDays) * 24 * time.Hour))
}
if err := g.Run(); err != nil {
log.Panicf("error in job: %v", err)

View File

@@ -516,6 +516,31 @@ func (p *Podcasts) DeletePodcastEpisode(podcastEpisodeID int) error {
return err
}
func (p *Podcasts) PurgeOldPodcasts(maxAge time.Duration) error {
expDate := time.Now().Add(-maxAge)
var episodes []*db.PodcastEpisode
err := p.db.
Debug().
Where("created_at < ?", expDate).
Where("updated_at < ?", expDate).
Where("modified_at < ?", expDate).
Find(&episodes).
Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("find podcasts: %w", err)
}
for _, episode := range episodes {
episode.Status = db.PodcastEpisodeStatusDeleted
if err := p.db.Save(episode).Error; err != nil {
return fmt.Errorf("save new podcast status: %w", err)
}
if err := os.Remove(filepath.Join(p.baseDir, episode.Path)); err != nil {
return fmt.Errorf("remove podcast path: %w", err)
}
}
return nil
}
func pathSafe(in string) string {
return filepath.Clean(strings.ReplaceAll(in, string(filepath.Separator), "_"))
}

View File

@@ -106,6 +106,24 @@ func streamUpdateStats(dbc *db.DB, userID, albumID int, playTime time.Time) erro
return nil
}
func streamUpdatePodcastEpisodeStats(dbc *db.DB, peID int) error {
var pe db.PodcastEpisode
err := dbc.
Where("id=?", peID).
First(&pe).
Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("find podcast episode: %w", err)
}
pe.ModifiedAt = time.Now()
if err := dbc.Save(&pe).Error; err != nil {
return fmt.Errorf("save podcast episode: %w", err)
}
return nil
}
const (
coverDefaultSize = 600
coverCacheFormat = "png"
@@ -271,7 +289,15 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R
if track, ok := file.(*db.Track); ok && track.Album != nil {
defer func() {
if err := streamUpdateStats(c.DB, user.ID, track.Album.ID, time.Now()); err != nil {
log.Printf("error updating status: %v", err)
log.Printf("error updating track status: %v", err)
}
}()
}
if pe, ok := file.(*db.PodcastEpisode); ok {
defer func() {
if err := streamUpdatePodcastEpisodeStats(c.DB, pe.ID); err != nil {
log.Printf("error updating podcast episode status: %v", err)
}
}()
}

View File

@@ -389,6 +389,31 @@ func (s *Server) StartPodcastRefresher(dur time.Duration) (FuncExecute, FuncInte
}
}
func (s *Server) StartPodcastPurger(maxAge time.Duration) (FuncExecute, FuncInterrupt) {
ticker := time.NewTicker(24 * time.Hour)
done := make(chan struct{})
waitFor := func() error {
for {
select {
case <-done:
return nil
case <-ticker.C:
if err := s.podcast.PurgeOldPodcasts(maxAge); err != nil {
log.Printf("error purging old podcasts: %v", err)
}
}
}
}
return func() error {
log.Printf("starting job 'podcast purger'\n")
return waitFor()
}, func(_ error) {
// stop job
ticker.Stop()
done <- struct{}{}
}
}
func (s *Server) StartSessionClean(dur time.Duration) (FuncExecute, FuncInterrupt) {
ticker := time.NewTicker(dur)
done := make(chan struct{})