diff --git a/client/addPodcast.html b/client/addPodcast.html index 09b9860..679fca4 100644 --- a/client/addPodcast.html +++ b/client/addPodcast.html @@ -1,149 +1,255 @@ - - - + + + Add Podcast - PodGrab {{template "commoncss"}} - - - - - + +
- {{template "navbar" .}} -
-
-
-

Add using the direct link to rss feed

-
-
- -
-
- -
-
-
-
-
-

Search for your favorite podcast

- Experimental: Uses iTunes API to show search results. -
-
-
- -
-
- -
-
-
- -
- -
-
-
- + {{template "navbar" .}} +
+
+
+

Add using the direct link to rss feed

+
+
+
-
-
${item.title}
- -

${ item.description }

-
-
- - - +
+ +
+ +
+
+
+
+

Import OPML file

+ Most of the major podcast manager apps (eg. Podcast Addict) have the option to export add your podcast subscriptions in the opml format. You can migrate all your podcast in one go by exportin that OPML file and importing it here. +

+ +
+
+ +
+ +
+ +
+
+
+
+

Search for your favorite podcast

+ Experimental: Uses iTunes API to show search results. +
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+ +
+
+
${item.title}
+ +

${ item.description }

+
+
+ + +
+
-
+
+
-
-
-
-{{template "scripts"}} + {{template "scripts"}} - - \ No newline at end of file + var self = this; + self.searching = true; + axios + .get("/search?q=" + this.query) + .then(function (response) { + self.results = response.data; + }) + .catch(function (error) {}) + .then(function () { + self.searching = false; + }); + }, + addPodcastManual: function (e) { + e.preventDefault(); + if (!this.url) { + return; + } + this.addPodcast({ url: this.url }); + }, + addPodcast: function (item) { + // console.log(item); + var self = this; + self.searching = true; + axios + .post("/podcasts", { + url: item.url, + }) + .then(function (response) { + Vue.toasted.show("Podcast added successfully.", { + theme: "bubble", + position: "top-right", + duration: 5000, + }); + item.already_saved = true; + }) + .catch(function (error) { + if (error.response) { + Vue.toasted.show(error.response.data?.message, { + theme: "bubble", + position: "top-right", + duration: 5000, + }); + } + }) + .then(function () { + self.searching = false; + self.url = ""; + }); + return false; + }, + }, + }); + + + diff --git a/client/episodes.html b/client/episodes.html index 9f45010..9306c4c 100644 --- a/client/episodes.html +++ b/client/episodes.html @@ -88,10 +88,10 @@ hr{
{{if .previousPage }} - Newer + Newer {{end}} {{if .nextPage }} - Older + Older {{end}}
diff --git a/controllers/pages.go b/controllers/pages.go index 021729b..bf4a668 100644 --- a/controllers/pages.go +++ b/controllers/pages.go @@ -1,7 +1,9 @@ package controllers import ( + "bytes" "fmt" + "io" "math" "net/http" "os" @@ -41,15 +43,16 @@ func PodcastPage(c *gin.Context) { if err := db.GetPodcastById(searchByIdQuery.Id, &podcast); err == nil { setting := c.MustGet("setting").(*db.Setting) c.HTML(http.StatusOK, "episodes.html", gin.H{ - "title": podcast.Title, - "podcastItems": podcast.PodcastItems, - "setting": setting, - "page": 1, - "count": 10, - "totalCount": len(podcast.PodcastItems), - "totalPages": 0, - "nextPage": 0, - "previousPage": 0, + "title": podcast.Title, + "podcastItems": podcast.PodcastItems, + "setting": setting, + "page": 1, + "count": 10, + "totalCount": len(podcast.PodcastItems), + "totalPages": 0, + "nextPage": 0, + "previousPage": 0, + "downloadedOnly": false, }) } else { c.JSON(http.StatusBadRequest, err) @@ -111,7 +114,7 @@ func AllEpisodesPage(c *gin.Context) { } var podcastItems []db.PodcastItem var totalCount int64 - if err := db.GetPaginatedPodcastItems(page, count, &podcastItems, &totalCount); err == nil { + if err := db.GetPaginatedPodcastItems(page, count, pagination.DownloadedOnly, &podcastItems, &totalCount); err == nil { setting := c.MustGet("setting").(*db.Setting) totalPages := math.Ceil(float64(totalCount) / float64(count)) nextPage, previousPage := 0, 0 @@ -122,21 +125,22 @@ func AllEpisodesPage(c *gin.Context) { previousPage = page - 1 } c.HTML(http.StatusOK, "episodes.html", gin.H{ - "title": "All Episodes", - "podcastItems": podcastItems, - "setting": setting, - "page": page, - "count": count, - "totalCount": totalCount, - "totalPages": totalPages, - "nextPage": nextPage, - "previousPage": previousPage, + "title": "All Episodes", + "podcastItems": podcastItems, + "setting": setting, + "page": page, + "count": count, + "totalCount": totalCount, + "totalPages": totalPages, + "nextPage": nextPage, + "previousPage": previousPage, + "downloadedOnly": pagination.DownloadedOnly, }) } else { c.JSON(http.StatusBadRequest, err) } } else { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid request"}) } } @@ -162,6 +166,27 @@ func Search(c *gin.Context) { } } +func UploadOpml(c *gin.Context) { + file, _, err := c.Request.FormFile("file") + defer file.Close() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid request"}) + return + } + + buf := bytes.NewBuffer(nil) + if _, err := io.Copy(buf, file); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid request"}) + return + } + content := string(buf.Bytes()) + err = service.AddOpml(content) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) + } else { + c.JSON(200, gin.H{"success": "File uploaded"}) + } +} func AddNewPodcast(c *gin.Context) { var addPodcastData AddPodcastData diff --git a/controllers/podcast.go b/controllers/podcast.go index d066ec0..e352b35 100644 --- a/controllers/podcast.go +++ b/controllers/podcast.go @@ -22,8 +22,9 @@ type SearchByIdQuery struct { } type Pagination struct { - Page int `uri:"page" query:"page" json:"page" form:"page"` - Count int `uri:"count" query:"count" json:"count" form:"count"` + Page int `uri:"page" query:"page" json:"page" form:"page"` + Count int `uri:"count" query:"count" json:"count" form:"count"` + DownloadedOnly bool `uri:"downloadedOnly" query:"downloadedOnly" json:"downloadedOnly" form:"downloadedOnly"` } type AddPodcastData struct { diff --git a/db/dbfunctions.go b/db/dbfunctions.go index 3053110..252afd5 100644 --- a/db/dbfunctions.go +++ b/db/dbfunctions.go @@ -26,8 +26,13 @@ func GetAllPodcastItems(podcasts *[]PodcastItem) error { result := DB.Preload("Podcast").Order("pub_date desc").Find(&podcasts) return result.Error } -func GetPaginatedPodcastItems(page int, count int, podcasts *[]PodcastItem, total *int64) error { - result := DB.Debug().Preload("Podcast").Limit(count).Offset((page - 1) * count).Order("pub_date desc").Find(&podcasts) +func GetPaginatedPodcastItems(page int, count int, downloadedOnly bool, podcasts *[]PodcastItem, total *int64) error { + query := DB.Debug().Preload("Podcast") + if downloadedOnly { + query = query.Where("download_status=?", Downloaded) + } + + result := query.Limit(count).Offset((page - 1) * count).Order("pub_date desc").Find(&podcasts) DB.Model(&PodcastItem{}).Count(total) @@ -75,6 +80,11 @@ func GetAllPodcastItemsAlreadyDownloaded() (*[]PodcastItem, error) { return &podcastItems, result.Error } +func GetPodcastItemsByPodcastIdAndGUIDs(podcastId string, guids []string) (*[]PodcastItem, error) { + var podcastItems []PodcastItem + result := DB.Preload(clause.Associations).Where(&PodcastItem{PodcastID: podcastId}).Where("guid IN ?", guids).Find(&podcastItems) + return &podcastItems, result.Error +} func GetPodcastItemByPodcastIdAndGUID(podcastId string, guid string, podcastItem *PodcastItem) error { result := DB.Preload(clause.Associations).Where(&PodcastItem{PodcastID: podcastId, GUID: guid}).First(&podcastItem) diff --git a/main.go b/main.go index cdfcd9d..18b4756 100644 --- a/main.go +++ b/main.go @@ -91,6 +91,7 @@ func main() { r.GET("/settings", controllers.SettingsPage) r.POST("/settings", controllers.UpdateSetting) r.GET("/backups", controllers.BackupsPage) + r.POST("/opml", controllers.UploadOpml) go assetEnv() go intiCron() diff --git a/model/opmlModels.go b/model/opmlModels.go new file mode 100644 index 0000000..3ccb6b9 --- /dev/null +++ b/model/opmlModels.go @@ -0,0 +1,30 @@ +package model + +import "encoding/xml" + +type OpmlModel struct { + XMLName xml.Name `xml:"opml"` + Text string `xml:",chardata"` + Version string `xml:"version,attr"` + Head struct { + Text string `xml:",chardata"` + Title string `xml:"title"` + } `xml:"head"` + Body struct { + Text string `xml:",chardata"` + Outline []struct { + Text string `xml:",chardata"` + Title string `xml:"title,attr"` + AttrText string `xml:"text,attr"` + Type string `xml:"type,attr"` + XmlUrl string `xml:"xmlUrl,attr"` + Outline []struct { + Text string `xml:",chardata"` + AttrText string `xml:"text,attr"` + Title string `xml:"title,attr"` + Type string `xml:"type,attr"` + XmlUrl string `xml:"xmlUrl,attr"` + } `xml:"outline"` + } `xml:"outline"` + } `xml:"body"` +} diff --git a/service/fileService.go b/service/fileService.go index f74dc18..e0d2e1a 100644 --- a/service/fileService.go +++ b/service/fileService.go @@ -19,10 +19,13 @@ import ( ) func Download(link string, episodeTitle string, podcastName string) (string, error) { + if link == "" { + return "", errors.New("Download path empty") + } client := httpClient() resp, err := client.Get(link) if err != nil { - Logger.Errorw("Error getting response", err) + Logger.Errorw("Error getting response: "+link, err) return "", err } @@ -31,7 +34,7 @@ func Download(link string, episodeTitle string, podcastName string) (string, err finalPath := path.Join(folder, fileName) file, err := os.Create(finalPath) if err != nil { - Logger.Errorw("Error creating file", err) + Logger.Errorw("Error creating file"+link, err) return "", err } defer resp.Body.Close() @@ -39,7 +42,7 @@ func Download(link string, episodeTitle string, podcastName string) (string, err //fmt.Println(size) defer file.Close() if erra != nil { - Logger.Errorw("Error saving file", err) + Logger.Errorw("Error saving file"+link, err) return "", erra } changeOwnership(finalPath) diff --git a/service/podcastService.go b/service/podcastService.go index a35bd01..a4e3729 100644 --- a/service/podcastService.go +++ b/service/podcastService.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "strconv" + "sync" "time" "github.com/akhilrex/podgrab/db" @@ -25,6 +26,12 @@ func init() { defer zapper.Sync() } +func ParseOpml(content string) (model.OpmlModel, error) { + var response model.OpmlModel + err := xml.Unmarshal([]byte(content), &response) + return response, err +} + //FetchURL is func FetchURL(url string) (model.PodcastData, error) { body, err := makeQuery(url) @@ -40,9 +47,45 @@ func GetAllPodcasts() *[]db.Podcast { db.GetAllPodcasts(&podcasts) return &podcasts } + +func AddOpml(content string) error { + model, err := ParseOpml(content) + if err != nil { + return errors.New("Invalid file format") + } + var wg sync.WaitGroup + setting := db.GetOrCreateSetting() + for _, outline := range model.Body.Outline { + if outline.XmlUrl != "" { + wg.Add(1) + go func(url string) { + defer wg.Done() + AddPodcast(url) + + }(outline.XmlUrl) + } + + for _, innerOutline := range outline.Outline { + if innerOutline.XmlUrl != "" { + wg.Add(1) + go func(url string) { + defer wg.Done() + AddPodcast(url) + }(innerOutline.XmlUrl) + } + } + } + wg.Wait() + if setting.DownloadOnAdd { + go RefreshEpisodes() + } + return nil + +} func AddPodcast(url string) (db.Podcast, error) { var podcast db.Podcast err := db.GetPodcastByURL(url, &podcast) + fmt.Println(url) if errors.Is(err, gorm.ErrRecordNotFound) { data, err := FetchURL(url) if err != nil { @@ -77,11 +120,24 @@ func AddPodcastItems(podcast *db.Podcast) error { // if len(data.Channel.Item) < limit { // limit = len(data.Channel.Item) // } + var allGuids []string + for i := 0; i < len(data.Channel.Item); i++ { + obj := data.Channel.Item[i] + allGuids = append(allGuids, obj.Guid.Text) + } + + existingItems, err := db.GetPodcastItemsByPodcastIdAndGUIDs(podcast.ID, allGuids) + keyMap := make(map[string]int) + + for _, item := range *existingItems { + keyMap[item.GUID] = 1 + } + for i := 0; i < len(data.Channel.Item); i++ { obj := data.Channel.Item[i] var podcastItem db.PodcastItem - err := db.GetPodcastItemByPodcastIdAndGUID(podcast.ID, obj.Guid.Text, &podcastItem) - if errors.Is(err, gorm.ErrRecordNotFound) { + _, keyExists := keyMap[obj.Guid.Text] + if !keyExists { duration, _ := strconv.Atoi(obj.Duration) pubDate, _ := time.Parse(time.RFC1123Z, obj.PubDate) if (pubDate == time.Time{}) { @@ -143,11 +199,20 @@ func DownloadMissingEpisodes() error { if err != nil { return err } - for _, item := range *data { - - url, _ := Download(item.FileURL, item.Title, item.Podcast.Title) - SetPodcastItemAsDownloaded(item.ID, url) + var wg sync.WaitGroup + for index, item := range *data { + wg.Add(1) + go func(item db.PodcastItem) { + defer wg.Done() + url, _ := Download(item.FileURL, item.Title, item.Podcast.Title) + SetPodcastItemAsDownloaded(item.ID, url) + }(item) + + if index%5 == 0 { + wg.Wait() + } } + wg.Wait() return nil } func CheckMissingFiles() error {