53 |
54 | {{template "scripts"}}
55 |
57 |
58 |
--------------------------------------------------------------------------------
/client/commoncss.html:
--------------------------------------------------------------------------------
1 | {{define "commoncss"}}
2 |
13 | {{if .setting.DarkMode}}
14 |
23 | {{else}}
24 |
33 | {{end}}
34 |
44 |
45 |
195 |
202 | {{end}}
203 |
--------------------------------------------------------------------------------
/client/episodes.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
231 |
232 | {{template "scripts"}}
233 |
359 |
389 |
390 |
391 |
--------------------------------------------------------------------------------
/client/navbar.html:
--------------------------------------------------------------------------------
1 | {{define "navbar"}}
2 |
138 |
171 |
200 |
201 |
219 |
220 | {{end}}
--------------------------------------------------------------------------------
/client/podcast.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
164 | {{template "scripts"}}
165 |
197 |
198 |
199 |
--------------------------------------------------------------------------------
/client/podcastlist.html:
--------------------------------------------------------------------------------
1 | {{define "podcastlist"}}
2 |
3 | {{end}}
--------------------------------------------------------------------------------
/client/scripts.html:
--------------------------------------------------------------------------------
1 | {{define "scripts"}}
2 |
3 |
4 |
5 |
6 |
303 | {{end}}
304 |
--------------------------------------------------------------------------------
/client/settings.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
32 |
33 | {{template "navbar" .}}
34 |
35 |
49 |
50 |
51 |
119 |
120 |
121 |
Disk Stats
122 |
123 |
124 | Disk Used
125 | {{formatFileSize .diskStats.Downloaded}}
126 |
127 |
128 | Pending Download
129 | {{ formatFileSize .diskStats.PendingDownload }}
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
More Info
139 |
140 | This project is under active development which means I release new updates very frequently. I will eventually build the version management/update checking mechanism. Until then it is recommended that you use something like watchtower which will automatically update your containers whenever I release a new version or periodically rebuild the container with the latest image manually.
141 |
142 |
164 |
165 |
166 |
167 |
168 | {{template "scripts"}}
169 |
244 |
245 |
246 |
--------------------------------------------------------------------------------
/client/tags.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
{{.title}} - PodGrab
7 | {{template "commoncss" .}}
8 |
60 |
61 |
62 |
63 | {{template "navbar" .}}
64 |
65 |
66 |
{{$setting := .setting}} {{range .tags}}
67 |
68 |
69 |
70 |
{{.Label}}
71 |
72 |
73 | {{range .Podcasts}}
74 |
81 | {{end}}
82 |
83 |
84 |
85 |
91 |
97 |
103 |
110 |
111 |
112 |
113 |
114 | {{else}}
115 |
116 |
117 | It seems you haven't created any tags yet. Try creating a few new tags
118 | on the podcast listing page.
119 |
120 |
121 | {{end}}
122 |
123 |
124 |
125 | {{if .previousPage }}
126 |
Newer
131 | {{end}} {{if .nextPage }}
132 |
Older
137 | {{end}}
138 |
139 |
140 |
141 |
142 | {{template "scripts"}}
143 |
182 |
215 |
216 |
217 |
--------------------------------------------------------------------------------
/controllers/pages.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "io"
7 | "math"
8 | "net/http"
9 | "os"
10 | "strings"
11 | "time"
12 |
13 | "github.com/akhilrex/podgrab/db"
14 | "github.com/akhilrex/podgrab/model"
15 | "github.com/akhilrex/podgrab/service"
16 | "github.com/gin-gonic/gin"
17 | )
18 |
19 | type SearchGPodderData struct {
20 | Q string `binding:"required" form:"q" json:"q" query:"q"`
21 | SearchSource string `binding:"required" form:"searchSource" json:"searchSource" query:"searchSource"`
22 | }
23 | type SettingModel struct {
24 | DownloadOnAdd bool `form:"downloadOnAdd" json:"downloadOnAdd" query:"downloadOnAdd"`
25 | InitialDownloadCount int `form:"initialDownloadCount" json:"initialDownloadCount" query:"initialDownloadCount"`
26 | AutoDownload bool `form:"autoDownload" json:"autoDownload" query:"autoDownload"`
27 | AppendDateToFileName bool `form:"appendDateToFileName" json:"appendDateToFileName" query:"appendDateToFileName"`
28 | AppendEpisodeNumberToFileName bool `form:"appendEpisodeNumberToFileName" json:"appendEpisodeNumberToFileName" query:"appendEpisodeNumberToFileName"`
29 | DarkMode bool `form:"darkMode" json:"darkMode" query:"darkMode"`
30 | DownloadEpisodeImages bool `form:"downloadEpisodeImages" json:"downloadEpisodeImages" query:"downloadEpisodeImages"`
31 | GenerateNFOFile bool `form:"generateNFOFile" json:"generateNFOFile" query:"generateNFOFile"`
32 | DontDownloadDeletedFromDisk bool `form:"dontDownloadDeletedFromDisk" json:"dontDownloadDeletedFromDisk" query:"dontDownloadDeletedFromDisk"`
33 | BaseUrl string `form:"baseUrl" json:"baseUrl" query:"baseUrl"`
34 | MaxDownloadConcurrency int `form:"maxDownloadConcurrency" json:"maxDownloadConcurrency" query:"maxDownloadConcurrency"`
35 | UserAgent string `form:"userAgent" json:"userAgent" query:"userAgent"`
36 | }
37 |
38 | var searchOptions = map[string]string{
39 | "itunes": "iTunes",
40 | "podcastindex": "PodcastIndex",
41 | }
42 | var searchProvider = map[string]service.SearchService{
43 | "itunes": new(service.ItunesService),
44 | "podcastindex": new(service.PodcastIndexService),
45 | }
46 |
47 | func AddPage(c *gin.Context) {
48 | setting := c.MustGet("setting").(*db.Setting)
49 | c.HTML(http.StatusOK, "addPodcast.html", gin.H{"title": "Add Podcast", "setting": setting, "searchOptions": searchOptions})
50 | }
51 | func HomePage(c *gin.Context) {
52 | //var podcasts []db.Podcast
53 | podcasts := service.GetAllPodcasts("")
54 | setting := c.MustGet("setting").(*db.Setting)
55 | c.HTML(http.StatusOK, "index.html", gin.H{"title": "Podgrab", "podcasts": podcasts, "setting": setting})
56 | }
57 | func PodcastPage(c *gin.Context) {
58 | var searchByIdQuery SearchByIdQuery
59 | if c.ShouldBindUri(&searchByIdQuery) == nil {
60 |
61 | var podcast db.Podcast
62 |
63 | if err := db.GetPodcastById(searchByIdQuery.Id, &podcast); err == nil {
64 | var pagination model.Pagination
65 | if c.ShouldBindQuery(&pagination) == nil {
66 | var page, count int
67 | if page = pagination.Page; page == 0 {
68 | page = 1
69 | }
70 | if count = pagination.Count; count == 0 {
71 | count = 10
72 | }
73 | setting := c.MustGet("setting").(*db.Setting)
74 | totalCount := len(podcast.PodcastItems)
75 | totalPages := int(math.Ceil(float64(totalCount) / float64(count)))
76 | nextPage, previousPage := 0, 0
77 | if page < totalPages {
78 | nextPage = page + 1
79 | }
80 | if page > 1 {
81 | previousPage = page - 1
82 | }
83 |
84 | from := (page - 1) * count
85 | to := page * count
86 | if to > totalCount {
87 | to = totalCount
88 | }
89 | c.HTML(http.StatusOK, "episodes.html", gin.H{
90 | "title": podcast.Title,
91 | "podcastItems": podcast.PodcastItems[from:to],
92 | "setting": setting,
93 | "page": page,
94 | "count": count,
95 | "totalCount": totalCount,
96 | "totalPages": totalPages,
97 | "nextPage": nextPage,
98 | "previousPage": previousPage,
99 | "downloadedOnly": false,
100 | "podcastId": searchByIdQuery.Id,
101 | })
102 | } else {
103 | c.JSON(http.StatusBadRequest, err)
104 | }
105 | } else {
106 | c.JSON(http.StatusBadRequest, err)
107 | }
108 | } else {
109 | c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
110 | }
111 |
112 | }
113 |
114 | func getItemsToPlay(itemIds []string, podcastId string, tagIds []string) []db.PodcastItem {
115 | var items []db.PodcastItem
116 | if len(itemIds) > 0 {
117 | toAdd, _ := service.GetAllPodcastItemsByIds(itemIds)
118 | items = *toAdd
119 |
120 | } else if podcastId != "" {
121 | pod := service.GetPodcastById(podcastId)
122 | items = pod.PodcastItems
123 | } else if len(tagIds) != 0 {
124 | tags := service.GetTagsByIds(tagIds)
125 | var tagNames []string
126 | var podIds []string
127 | for _, tag := range *tags {
128 | tagNames = append(tagNames, tag.Label)
129 | for _, pod := range tag.Podcasts {
130 | podIds = append(podIds, pod.ID)
131 | }
132 | }
133 | items = *service.GetAllPodcastItemsByPodcastIds(podIds)
134 | }
135 | return items
136 | }
137 |
138 | func PlayerPage(c *gin.Context) {
139 |
140 | itemIds, hasItemIds := c.GetQueryArray("itemIds")
141 | podcastId, hasPodcastId := c.GetQuery("podcastId")
142 | tagIds, hasTagIds := c.GetQueryArray("tagIds")
143 | title := "Podgrab"
144 | var items []db.PodcastItem
145 | var totalCount int64
146 | if hasItemIds {
147 | toAdd, _ := service.GetAllPodcastItemsByIds(itemIds)
148 | items = *toAdd
149 | totalCount = int64(len(items))
150 | } else if hasPodcastId {
151 | pod := service.GetPodcastById(podcastId)
152 | items = pod.PodcastItems
153 | title = "Playing: " + pod.Title
154 | totalCount = int64(len(items))
155 | } else if hasTagIds {
156 | tags := service.GetTagsByIds(tagIds)
157 | var tagNames []string
158 | var podIds []string
159 | for _, tag := range *tags {
160 | tagNames = append(tagNames, tag.Label)
161 | for _, pod := range tag.Podcasts {
162 | podIds = append(podIds, pod.ID)
163 | }
164 | }
165 | items = *service.GetAllPodcastItemsByPodcastIds(podIds)
166 | if len(tagNames) == 1 {
167 | title = fmt.Sprintf("Playing episodes with tag : %s", (tagNames[0]))
168 | } else {
169 | title = fmt.Sprintf("Playing episodes with tags : %s", strings.Join(tagNames, ", "))
170 | }
171 | } else {
172 | title = "Playing Latest Episodes"
173 | if err := db.GetPaginatedPodcastItems(1, 20, nil, nil, time.Time{}, &items, &totalCount); err != nil {
174 | fmt.Println(err.Error())
175 | }
176 | }
177 | setting := c.MustGet("setting").(*db.Setting)
178 |
179 | c.HTML(http.StatusOK, "player.html", gin.H{
180 | "title": title,
181 | "podcastItems": items,
182 | "setting": setting,
183 | "count": len(items),
184 | "totalCount": totalCount,
185 | "downloadedOnly": true,
186 | })
187 |
188 | }
189 | func SettingsPage(c *gin.Context) {
190 |
191 | setting := c.MustGet("setting").(*db.Setting)
192 | diskStats, _ := db.GetPodcastEpisodeDiskStats()
193 | c.HTML(http.StatusOK, "settings.html", gin.H{
194 | "setting": setting,
195 | "title": "Update your preferences",
196 | "diskStats": diskStats,
197 | })
198 |
199 | }
200 | func BackupsPage(c *gin.Context) {
201 |
202 | files, err := service.GetAllBackupFiles()
203 | var allFiles []interface{}
204 | setting := c.MustGet("setting").(*db.Setting)
205 |
206 | for _, file := range files {
207 | arr := strings.Split(file, string(os.PathSeparator))
208 | name := arr[len(arr)-1]
209 | subsplit := strings.Split(name, "_")
210 | dateStr := subsplit[2]
211 | date, err := time.Parse("2006.01.02", dateStr)
212 | if err == nil {
213 | toAdd := map[string]interface{}{
214 | "date": date,
215 | "name": name,
216 | "path": strings.ReplaceAll(file, string(os.PathSeparator), "/"),
217 | }
218 | allFiles = append(allFiles, toAdd)
219 | }
220 | }
221 |
222 | if err == nil {
223 | c.HTML(http.StatusOK, "backups.html", gin.H{
224 | "backups": allFiles,
225 | "title": "Backups",
226 | "setting": setting,
227 | })
228 | } else {
229 | c.JSON(http.StatusBadRequest, err)
230 | }
231 |
232 | }
233 |
234 | func getSortOptions() interface{} {
235 | return []struct {
236 | Label, Value string
237 | }{
238 | {"Release (asc)", "release_asc"},
239 | {"Release (desc)", "release_desc"},
240 | {"Duration (asc)", "duration_asc"},
241 | {"Duration (desc)", "duration_desc"},
242 | }
243 | }
244 | func AllEpisodesPage(c *gin.Context) {
245 | var filter model.EpisodesFilter
246 | c.ShouldBindQuery(&filter)
247 | filter.VerifyPaginationValues()
248 | setting := c.MustGet("setting").(*db.Setting)
249 | podcasts := service.GetAllPodcasts("")
250 | tags, _ := db.GetAllTags("")
251 | toReturn := gin.H{
252 | "title": "All Episodes",
253 | "podcastItems": []db.PodcastItem{},
254 | "setting": setting,
255 | "page": filter.Page,
256 | "count": filter.Count,
257 | "filter": filter,
258 | "podcasts": podcasts,
259 | "tags": tags,
260 | "sortOptions": getSortOptions(),
261 | }
262 | c.HTML(http.StatusOK, "episodes_new.html", toReturn)
263 |
264 | }
265 |
266 | func AllTagsPage(c *gin.Context) {
267 | var pagination model.Pagination
268 | var page, count int
269 | c.ShouldBindQuery(&pagination)
270 | if page = pagination.Page; page == 0 {
271 | page = 1
272 | }
273 | if count = pagination.Count; count == 0 {
274 | count = 10
275 | }
276 |
277 | var tags []db.Tag
278 | var totalCount int64
279 | //fmt.Printf("%+v\n", filter)
280 |
281 | if err := db.GetPaginatedTags(page, count,
282 | &tags, &totalCount); err == nil {
283 |
284 | setting := c.MustGet("setting").(*db.Setting)
285 | totalPages := math.Ceil(float64(totalCount) / float64(count))
286 | nextPage, previousPage := 0, 0
287 | if float64(page) < totalPages {
288 | nextPage = page + 1
289 | }
290 | if page > 1 {
291 | previousPage = page - 1
292 | }
293 | toReturn := gin.H{
294 | "title": "Tags",
295 | "tags": tags,
296 | "setting": setting,
297 | "page": page,
298 | "count": count,
299 | "totalCount": totalCount,
300 | "totalPages": totalPages,
301 | "nextPage": nextPage,
302 | "previousPage": previousPage,
303 | }
304 | c.HTML(http.StatusOK, "tags.html", toReturn)
305 | } else {
306 | c.JSON(http.StatusBadRequest, err)
307 | }
308 |
309 | }
310 |
311 | func Search(c *gin.Context) {
312 | var searchQuery SearchGPodderData
313 | if c.ShouldBindQuery(&searchQuery) == nil {
314 | var searcher service.SearchService
315 | var isValidSearchProvider bool
316 | if searcher, isValidSearchProvider = searchProvider[searchQuery.SearchSource]; !isValidSearchProvider {
317 | searcher = new(service.PodcastIndexService)
318 | }
319 |
320 | data := searcher.Query(searchQuery.Q)
321 | allPodcasts := service.GetAllPodcasts("")
322 |
323 | urls := make(map[string]string, len(*allPodcasts))
324 | for _, pod := range *allPodcasts {
325 | urls[pod.URL] = pod.ID
326 | }
327 | for _, pod := range data {
328 | _, ok := urls[pod.URL]
329 | pod.AlreadySaved = ok
330 | }
331 | c.JSON(200, data)
332 | }
333 |
334 | }
335 |
336 | func GetOmpl(c *gin.Context) {
337 |
338 | usePodgrabLink := c.DefaultQuery("usePodgrabLink", "false") == "true"
339 |
340 | data, err := service.ExportOmpl(usePodgrabLink, getBaseUrl(c))
341 | if err != nil {
342 | c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid request"})
343 | return
344 | }
345 | c.Header("Content-Disposition", "attachment; filename=podgrab-export.opml")
346 | c.Data(200, "text/xml", data)
347 | }
348 | func UploadOpml(c *gin.Context) {
349 | file, _, err := c.Request.FormFile("file")
350 | defer file.Close()
351 | if err != nil {
352 | c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid request"})
353 | return
354 | }
355 |
356 | buf := bytes.NewBuffer(nil)
357 | if _, err := io.Copy(buf, file); err != nil {
358 | c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid request"})
359 | return
360 | }
361 | content := string(buf.Bytes())
362 | err = service.AddOpml(content)
363 | if err != nil {
364 | c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()})
365 | } else {
366 | c.JSON(200, gin.H{"success": "File uploaded"})
367 | }
368 | }
369 |
370 | func AddNewPodcast(c *gin.Context) {
371 | var addPodcastData AddPodcastData
372 | err := c.ShouldBind(&addPodcastData)
373 |
374 | if err == nil {
375 |
376 | _, err = service.AddPodcast(addPodcastData.Url)
377 | if err == nil {
378 | go service.RefreshEpisodes()
379 | c.Redirect(http.StatusFound, "/")
380 |
381 | } else {
382 |
383 | c.JSON(http.StatusBadRequest, err)
384 |
385 | }
386 | } else {
387 | // fmt.Println(err.Error())
388 | c.JSON(http.StatusBadRequest, err)
389 | }
390 |
391 | }
392 |
--------------------------------------------------------------------------------
/controllers/websockets.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 |
8 | "github.com/gorilla/websocket"
9 | )
10 |
11 | type EnqueuePayload struct {
12 | ItemIds []string `json:"itemIds"`
13 | PodcastId string `json:"podcastId"`
14 | TagIds []string `json:"tagIds"`
15 | }
16 |
17 | var wsupgrader = websocket.Upgrader{
18 | ReadBufferSize: 1024,
19 | WriteBufferSize: 1024,
20 | }
21 |
22 | var activePlayers = make(map[*websocket.Conn]string)
23 | var allConnections = make(map[*websocket.Conn]string)
24 |
25 | var broadcast = make(chan Message) // broadcast channel
26 |
27 | type Message struct {
28 | Identifier string `json:"identifier"`
29 | MessageType string `json:"messageType"`
30 | Payload string `json:"payload"`
31 | Connection *websocket.Conn `json:"-"`
32 | }
33 |
34 | func Wshandler(w http.ResponseWriter, r *http.Request) {
35 | conn, err := wsupgrader.Upgrade(w, r, nil)
36 | if err != nil {
37 | fmt.Println("Failed to set websocket upgrade: %+v", err)
38 | return
39 | }
40 | defer conn.Close()
41 | for {
42 | var mess Message
43 | err := conn.ReadJSON(&mess)
44 | if err != nil {
45 | // fmt.Println("Socket Error")
46 | //fmt.Println(err.Error())
47 | isPlayer := activePlayers[conn] != ""
48 | if isPlayer {
49 | delete(activePlayers, conn)
50 | broadcast <- Message{
51 | MessageType: "PlayerRemoved",
52 | Identifier: mess.Identifier,
53 | }
54 | }
55 | delete(allConnections, conn)
56 | break
57 | }
58 | mess.Connection = conn
59 | allConnections[conn] = mess.Identifier
60 | broadcast <- mess
61 | // conn.WriteJSON(mess)
62 | }
63 | }
64 |
65 | func HandleWebsocketMessages() {
66 | for {
67 | // Grab the next message from the broadcast channel
68 | msg := <-broadcast
69 | //fmt.Println(msg)
70 |
71 | switch msg.MessageType {
72 | case "RegisterPlayer":
73 | activePlayers[msg.Connection] = msg.Identifier
74 | for connection, _ := range allConnections {
75 | connection.WriteJSON(Message{
76 | Identifier: msg.Identifier,
77 | MessageType: "PlayerExists",
78 | })
79 | }
80 | fmt.Println("Player Registered")
81 | case "PlayerRemoved":
82 | for connection, _ := range allConnections {
83 | connection.WriteJSON(Message{
84 | Identifier: msg.Identifier,
85 | MessageType: "NoPlayer",
86 | })
87 | }
88 | fmt.Println("Player Registered")
89 | case "Enqueue":
90 | var payload EnqueuePayload
91 | fmt.Println(msg.Payload)
92 | err := json.Unmarshal([]byte(msg.Payload), &payload)
93 | if err == nil {
94 | items := getItemsToPlay(payload.ItemIds, payload.PodcastId, payload.TagIds)
95 | var player *websocket.Conn
96 | for connection, id := range activePlayers {
97 |
98 | if msg.Identifier == id {
99 | player = connection
100 | break
101 | }
102 | }
103 | if player != nil {
104 | payloadStr, err := json.Marshal(items)
105 | if err == nil {
106 | player.WriteJSON(Message{
107 | Identifier: msg.Identifier,
108 | MessageType: "Enqueue",
109 | Payload: string(payloadStr),
110 | })
111 | }
112 | }
113 | } else {
114 | fmt.Println(err.Error())
115 | }
116 | case "Register":
117 | var player *websocket.Conn
118 | for connection, id := range activePlayers {
119 |
120 | if msg.Identifier == id {
121 | player = connection
122 | break
123 | }
124 | }
125 |
126 | if player == nil {
127 | fmt.Println("Player Not Exists")
128 | msg.Connection.WriteJSON(Message{
129 | Identifier: msg.Identifier,
130 | MessageType: "NoPlayer",
131 | })
132 | } else {
133 | msg.Connection.WriteJSON(Message{
134 | Identifier: msg.Identifier,
135 | MessageType: "PlayerExists",
136 | })
137 | }
138 | }
139 | // Send it out to every client that is currently connected
140 | // for client := range clients {
141 | // err := client.WriteJSON(msg)
142 | // if err != nil {
143 | // log.Printf("error: %v", err)
144 | // client.Close()
145 | // delete(clients, client)
146 | // }
147 | // }
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/db/base.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "time"
5 |
6 | uuid "github.com/satori/go.uuid"
7 | "gorm.io/gorm"
8 | )
9 |
10 | //Base is
11 | type Base struct {
12 | ID string `sql:"type:uuid;primary_key"`
13 | CreatedAt time.Time
14 | UpdatedAt time.Time
15 | DeletedAt *time.Time `gorm:"index"`
16 | }
17 |
18 | //BeforeCreate
19 | func (base *Base) BeforeCreate(tx *gorm.DB) error {
20 | tx.Statement.SetColumn("ID", uuid.NewV4().String())
21 | return nil
22 | }
23 |
--------------------------------------------------------------------------------
/db/db.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 | "path"
8 |
9 | "gorm.io/driver/sqlite"
10 |
11 | "gorm.io/gorm"
12 | )
13 |
14 | //DB is
15 | var DB *gorm.DB
16 |
17 | //Init is used to Initialize Database
18 | func Init() (*gorm.DB, error) {
19 | // github.com/mattn/go-sqlite3
20 | configPath := os.Getenv("CONFIG")
21 | dbPath := path.Join(configPath, "podgrab.db")
22 | log.Println(dbPath)
23 | db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
24 | if err != nil {
25 | fmt.Println("db err: ", err)
26 | return nil, err
27 | }
28 |
29 | localDB, _ := db.DB()
30 | localDB.SetMaxIdleConns(10)
31 | //db.LogMode(true)
32 | DB = db
33 | return DB, nil
34 | }
35 |
36 | //Migrate Database
37 | func Migrate() {
38 | DB.AutoMigrate(&Podcast{}, &PodcastItem{}, &Setting{}, &Migration{}, &JobLock{}, &Tag{})
39 | RunMigrations()
40 | }
41 |
42 | // Using this function to get a connection, you can create your connection pool here.
43 | func GetDB() *gorm.DB {
44 | return DB
45 | }
46 |
--------------------------------------------------------------------------------
/db/migrations.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "time"
7 |
8 | "gorm.io/gorm"
9 | )
10 |
11 | type localMigration struct {
12 | Name string
13 | Query string
14 | }
15 |
16 | var migrations = []localMigration{
17 | {
18 | Name: "2020_11_03_04_42_SetDefaultDownloadStatus",
19 | Query: "update podcast_items set download_status=2 where download_path!='' and download_status=0",
20 | },
21 | }
22 |
23 | func RunMigrations() {
24 | for _, mig := range migrations {
25 | ExecuteAndSaveMigration(mig.Name, mig.Query)
26 | }
27 | }
28 | func ExecuteAndSaveMigration(name string, query string) error {
29 | var migration Migration
30 | result := DB.Where("name=?", name).First(&migration)
31 | if errors.Is(result.Error, gorm.ErrRecordNotFound) {
32 | fmt.Println(query)
33 | result = DB.Debug().Exec(query)
34 | if result.Error == nil {
35 | DB.Save(&Migration{
36 | Date: time.Now(),
37 | Name: name,
38 | })
39 | }
40 | return result.Error
41 | }
42 | return nil
43 | }
44 |
--------------------------------------------------------------------------------
/db/podcast.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | //Podcast is
8 | type Podcast struct {
9 | Base
10 | Title string
11 |
12 | Summary string `gorm:"type:text"`
13 |
14 | Author string
15 |
16 | Image string
17 |
18 | URL string
19 |
20 | LastEpisode *time.Time
21 |
22 | PodcastItems []PodcastItem
23 |
24 | Tags []*Tag `gorm:"many2many:podcast_tags;"`
25 |
26 | DownloadedEpisodesCount int `gorm:"-"`
27 | DownloadingEpisodesCount int `gorm:"-"`
28 | AllEpisodesCount int `gorm:"-"`
29 |
30 | DownloadedEpisodesSize int64 `gorm:"-"`
31 | DownloadingEpisodesSize int64 `gorm:"-"`
32 | AllEpisodesSize int64 `gorm:"-"`
33 |
34 | IsPaused bool `gorm:"default:false"`
35 | }
36 |
37 | //PodcastItem is
38 | type PodcastItem struct {
39 | Base
40 | PodcastID string
41 | Podcast Podcast
42 | Title string
43 | Summary string `gorm:"type:text"`
44 |
45 | EpisodeType string
46 |
47 | Duration int
48 |
49 | PubDate time.Time
50 |
51 | FileURL string
52 |
53 | GUID string
54 | Image string
55 |
56 | DownloadDate time.Time
57 | DownloadPath string
58 | DownloadStatus DownloadStatus `gorm:"default:0"`
59 |
60 | IsPlayed bool `gorm:"default:false"`
61 |
62 | BookmarkDate time.Time
63 |
64 | LocalImage string
65 |
66 | FileSize int64
67 | }
68 |
69 | type DownloadStatus int
70 |
71 | const (
72 | NotDownloaded DownloadStatus = iota
73 | Downloading
74 | Downloaded
75 | Deleted
76 | )
77 |
78 | type Setting struct {
79 | Base
80 | DownloadOnAdd bool `gorm:"default:true"`
81 | InitialDownloadCount int `gorm:"default:5"`
82 | AutoDownload bool `gorm:"default:true"`
83 | AppendDateToFileName bool `gorm:"default:false"`
84 | AppendEpisodeNumberToFileName bool `gorm:"default:false"`
85 | DarkMode bool `gorm:"default:false"`
86 | DownloadEpisodeImages bool `gorm:"default:false"`
87 | GenerateNFOFile bool `gorm:"default:false"`
88 | DontDownloadDeletedFromDisk bool `gorm:"default:false"`
89 | BaseUrl string
90 | MaxDownloadConcurrency int `gorm:"default:5"`
91 | UserAgent string
92 | }
93 | type Migration struct {
94 | Base
95 | Date time.Time
96 | Name string
97 | }
98 |
99 | type JobLock struct {
100 | Base
101 | Date time.Time
102 | Name string
103 | Duration int
104 | }
105 |
106 | type Tag struct {
107 | Base
108 | Label string
109 | Description string `gorm:"type:text"`
110 | Podcasts []*Podcast `gorm:"many2many:podcast_tags;"`
111 | }
112 |
113 | func (lock *JobLock) IsLocked() bool {
114 | return lock != nil && lock.Date != time.Time{}
115 | }
116 |
117 | type PodcastItemStatsModel struct {
118 | PodcastID string
119 | DownloadStatus DownloadStatus
120 | Count int
121 | Size int64
122 | }
123 |
124 | type PodcastItemDiskStatsModel struct {
125 | DownloadStatus DownloadStatus
126 | Count int
127 | Size int64
128 | }
129 |
130 | type PodcastItemConsolidateDiskStatsModel struct {
131 | Downloaded int64
132 | Downloading int64
133 | NotDownloaded int64
134 | Deleted int64
135 | PendingDownload int64
136 | }
137 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "2.1"
2 | services:
3 | podgrab:
4 | image: akhilrex/podgrab
5 | container_name: podgrab
6 | environment:
7 | - CHECK_FREQUENCY=240
8 | volumes:
9 | - /path/to/config:/config
10 | - /path/to/data:/assets
11 | ports:
12 | - 8080:8080
13 | restart: unless-stopped
14 |
--------------------------------------------------------------------------------
/docs/ubuntu-install.md:
--------------------------------------------------------------------------------
1 | # Building from source / Ubuntu Installation Guide
2 |
3 | Although personally I feel that using the docker container is the best way of using and enjoying something like Podgrab, a lot of people in the community are still not comfortable with using Docker and wanted to host it natively on their Linux servers.
4 |
5 | This guide has been written with Ubuntu in mind. If you are using any other flavour of Linux and are decently competent with using command line tools, it should be easy to figure out the steps for your specific distro.
6 |
7 | ## Install Go
8 |
9 | Podgrab is built using Go which would be needed to compile and build the source code. Podgrab is written with Go 1.15 so any version equal to or above this should be good to Go.
10 |
11 | If you already have Go installed on your machine, you can skip to the next step.
12 |
13 | Get precise Go installation process at the official link here - https://golang.org/doc/install
14 |
15 | Following steps will only work if Go is installed and configured properly.
16 |
17 | ## Install dependencies
18 |
19 | ``` bash
20 | sudo apt-get install -y git ca-certificates ufw gcc
21 | ```
22 |
23 | ## Clone from Git
24 |
25 | ``` bash
26 | git clone --depth 1 https://github.com/akhilrex/podgrab
27 | ```
28 |
29 | ## Build and Copy dependencies
30 |
31 | ``` bash
32 | cd podgrab
33 | mkdir -p ./dist
34 | cp -r client ./dist
35 | cp -r webassets ./dist
36 | cp .env ./dist
37 | go build -o ./dist/podgrab ./main.go
38 | ```
39 |
40 | ## Create final destination and copy executable
41 | ``` bash
42 | sudo mkdir -p /usr/local/bin/podgrab
43 | mv -v dist/* /usr/local/bin/podgrab
44 | mv -v dist/.* /usr/local/bin/podgrab
45 | ```
46 |
47 | At this point theoretically the installation is complete. You can make the relevant changes in the ```.env``` file present at ```/usr/local/bin/podgrab``` path and run the following command
48 |
49 | ``` bash
50 | cd /usr/local/bin/podgrab && ./podgrab
51 | ```
52 |
53 | Point your browser to http://localhost:8080 (if trying on the same machine) or http://server-ip:8080 from other machines.
54 |
55 | If you are using ufw or some other firewall, you might have to make an exception for this port on that.
56 |
57 | ## Setup as service (Optional)
58 |
59 | If you want to run Podgrab in the background as a service or auto-start whenever the server starts, follow the next steps.
60 |
61 | Create new file named ```podgrab.service``` at ```/etc/systemd/system``` and add the following content. You will have to modify the content accordingly if you changed the installation path in the previous steps.
62 |
63 |
64 | ``` unit
65 | [Unit]
66 | Description=Podgrab
67 |
68 | [Service]
69 | ExecStart=/usr/local/bin/podgrab/podgrab
70 | WorkingDirectory=/usr/local/bin/podgrab/
71 | [Install]
72 | WantedBy=multi-user.target
73 | ```
74 |
75 | Run the following commands
76 | ``` bash
77 | sudo systemctl daemon-reload
78 | sudo systemctl enable podgrab.service
79 | sudo systemctl start podgrab.service
80 | ```
81 |
82 | Run the following command to check the service status.
83 |
84 | ``` bash
85 | sudo systemctl status podgrab.service
86 | ```
87 |
88 | # Update Podgrab
89 |
90 | In case you have installed Podgrab and want to update the latest version (another area where Docker really shines) you need to repeat the steps from cloning to building and copying.
91 |
92 | Stop the running service (if using)
93 | ``` bash
94 | sudo systemctl stop podgrab.service
95 | ```
96 |
97 | ## Clone from Git
98 |
99 | ``` bash
100 | git clone --depth 1 https://github.com/akhilrex/podgrab
101 | ```
102 |
103 | ## Build and Copy dependencies
104 |
105 | ``` bash
106 | cd podgrab
107 | mkdir -p ./dist
108 | cp -r client ./dist
109 | cp -r webassets ./dist
110 | cp .env ./dist
111 | go build -o ./dist/podgrab ./main.go
112 | ```
113 |
114 | ## Create final destination and copy executable
115 | ``` bash
116 | sudo mkdir -p /usr/local/bin/podgrab
117 | mv -v dist/* /usr/local/bin/podgrab
118 | ```
119 |
120 | Restart the service (if using)
121 | ``` bash
122 | sudo systemctl start podgrab.service
123 | ```
124 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/akhilrex/podgrab
2 |
3 | go 1.15
4 |
5 | require (
6 | github.com/TheHippo/podcastindex v1.0.0
7 | github.com/antchfx/xmlquery v1.3.3
8 | github.com/chris-ramon/douceur v0.2.0 // indirect
9 | github.com/dgrijalva/jwt-go v3.2.0+incompatible
10 | github.com/gin-contrib/location v0.0.2
11 | github.com/gin-gonic/gin v1.7.2
12 | github.com/gobeam/stringy v0.0.0-20200717095810-8a3637503f62
13 | github.com/gorilla/websocket v1.4.2
14 | github.com/grokify/html-strip-tags-go v0.0.0-20200923094847-079d207a09f1
15 | github.com/jasonlvhit/gocron v0.0.1
16 | github.com/joho/godotenv v1.3.0
17 | github.com/microcosm-cc/bluemonday v1.0.15
18 | github.com/satori/go.uuid v1.2.0
19 | go.uber.org/multierr v1.6.0 // indirect
20 | go.uber.org/zap v1.16.0
21 | golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
22 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e
23 | golang.org/x/text v0.3.6 // indirect
24 | gorm.io/driver/sqlite v1.1.3
25 | gorm.io/gorm v1.20.2
26 | )
27 |
--------------------------------------------------------------------------------
/images/add_podcast.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/images/add_podcast.jpg
--------------------------------------------------------------------------------
/images/all_episodes.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/images/all_episodes.jpg
--------------------------------------------------------------------------------
/images/player.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/images/player.jpg
--------------------------------------------------------------------------------
/images/podcast_episodes.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/images/podcast_episodes.jpg
--------------------------------------------------------------------------------
/images/screenshot.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/images/screenshot.jpg
--------------------------------------------------------------------------------
/images/screenshot_1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/images/screenshot_1.jpg
--------------------------------------------------------------------------------
/images/settings.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/images/settings.jpg
--------------------------------------------------------------------------------
/internal/sanitize/.gitignore:
--------------------------------------------------------------------------------
1 | # Compiled Object files, Static and Dynamic libs (Shared Objects)
2 | *.o
3 | *.a
4 | *.so
5 |
6 | # Folders
7 | _obj
8 | _test
9 |
10 | # Architecture specific extensions/prefixes
11 | *.[568vq]
12 | [568vq].out
13 |
14 | *.cgo1.go
15 | *.cgo2.c
16 | _cgo_defun.c
17 | _cgo_gotypes.go
18 | _cgo_export.*
19 |
20 | _testmain.go
21 |
22 | *.exe
23 |
--------------------------------------------------------------------------------
/internal/sanitize/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2017 Mechanism Design. All rights reserved.
2 |
3 | Redistribution and use in source and binary forms, with or without
4 | modification, are permitted provided that the following conditions are
5 | met:
6 |
7 | * Redistributions of source code must retain the above copyright
8 | notice, this list of conditions and the following disclaimer.
9 | * Redistributions in binary form must reproduce the above
10 | copyright notice, this list of conditions and the following disclaimer
11 | in the documentation and/or other materials provided with the
12 | distribution.
13 | * Neither the name of Google Inc. nor the names of its
14 | contributors may be used to endorse or promote products derived from
15 | this software without specific prior written permission.
16 |
17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
/internal/sanitize/README.md:
--------------------------------------------------------------------------------
1 | sanitize [](https://godoc.org/github.com/kennygrant/sanitize) [](https://goreportcard.com/report/github.com/kennygrant/sanitize) [](https://circleci.com/gh/kennygrant/sanitize)
2 | ========
3 |
4 | Package sanitize provides functions to sanitize html and paths with go (golang).
5 |
6 | FUNCTIONS
7 |
8 |
9 | ```go
10 | sanitize.Accents(s string) string
11 | ```
12 |
13 | Accents replaces a set of accented characters with ascii equivalents.
14 |
15 | ```go
16 | sanitize.BaseName(s string) string
17 | ```
18 |
19 | BaseName makes a string safe to use in a file name, producing a sanitized basename replacing . or / with -. Unlike Name no attempt is made to normalise text as a path.
20 |
21 | ```go
22 | sanitize.HTML(s string) string
23 | ```
24 |
25 | HTML strips html tags with a very simple parser, replace common entities, and escape < and > in the result. The result is intended to be used as plain text.
26 |
27 | ```go
28 | sanitize.HTMLAllowing(s string, args...[]string) (string, error)
29 | ```
30 |
31 | HTMLAllowing parses html and allow certain tags and attributes from the lists optionally specified by args - args[0] is a list of allowed tags, args[1] is a list of allowed attributes. If either is missing default sets are used.
32 |
33 | ```go
34 | sanitize.Name(s string) string
35 | ```
36 |
37 | Name makes a string safe to use in a file name by first finding the path basename, then replacing non-ascii characters.
38 |
39 | ```go
40 | sanitize.Path(s string) string
41 | ```
42 |
43 | Path makes a string safe to use as an url path.
44 |
45 |
46 | Changes
47 | -------
48 |
49 | Version 1.2
50 |
51 | Adjusted HTML function to avoid linter warning
52 | Added more tests from https://githubengineering.com/githubs-post-csp-journey/
53 | Chnaged name of license file
54 | Added badges and change log to readme
55 |
56 | Version 1.1
57 | Fixed type in comments.
58 | Merge pull request from Povilas Balzaravicius Pawka
59 | - replace br tags with newline even when they contain a space
60 |
61 | Version 1.0
62 | First release
--------------------------------------------------------------------------------
/internal/sanitize/sanitize.go:
--------------------------------------------------------------------------------
1 | // Package sanitize provides functions for sanitizing text.
2 | package sanitize
3 |
4 | import (
5 | "bytes"
6 | "html"
7 | "html/template"
8 | "io"
9 | "path"
10 | "regexp"
11 | "strings"
12 |
13 | parser "golang.org/x/net/html"
14 | )
15 |
16 | var (
17 | ignoreTags = []string{"title", "script", "style", "iframe", "frame", "frameset", "noframes", "noembed", "embed", "applet", "object", "base"}
18 |
19 | defaultTags = []string{"h1", "h2", "h3", "h4", "h5", "h6", "div", "span", "hr", "p", "br", "b", "i", "strong", "em", "ol", "ul", "li", "a", "img", "pre", "code", "blockquote", "article", "section"}
20 |
21 | defaultAttributes = []string{"id", "class", "src", "href", "title", "alt", "name", "rel"}
22 | )
23 |
24 | // HTMLAllowing sanitizes html, allowing some tags.
25 | // Arrays of allowed tags and allowed attributes may optionally be passed as the second and third arguments.
26 | func HTMLAllowing(s string, args ...[]string) (string, error) {
27 |
28 | allowedTags := defaultTags
29 | if len(args) > 0 {
30 | allowedTags = args[0]
31 | }
32 | allowedAttributes := defaultAttributes
33 | if len(args) > 1 {
34 | allowedAttributes = args[1]
35 | }
36 |
37 | // Parse the html
38 | tokenizer := parser.NewTokenizer(strings.NewReader(s))
39 |
40 | buffer := bytes.NewBufferString("")
41 | ignore := ""
42 |
43 | for {
44 | tokenType := tokenizer.Next()
45 | token := tokenizer.Token()
46 |
47 | switch tokenType {
48 |
49 | case parser.ErrorToken:
50 | err := tokenizer.Err()
51 | if err == io.EOF {
52 | return buffer.String(), nil
53 | }
54 | return "", err
55 |
56 | case parser.StartTagToken:
57 |
58 | if len(ignore) == 0 && includes(allowedTags, token.Data) {
59 | token.Attr = cleanAttributes(token.Attr, allowedAttributes)
60 | buffer.WriteString(token.String())
61 | } else if includes(ignoreTags, token.Data) {
62 | ignore = token.Data
63 | }
64 |
65 | case parser.SelfClosingTagToken:
66 |
67 | if len(ignore) == 0 && includes(allowedTags, token.Data) {
68 | token.Attr = cleanAttributes(token.Attr, allowedAttributes)
69 | buffer.WriteString(token.String())
70 | } else if token.Data == ignore {
71 | ignore = ""
72 | }
73 |
74 | case parser.EndTagToken:
75 | if len(ignore) == 0 && includes(allowedTags, token.Data) {
76 | token.Attr = []parser.Attribute{}
77 | buffer.WriteString(token.String())
78 | } else if token.Data == ignore {
79 | ignore = ""
80 | }
81 |
82 | case parser.TextToken:
83 | // We allow text content through, unless ignoring this entire tag and its contents (including other tags)
84 | if ignore == "" {
85 | buffer.WriteString(token.String())
86 | }
87 | case parser.CommentToken:
88 | // We ignore comments by default
89 | case parser.DoctypeToken:
90 | // We ignore doctypes by default - html5 does not require them and this is intended for sanitizing snippets of text
91 | default:
92 | // We ignore unknown token types by default
93 |
94 | }
95 |
96 | }
97 |
98 | }
99 |
100 | // HTML strips html tags, replace common entities, and escapes <>&;'" in the result.
101 | // Note the returned text may contain entities as it is escaped by HTMLEscapeString, and most entities are not translated.
102 | func HTML(s string) (output string) {
103 |
104 | // Shortcut strings with no tags in them
105 | if !strings.ContainsAny(s, "<>") {
106 | output = s
107 | } else {
108 |
109 | // First remove line breaks etc as these have no meaning outside html tags (except pre)
110 | // this means pre sections will lose formatting... but will result in less unintentional paras.
111 | s = strings.Replace(s, "\n", "", -1)
112 |
113 | // Then replace line breaks with newlines, to preserve that formatting
114 | s = strings.Replace(s, "", "\n", -1)
115 | s = strings.Replace(s, "
", "\n", -1)
116 | s = strings.Replace(s, "", "\n", -1)
117 | s = strings.Replace(s, "
", "\n", -1)
118 | s = strings.Replace(s, "
", "\n", -1)
119 |
120 | // Walk through the string removing all tags
121 | b := bytes.NewBufferString("")
122 | inTag := false
123 | for _, r := range s {
124 | switch r {
125 | case '<':
126 | inTag = true
127 | case '>':
128 | inTag = false
129 | default:
130 | if !inTag {
131 | b.WriteRune(r)
132 | }
133 | }
134 | }
135 | output = b.String()
136 | }
137 |
138 | // Remove a few common harmless entities, to arrive at something more like plain text
139 | output = strings.Replace(output, "‘", "'", -1)
140 | output = strings.Replace(output, "’", "'", -1)
141 | output = strings.Replace(output, "“", "\"", -1)
142 | output = strings.Replace(output, "”", "\"", -1)
143 | output = strings.Replace(output, " ", " ", -1)
144 | output = strings.Replace(output, """, "\"", -1)
145 | output = strings.Replace(output, "'", "'", -1)
146 |
147 | // Translate some entities into their plain text equivalent (for example accents, if encoded as entities)
148 | output = html.UnescapeString(output)
149 |
150 | // In case we have missed any tags above, escape the text - removes <, >, &, ' and ".
151 | output = template.HTMLEscapeString(output)
152 |
153 | // After processing, remove some harmless entities &, ' and " which are encoded by HTMLEscapeString
154 | output = strings.Replace(output, """, "\"", -1)
155 | output = strings.Replace(output, "'", "'", -1)
156 | output = strings.Replace(output, "& ", "& ", -1) // NB space after
157 | output = strings.Replace(output, "& ", "& ", -1) // NB space after
158 |
159 | return output
160 | }
161 |
162 | // We are very restrictive as this is intended for ascii url slugs
163 | var illegalPath = regexp.MustCompile(`[^[:alnum:]\~\-\./]`)
164 |
165 | // Path makes a string safe to use as a URL path,
166 | // removing accents and replacing separators with -.
167 | // The path may still start at / and is not intended
168 | // for use as a file system path without prefix.
169 | func Path(s string) string {
170 | // Start with lowercase string
171 | filePath := strings.ToLower(s)
172 | filePath = strings.Replace(filePath, "..", "", -1)
173 | filePath = path.Clean(filePath)
174 |
175 | // Remove illegal characters for paths, flattening accents
176 | // and replacing some common separators with -
177 | filePath = cleanString(filePath, illegalPath)
178 |
179 | // NB this may be of length 0, caller must check
180 | return filePath
181 | }
182 |
183 | // Remove all other unrecognised characters apart from
184 | var illegalName = regexp.MustCompile(`[^[:alnum:]-.]`)
185 |
186 | // Name makes a string safe to use in a file name by first finding the path basename, then replacing non-ascii characters.
187 | func Name(s string) string {
188 | // Start with lowercase string
189 | fileName := s
190 | fileName = baseNameSeparators.ReplaceAllString(fileName, "-")
191 |
192 | fileName = path.Clean(path.Base(fileName))
193 |
194 | // Remove illegal characters for names, replacing some common separators with -
195 | fileName = cleanString(fileName, illegalName)
196 |
197 | // NB this may be of length 0, caller must check
198 | return fileName
199 | }
200 |
201 | // Replace these separators with -
202 | var baseNameSeparators = regexp.MustCompile(`[./]`)
203 |
204 | // BaseName makes a string safe to use in a file name, producing a sanitized basename replacing . or / with -.
205 | // No attempt is made to normalise a path or normalise case.
206 | func BaseName(s string) string {
207 |
208 | // Replace certain joining characters with a dash
209 | baseName := baseNameSeparators.ReplaceAllString(s, "-")
210 |
211 | // Remove illegal characters for names, replacing some common separators with -
212 | baseName = cleanString(baseName, illegalName)
213 |
214 | // NB this may be of length 0, caller must check
215 | return baseName
216 | }
217 |
218 | // A very limited list of transliterations to catch common european names translated to urls.
219 | // This set could be expanded with at least caps and many more characters.
220 | var transliterations = map[rune]string{
221 | 'À': "A",
222 | 'Á': "A",
223 | 'Â': "A",
224 | 'Ã': "A",
225 | 'Ä': "A",
226 | 'Å': "AA",
227 | 'Æ': "AE",
228 | 'Ç': "C",
229 | 'È': "E",
230 | 'É': "E",
231 | 'Ê': "E",
232 | 'Ë': "E",
233 | 'Ì': "I",
234 | 'Í': "I",
235 | 'Î': "I",
236 | 'Ï': "I",
237 | 'Ð': "D",
238 | 'Ł': "L",
239 | 'Ñ': "N",
240 | 'Ò': "O",
241 | 'Ó': "O",
242 | 'Ô': "O",
243 | 'Õ': "O",
244 | 'Ö': "OE",
245 | 'Ø': "OE",
246 | 'Œ': "OE",
247 | 'Ù': "U",
248 | 'Ú': "U",
249 | 'Ü': "UE",
250 | 'Û': "U",
251 | 'Ý': "Y",
252 | 'Þ': "TH",
253 | 'ẞ': "SS",
254 | 'à': "a",
255 | 'á': "a",
256 | 'â': "a",
257 | 'ã': "a",
258 | 'ä': "ae",
259 | 'å': "aa",
260 | 'æ': "ae",
261 | 'ç': "c",
262 | 'è': "e",
263 | 'é': "e",
264 | 'ê': "e",
265 | 'ë': "e",
266 | 'ì': "i",
267 | 'í': "i",
268 | 'î': "i",
269 | 'ï': "i",
270 | 'ð': "d",
271 | 'ł': "l",
272 | 'ñ': "n",
273 | 'ń': "n",
274 | 'ò': "o",
275 | 'ó': "o",
276 | 'ô': "o",
277 | 'õ': "o",
278 | 'ō': "o",
279 | 'ö': "oe",
280 | 'ø': "oe",
281 | 'œ': "oe",
282 | 'ś': "s",
283 | 'ù': "u",
284 | 'ú': "u",
285 | 'û': "u",
286 | 'ū': "u",
287 | 'ü': "ue",
288 | 'ý': "y",
289 | 'ÿ': "y",
290 | 'ż': "z",
291 | 'þ': "th",
292 | 'ß': "ss",
293 | }
294 |
295 | // Accents replaces a set of accented characters with ascii equivalents.
296 | func Accents(s string) string {
297 | // Replace some common accent characters
298 | b := bytes.NewBufferString("")
299 | for _, c := range s {
300 | // Check transliterations first
301 | if val, ok := transliterations[c]; ok {
302 | b.WriteString(val)
303 | } else {
304 | b.WriteRune(c)
305 | }
306 | }
307 | return b.String()
308 | }
309 |
310 | var (
311 | // If the attribute contains data: or javascript: anywhere, ignore it
312 | // we don't allow this in attributes as it is so frequently used for xss
313 | // NB we allow spaces in the value, and lowercase.
314 | illegalAttr = regexp.MustCompile(`(d\s*a\s*t\s*a|j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*)\s*:`)
315 |
316 | // We are far more restrictive with href attributes.
317 | legalHrefAttr = regexp.MustCompile(`\A[/#][^/\\]?|mailto:|http://|https://`)
318 | )
319 |
320 | // cleanAttributes returns an array of attributes after removing malicious ones.
321 | func cleanAttributes(a []parser.Attribute, allowed []string) []parser.Attribute {
322 | if len(a) == 0 {
323 | return a
324 | }
325 |
326 | var cleaned []parser.Attribute
327 | for _, attr := range a {
328 | if includes(allowed, attr.Key) {
329 |
330 | val := strings.ToLower(attr.Val)
331 |
332 | // Check for illegal attribute values
333 | if illegalAttr.FindString(val) != "" {
334 | attr.Val = ""
335 | }
336 |
337 | // Check for legal href values - / mailto:// http:// or https://
338 | if attr.Key == "href" {
339 | if legalHrefAttr.FindString(val) == "" {
340 | attr.Val = ""
341 | }
342 | }
343 |
344 | // If we still have an attribute, append it to the array
345 | if attr.Val != "" {
346 | cleaned = append(cleaned, attr)
347 | }
348 | }
349 | }
350 | return cleaned
351 | }
352 |
353 | // A list of characters we consider separators in normal strings and replace with our canonical separator - rather than removing.
354 | var (
355 | separators = regexp.MustCompile(`[!&_="#|+?:]`)
356 |
357 | dashes = regexp.MustCompile(`[\-]+`)
358 | )
359 |
360 | // cleanString replaces separators with - and removes characters listed in the regexp provided from string.
361 | // Accents, spaces, and all characters not in A-Za-z0-9 are replaced.
362 | func cleanString(s string, r *regexp.Regexp) string {
363 |
364 | // Remove any trailing space to avoid ending on -
365 | s = strings.Trim(s, " ")
366 |
367 | // Flatten accents first so that if we remove non-ascii we still get a legible name
368 | s = Accents(s)
369 |
370 | // Replace certain joining characters with a dash
371 | s = separators.ReplaceAllString(s, "-")
372 |
373 | // Remove all other unrecognised characters - NB we do allow any printable characters
374 | //s = r.ReplaceAllString(s, "")
375 |
376 | // Remove any multiple dashes caused by replacements above
377 | s = dashes.ReplaceAllString(s, "-")
378 |
379 | return s
380 | }
381 |
382 | // includes checks for inclusion of a string in a []string.
383 | func includes(a []string, s string) bool {
384 | for _, as := range a {
385 | if as == s {
386 | return true
387 | }
388 | }
389 | return false
390 | }
391 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "html/template"
6 | "log"
7 | "os"
8 | "path"
9 | "strconv"
10 | "time"
11 |
12 | "github.com/akhilrex/podgrab/controllers"
13 | "github.com/akhilrex/podgrab/db"
14 | "github.com/akhilrex/podgrab/service"
15 | "github.com/gin-contrib/location"
16 | "github.com/gin-gonic/gin"
17 | "github.com/jasonlvhit/gocron"
18 | _ "github.com/joho/godotenv/autoload"
19 | )
20 |
21 | func main() {
22 | var err error
23 | db.DB, err = db.Init()
24 | if err != nil {
25 | fmt.Println("statuse: ", err)
26 | } else {
27 | db.Migrate()
28 | }
29 | r := gin.Default()
30 |
31 | r.Use(setupSettings())
32 | r.Use(gin.Recovery())
33 | r.Use(location.Default())
34 |
35 | funcMap := template.FuncMap{
36 | "intRange": func(start, end int) []int {
37 | n := end - start + 1
38 | result := make([]int, n)
39 | for i := 0; i < n; i++ {
40 | result[i] = start + i
41 | }
42 | return result
43 | },
44 | "removeStartingSlash": func(raw string) string {
45 | fmt.Println(raw)
46 | if string(raw[0]) == "/" {
47 | return raw
48 | }
49 | return "/" + raw
50 | },
51 | "isDateNull": func(raw time.Time) bool {
52 | return raw == (time.Time{})
53 | },
54 | "formatDate": func(raw time.Time) string {
55 | if raw == (time.Time{}) {
56 | return ""
57 | }
58 |
59 | return raw.Format("Jan 2 2006")
60 | },
61 | "naturalDate": func(raw time.Time) string {
62 | return service.NatualTime(time.Now(), raw)
63 | //return raw.Format("Jan 2 2006")
64 | },
65 | "latestEpisodeDate": func(podcastItems []db.PodcastItem) string {
66 | var latest time.Time
67 | for _, item := range podcastItems {
68 | if item.PubDate.After(latest) {
69 | latest = item.PubDate
70 | }
71 | }
72 | return latest.Format("Jan 2 2006")
73 | },
74 | "downloadedEpisodes": func(podcastItems []db.PodcastItem) int {
75 | count := 0
76 | for _, item := range podcastItems {
77 | if item.DownloadStatus == db.Downloaded {
78 | count++
79 | }
80 | }
81 | return count
82 | },
83 | "downloadingEpisodes": func(podcastItems []db.PodcastItem) int {
84 | count := 0
85 | for _, item := range podcastItems {
86 | if item.DownloadStatus == db.NotDownloaded {
87 | count++
88 | }
89 | }
90 | return count
91 | },
92 | "formatFileSize": func(inputSize int64) string {
93 | size := float64(inputSize)
94 | const divisor float64 = 1024
95 | if size < divisor {
96 | return fmt.Sprintf("%.0f bytes", size)
97 | }
98 | size = size / divisor
99 | if size < divisor {
100 | return fmt.Sprintf("%.2f KB", size)
101 | }
102 | size = size / divisor
103 | if size < divisor {
104 | return fmt.Sprintf("%.2f MB", size)
105 | }
106 | size = size / divisor
107 | if size < divisor {
108 | return fmt.Sprintf("%.2f GB", size)
109 | }
110 | size = size / divisor
111 | return fmt.Sprintf("%.2f TB", size)
112 | },
113 | "formatDuration": func(total int) string {
114 | if total <= 0 {
115 | return ""
116 | }
117 | mins := total / 60
118 | secs := total % 60
119 | hrs := 0
120 | if mins >= 60 {
121 | hrs = mins / 60
122 | mins = mins % 60
123 | }
124 | if hrs > 0 {
125 | return fmt.Sprintf("%02d:%02d:%02d", hrs, mins, secs)
126 | }
127 | return fmt.Sprintf("%02d:%02d", mins, secs)
128 | },
129 | }
130 | tmpl := template.Must(template.New("main").Funcs(funcMap).ParseGlob("client/*"))
131 |
132 | //r.LoadHTMLGlob("client/*")
133 | r.SetHTMLTemplate(tmpl)
134 |
135 | pass := os.Getenv("PASSWORD")
136 | var router *gin.RouterGroup
137 | if pass != "" {
138 | router = r.Group("/", gin.BasicAuth(gin.Accounts{
139 | "podgrab": pass,
140 | }))
141 | } else {
142 | router = &r.RouterGroup
143 | }
144 |
145 | dataPath := os.Getenv("DATA")
146 | backupPath := path.Join(os.Getenv("CONFIG"), "backups")
147 |
148 | router.Static("/webassets", "./webassets")
149 | router.Static("/assets", dataPath)
150 | router.Static(backupPath, backupPath)
151 | router.POST("/podcasts", controllers.AddPodcast)
152 | router.GET("/podcasts", controllers.GetAllPodcasts)
153 | router.GET("/podcasts/:id", controllers.GetPodcastById)
154 | router.GET("/podcasts/:id/image", controllers.GetPodcastImageById)
155 | router.DELETE("/podcasts/:id", controllers.DeletePodcastById)
156 | router.GET("/podcasts/:id/items", controllers.GetPodcastItemsByPodcastId)
157 | router.GET("/podcasts/:id/download", controllers.DownloadAllEpisodesByPodcastId)
158 | router.DELETE("/podcasts/:id/items", controllers.DeletePodcastEpisodesById)
159 | router.DELETE("/podcasts/:id/podcast", controllers.DeleteOnlyPodcastById)
160 | router.GET("/podcasts/:id/pause", controllers.PausePodcastById)
161 | router.GET("/podcasts/:id/unpause", controllers.UnpausePodcastById)
162 | router.GET("/podcasts/:id/rss", controllers.GetRssForPodcastById)
163 |
164 | router.GET("/podcastitems", controllers.GetAllPodcastItems)
165 | router.GET("/podcastitems/:id", controllers.GetPodcastItemById)
166 | router.GET("/podcastitems/:id/image", controllers.GetPodcastItemImageById)
167 | router.GET("/podcastitems/:id/file", controllers.GetPodcastItemFileById)
168 | router.GET("/podcastitems/:id/markUnplayed", controllers.MarkPodcastItemAsUnplayed)
169 | router.GET("/podcastitems/:id/markPlayed", controllers.MarkPodcastItemAsPlayed)
170 | router.GET("/podcastitems/:id/bookmark", controllers.BookmarkPodcastItem)
171 | router.GET("/podcastitems/:id/unbookmark", controllers.UnbookmarkPodcastItem)
172 | router.PATCH("/podcastitems/:id", controllers.PatchPodcastItemById)
173 | router.GET("/podcastitems/:id/download", controllers.DownloadPodcastItem)
174 | router.GET("/podcastitems/:id/delete", controllers.DeletePodcastItem)
175 |
176 | router.GET("/tags", controllers.GetAllTags)
177 | router.GET("/tags/:id", controllers.GetTagById)
178 | router.GET("/tags/:id/rss", controllers.GetRssForTagById)
179 | router.DELETE("/tags/:id", controllers.DeleteTagById)
180 | router.POST("/tags", controllers.AddTag)
181 | router.POST("/podcasts/:id/tags/:tagId", controllers.AddTagToPodcast)
182 | router.DELETE("/podcasts/:id/tags/:tagId", controllers.RemoveTagFromPodcast)
183 |
184 | router.GET("/add", controllers.AddPage)
185 | router.GET("/search", controllers.Search)
186 | router.GET("/", controllers.HomePage)
187 | router.GET("/podcasts/:id/view", controllers.PodcastPage)
188 | router.GET("/episodes", controllers.AllEpisodesPage)
189 | router.GET("/allTags", controllers.AllTagsPage)
190 | router.GET("/settings", controllers.SettingsPage)
191 | router.POST("/settings", controllers.UpdateSetting)
192 | router.GET("/backups", controllers.BackupsPage)
193 | router.POST("/opml", controllers.UploadOpml)
194 | router.GET("/opml", controllers.GetOmpl)
195 | router.GET("/player", controllers.PlayerPage)
196 | router.GET("/rss", controllers.GetRss)
197 |
198 | r.GET("/ws", func(c *gin.Context) {
199 | controllers.Wshandler(c.Writer, c.Request)
200 | })
201 | go controllers.HandleWebsocketMessages()
202 |
203 | go assetEnv()
204 | go intiCron()
205 |
206 | r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
207 |
208 | }
209 | func setupSettings() gin.HandlerFunc {
210 | return func(c *gin.Context) {
211 |
212 | setting := db.GetOrCreateSetting()
213 | c.Set("setting", setting)
214 | c.Writer.Header().Set("X-Clacks-Overhead", "GNU Terry Pratchett")
215 |
216 | c.Next()
217 | }
218 | }
219 |
220 | func intiCron() {
221 | checkFrequency, err := strconv.Atoi(os.Getenv("CHECK_FREQUENCY"))
222 | if err != nil {
223 | checkFrequency = 30
224 | log.Print(err)
225 | }
226 | service.UnlockMissedJobs()
227 | //gocron.Every(uint64(checkFrequency)).Minutes().Do(service.DownloadMissingEpisodes)
228 | gocron.Every(uint64(checkFrequency)).Minutes().Do(service.RefreshEpisodes)
229 | gocron.Every(uint64(checkFrequency)).Minutes().Do(service.CheckMissingFiles)
230 | gocron.Every(uint64(checkFrequency) * 2).Minutes().Do(service.UnlockMissedJobs)
231 | gocron.Every(uint64(checkFrequency) * 3).Minutes().Do(service.UpdateAllFileSizes)
232 | gocron.Every(uint64(checkFrequency)).Minutes().Do(service.DownloadMissingImages)
233 | gocron.Every(2).Days().Do(service.CreateBackup)
234 | <-gocron.Start()
235 | }
236 |
237 | func assetEnv() {
238 | log.Println("Config Dir: ", os.Getenv("CONFIG"))
239 | log.Println("Assets Dir: ", os.Getenv("DATA"))
240 | log.Println("Check Frequency (mins): ", os.Getenv("CHECK_FREQUENCY"))
241 | }
242 |
--------------------------------------------------------------------------------
/model/errors.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "fmt"
4 |
5 | type PodcastAlreadyExistsError struct {
6 | Url string
7 | }
8 |
9 | func (e *PodcastAlreadyExistsError) Error() string {
10 | return fmt.Sprintf("Podcast with this url already exists")
11 | }
12 |
13 | type TagAlreadyExistsError struct {
14 | Label string
15 | }
16 |
17 | func (e *TagAlreadyExistsError) Error() string {
18 | return fmt.Sprintf("Tag with this label already exists : " + e.Label)
19 | }
20 |
--------------------------------------------------------------------------------
/model/gpodderModels.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type GPodcast struct {
4 | URL string `json:"url"`
5 | Title string `json:"title"`
6 | Author string `json:"author"`
7 | Description string `json:"description"`
8 | Subscribers int `json:"subscribers"`
9 | SubscribersLastWeek int `json:"subscribers_last_week"`
10 | LogoURL string `json:"logo_url"`
11 | ScaledLogoURL string `json:"scaled_logo_url"`
12 | Website string `json:"website"`
13 | MygpoLink string `json:"mygpo_link"`
14 | AlreadySaved bool `json:"already_saved"`
15 | }
16 |
17 | type GPodcastTag struct {
18 | Tag string `json:"tag"`
19 | Title string `json:"title"`
20 | Usage int `json:"usage"`
21 | }
22 |
--------------------------------------------------------------------------------
/model/itunesModel.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "time"
4 |
5 | type ItunesResponse struct {
6 | ResultCount int `json:"resultCount"`
7 | Results []ItunesSingleResult `json:"results"`
8 | }
9 |
10 | type ItunesSingleResult struct {
11 | WrapperType string `json:"wrapperType"`
12 | Kind string `json:"kind"`
13 | CollectionID int `json:"collectionId"`
14 | TrackID int `json:"trackId"`
15 | ArtistName string `json:"artistName"`
16 | CollectionName string `json:"collectionName"`
17 | TrackName string `json:"trackName"`
18 | CollectionCensoredName string `json:"collectionCensoredName"`
19 | TrackCensoredName string `json:"trackCensoredName"`
20 | CollectionViewURL string `json:"collectionViewUrl"`
21 | FeedURL string `json:"feedUrl"`
22 | TrackViewURL string `json:"trackViewUrl"`
23 | ArtworkURL30 string `json:"artworkUrl30"`
24 | ArtworkURL60 string `json:"artworkUrl60"`
25 | ArtworkURL100 string `json:"artworkUrl100"`
26 | CollectionPrice float64 `json:"collectionPrice"`
27 | TrackPrice float64 `json:"trackPrice"`
28 | TrackRentalPrice int `json:"trackRentalPrice"`
29 | CollectionHdPrice int `json:"collectionHdPrice"`
30 | TrackHdPrice int `json:"trackHdPrice"`
31 | TrackHdRentalPrice int `json:"trackHdRentalPrice"`
32 | ReleaseDate time.Time `json:"releaseDate"`
33 | CollectionExplicitness string `json:"collectionExplicitness"`
34 | TrackExplicitness string `json:"trackExplicitness"`
35 | TrackCount int `json:"trackCount"`
36 | Country string `json:"country"`
37 | Currency string `json:"currency"`
38 | PrimaryGenreName string `json:"primaryGenreName"`
39 | ContentAdvisoryRating string `json:"contentAdvisoryRating,omitempty"`
40 | ArtworkURL600 string `json:"artworkUrl600"`
41 | GenreIds []string `json:"genreIds"`
42 | Genres []string `json:"genres"`
43 | ArtistID int `json:"artistId,omitempty"`
44 | ArtistViewURL string `json:"artistViewUrl,omitempty"`
45 | }
46 |
--------------------------------------------------------------------------------
/model/opmlModels.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "encoding/xml"
5 | "time"
6 | )
7 |
8 | type OpmlModel struct {
9 | XMLName xml.Name `xml:"opml"`
10 | Text string `xml:",chardata"`
11 | Version string `xml:"version,attr"`
12 | Head OpmlHead `xml:"head"`
13 | Body OpmlBody `xml:"body"`
14 | }
15 | type OpmlExportModel struct {
16 | XMLName xml.Name `xml:"opml"`
17 | Text string `xml:",chardata"`
18 | Version string `xml:"version,attr"`
19 | Head OpmlExportHead `xml:"head"`
20 | Body OpmlBody `xml:"body"`
21 | }
22 |
23 | type OpmlHead struct {
24 | Text string `xml:",chardata"`
25 | Title string `xml:"title"`
26 | //DateCreated time.Time `xml:"dateCreated"`
27 | }
28 | type OpmlExportHead struct {
29 | Text string `xml:",chardata"`
30 | Title string `xml:"title"`
31 | DateCreated time.Time `xml:"dateCreated"`
32 | }
33 |
34 | type OpmlBody struct {
35 | Text string `xml:",chardata"`
36 | Outline []OpmlOutline `xml:"outline"`
37 | }
38 |
39 | type OpmlOutline struct {
40 | Title string `xml:"title,attr"`
41 | XmlUrl string `xml:"xmlUrl,attr"`
42 | Text string `xml:",chardata"`
43 | AttrText string `xml:"text,attr"`
44 | Type string `xml:"type,attr"`
45 | Outline []OpmlOutline `xml:"outline"`
46 | }
47 |
--------------------------------------------------------------------------------
/model/podcastModels.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "encoding/xml"
4 |
5 | //PodcastData is
6 | type PodcastData struct {
7 | XMLName xml.Name `xml:"rss"`
8 | Text string `xml:",chardata"`
9 | Itunes string `xml:"itunes,attr"`
10 | Atom string `xml:"atom,attr"`
11 | Media string `xml:"media,attr"`
12 | Psc string `xml:"psc,attr"`
13 | Omny string `xml:"omny,attr"`
14 | Content string `xml:"content,attr"`
15 | Googleplay string `xml:"googleplay,attr"`
16 | Acast string `xml:"acast,attr"`
17 | Version string `xml:"version,attr"`
18 | Channel struct {
19 | Text string `xml:",chardata"`
20 | Language string `xml:"language"`
21 | Link []struct {
22 | Text string `xml:",chardata"`
23 | Rel string `xml:"rel,attr"`
24 | Type string `xml:"type,attr"`
25 | Href string `xml:"href,attr"`
26 | } `xml:"link"`
27 | Title string `xml:"title"`
28 | Description string `xml:"description"`
29 | Type string `xml:"type"`
30 | Summary string `xml:"summary"`
31 | Owner struct {
32 | Text string `xml:",chardata"`
33 | Name string `xml:"name"`
34 | Email string `xml:"email"`
35 | } `xml:"owner"`
36 | Author string `xml:"author"`
37 | Copyright string `xml:"copyright"`
38 | Explicit string `xml:"explicit"`
39 | Category struct {
40 | Text string `xml:",chardata"`
41 | AttrText string `xml:"text,attr"`
42 | Category struct {
43 | Text string `xml:",chardata"`
44 | AttrText string `xml:"text,attr"`
45 | } `xml:"category"`
46 | } `xml:"category"`
47 | Image struct {
48 | Text string `xml:",chardata"`
49 | Href string `xml:"href,attr"`
50 | URL string `xml:"url"`
51 | Title string `xml:"title"`
52 | Link string `xml:"link"`
53 | } `xml:"image"`
54 | Item []struct {
55 | Text string `xml:",chardata"`
56 | Title string `xml:"title"`
57 | Description string `xml:"description"`
58 | Encoded string `xml:"encoded"`
59 | Summary string `xml:"summary"`
60 | EpisodeType string `xml:"episodeType"`
61 | Author string `xml:"author"`
62 | Image struct {
63 | Text string `xml:",chardata"`
64 | Href string `xml:"href,attr"`
65 | } `xml:"image"`
66 | Content []struct {
67 | Text string `xml:",chardata"`
68 | URL string `xml:"url,attr"`
69 | Type string `xml:"type,attr"`
70 | Player struct {
71 | Text string `xml:",chardata"`
72 | URL string `xml:"url,attr"`
73 | } `xml:"player"`
74 | } `xml:"content"`
75 | Guid struct {
76 | Text string `xml:",chardata"`
77 | IsPermaLink string `xml:"isPermaLink,attr"`
78 | } `xml:"guid"`
79 | ClipId string `xml:"clipId"`
80 | PubDate string `xml:"pubDate"`
81 | Duration string `xml:"duration"`
82 | Enclosure struct {
83 | Text string `xml:",chardata"`
84 | URL string `xml:"url,attr"`
85 | Length string `xml:"length,attr"`
86 | Type string `xml:"type,attr"`
87 | } `xml:"enclosure"`
88 | Link string `xml:"link"`
89 | StitcherId string `xml:"stitcherId"`
90 | Episode string `xml:"episode"`
91 | } `xml:"item"`
92 | } `xml:"channel"`
93 | }
94 |
95 | type CommonSearchResultModel struct {
96 | URL string `json:"url"`
97 | Title string `json:"title"`
98 | Image string `json:"image"`
99 | AlreadySaved bool `json:"already_saved"`
100 | Description string `json:"description"`
101 | Categories []string `json:"categories"`
102 | }
103 |
--------------------------------------------------------------------------------
/model/queryModels.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "math"
4 |
5 | type Pagination struct {
6 | Page int `uri:"page" query:"page" json:"page" form:"page" default:1`
7 | Count int `uri:"count" query:"count" json:"count" form:"count" default:20`
8 | NextPage int `uri:"nextPage" query:"nextPage" json:"nextPage" form:"nextPage"`
9 | PreviousPage int `uri:"previousPage" query:"previousPage" json:"previousPage" form:"previousPage"`
10 | TotalCount int `uri:"totalCount" query:"totalCount" json:"totalCount" form:"totalCount"`
11 | TotalPages int `uri:"totalPages" query:"totalPages" json:"totalPages" form:"totalPages"`
12 | }
13 |
14 | type EpisodeSort string
15 |
16 | const (
17 | RELEASE_ASC EpisodeSort = "release_asc"
18 | RELEASE_DESC EpisodeSort = "release_desc"
19 | DURATION_ASC EpisodeSort = "duration_asc"
20 | DURATION_DESC EpisodeSort = "duration_desc"
21 | )
22 |
23 | type EpisodesFilter struct {
24 | Pagination
25 | IsDownloaded *string `uri:"isDownloaded" query:"isDownloaded" json:"isDownloaded" form:"isDownloaded"`
26 | IsPlayed *string `uri:"isPlayed" query:"isPlayed" json:"isPlayed" form:"isPlayed"`
27 | Sorting EpisodeSort `uri:"sorting" query:"sorting" json:"sorting" form:"sorting"`
28 | Q string `uri:"q" query:"q" json:"q" form:"q"`
29 | TagIds []string `uri:"tagIds" query:"tagIds[]" json:"tagIds" form:"tagIds[]"`
30 | PodcastIds []string `uri:"podcastIds" query:"podcastIds[]" json:"podcastIds" form:"podcastIds[]"`
31 | }
32 |
33 | func (filter *EpisodesFilter) VerifyPaginationValues() {
34 | if filter.Count == 0 {
35 | filter.Count = 20
36 | }
37 | if filter.Page == 0 {
38 | filter.Page = 1
39 | }
40 | if filter.Sorting == "" {
41 | filter.Sorting = RELEASE_DESC
42 | }
43 | }
44 |
45 | func (filter *EpisodesFilter) SetCounts(totalCount int64) {
46 | totalPages := int(math.Ceil(float64(totalCount) / float64(filter.Count)))
47 | nextPage, previousPage := 0, 0
48 | if filter.Page < totalPages {
49 | nextPage = filter.Page + 1
50 | }
51 | if filter.Page > 1 {
52 | previousPage = filter.Page - 1
53 | }
54 | filter.NextPage = nextPage
55 | filter.PreviousPage = previousPage
56 | filter.TotalCount = int(totalCount)
57 | filter.TotalPages = totalPages
58 | }
59 |
--------------------------------------------------------------------------------
/model/rssModels.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "encoding/xml"
4 |
5 | //PodcastData is
6 | type RssPodcastData struct {
7 | XMLName xml.Name `xml:"rss"`
8 | Text string `xml:",chardata"`
9 | Itunes string `xml:"itunes,attr"`
10 | Atom string `xml:"atom,attr"`
11 | Media string `xml:"media,attr"`
12 | Psc string `xml:"psc,attr"`
13 | Omny string `xml:"omny,attr"`
14 | Content string `xml:"content,attr"`
15 | Googleplay string `xml:"googleplay,attr"`
16 | Acast string `xml:"acast,attr"`
17 | Version string `xml:"version,attr"`
18 | Channel RssChannel `xml:"channel"`
19 | }
20 | type RssChannel struct {
21 | Text string `xml:",chardata"`
22 | Language string `xml:"language"`
23 | Link string `xml:"link"`
24 | Title string `xml:"title"`
25 | Description string `xml:"description"`
26 | Type string `xml:"type"`
27 | Summary string `xml:"summary"`
28 | Image RssItemImage `xml:"image"`
29 | Item []RssItem `xml:"item"`
30 | Author string `xml:"author"`
31 | }
32 | type RssItem struct {
33 | Text string `xml:",chardata"`
34 | Title string `xml:"title"`
35 | Description string `xml:"description"`
36 | Encoded string `xml:"encoded"`
37 | Summary string `xml:"summary"`
38 | EpisodeType string `xml:"episodeType"`
39 | Author string `xml:"author"`
40 | Image RssItemImage `xml:"image"`
41 | Guid RssItemGuid `xml:"guid"`
42 | ClipId string `xml:"clipId"`
43 | PubDate string `xml:"pubDate"`
44 | Duration string `xml:"duration"`
45 | Enclosure RssItemEnclosure `xml:"enclosure"`
46 | Link string `xml:"link"`
47 | Episode string `xml:"episode"`
48 | }
49 |
50 | type RssItemEnclosure struct {
51 | Text string `xml:",chardata"`
52 | URL string `xml:"url,attr"`
53 | Length string `xml:"length,attr"`
54 | Type string `xml:"type,attr"`
55 | }
56 | type RssItemImage struct {
57 | Text string `xml:",chardata"`
58 | Href string `xml:"href,attr"`
59 | URL string `xml:"url"`
60 | }
61 |
62 | type RssItemGuid struct {
63 | Text string `xml:",chardata"`
64 | IsPermaLink string `xml:"isPermaLink,attr"`
65 | }
66 |
--------------------------------------------------------------------------------
/service/fileService.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "archive/tar"
5 | "compress/gzip"
6 | "encoding/xml"
7 | "errors"
8 | "fmt"
9 | "io"
10 | "io/ioutil"
11 | "net/http"
12 | "net/url"
13 | "os"
14 | "path"
15 | "path/filepath"
16 | "sort"
17 | "strconv"
18 | "time"
19 |
20 | "github.com/akhilrex/podgrab/db"
21 | "github.com/akhilrex/podgrab/internal/sanitize"
22 | stringy "github.com/gobeam/stringy"
23 | )
24 |
25 | func Download(link string, episodeTitle string, podcastName string, prefix string) (string, error) {
26 | if link == "" {
27 | return "", errors.New("Download path empty")
28 | }
29 | client := httpClient()
30 |
31 | req, err := getRequest(link)
32 | if err != nil {
33 | Logger.Errorw("Error creating request: "+link, err)
34 | }
35 |
36 | resp, err := client.Do(req)
37 | if err != nil {
38 | Logger.Errorw("Error getting response: "+link, err)
39 | return "", err
40 | }
41 |
42 | fileName := getFileName(link, episodeTitle, ".mp3")
43 | if prefix != "" {
44 | fileName = fmt.Sprintf("%s-%s", prefix, fileName)
45 | }
46 | folder := createDataFolderIfNotExists(podcastName)
47 | finalPath := path.Join(folder, fileName)
48 |
49 | if _, err := os.Stat(finalPath); !os.IsNotExist(err) {
50 | changeOwnership(finalPath)
51 | return finalPath, nil
52 | }
53 |
54 | file, err := os.Create(finalPath)
55 | if err != nil {
56 | Logger.Errorw("Error creating file"+link, err)
57 | return "", err
58 | }
59 | defer resp.Body.Close()
60 | _, erra := io.Copy(file, resp.Body)
61 | //fmt.Println(size)
62 | defer file.Close()
63 | if erra != nil {
64 | Logger.Errorw("Error saving file"+link, err)
65 | return "", erra
66 | }
67 | changeOwnership(finalPath)
68 | return finalPath, nil
69 |
70 | }
71 |
72 | func GetPodcastLocalImagePath(link string, podcastName string) string {
73 | fileName := getFileName(link, "folder", ".jpg")
74 | folder := createDataFolderIfNotExists(podcastName)
75 |
76 | finalPath := path.Join(folder, fileName)
77 | return finalPath
78 | }
79 |
80 | func CreateNfoFile(podcast *db.Podcast) error {
81 | fileName := "album.nfo"
82 | folder := createDataFolderIfNotExists(podcast.Title)
83 |
84 | finalPath := path.Join(folder, fileName)
85 |
86 | type NFO struct {
87 | XMLName xml.Name `xml:"album"`
88 | Title string `xml:"title"`
89 | Type string `xml:"type"`
90 | Thumb string `xml:"thumb"`
91 | }
92 |
93 | toSave := NFO{
94 | Title: podcast.Title,
95 | Type: "Broadcast",
96 | Thumb: podcast.Image,
97 | }
98 | out, err := xml.MarshalIndent(toSave, " ", " ")
99 | if err != nil {
100 | return err
101 | }
102 | toPersist := xml.Header + string(out)
103 | return ioutil.WriteFile(finalPath, []byte(toPersist), 0644)
104 | }
105 |
106 | func DownloadPodcastCoverImage(link string, podcastName string) (string, error) {
107 | if link == "" {
108 | return "", errors.New("Download path empty")
109 | }
110 | client := httpClient()
111 | req, err := getRequest(link)
112 | if err != nil {
113 | Logger.Errorw("Error creating request: "+link, err)
114 | return "", err
115 | }
116 |
117 | resp, err := client.Do(req)
118 | if err != nil {
119 | Logger.Errorw("Error getting response: "+link, err)
120 | return "", err
121 | }
122 |
123 | fileName := getFileName(link, "folder", ".jpg")
124 | folder := createDataFolderIfNotExists(podcastName)
125 |
126 | finalPath := path.Join(folder, fileName)
127 | if _, err := os.Stat(finalPath); !os.IsNotExist(err) {
128 | changeOwnership(finalPath)
129 | return finalPath, nil
130 | }
131 |
132 | file, err := os.Create(finalPath)
133 | if err != nil {
134 | Logger.Errorw("Error creating file"+link, err)
135 | return "", err
136 | }
137 | defer resp.Body.Close()
138 | _, erra := io.Copy(file, resp.Body)
139 | //fmt.Println(size)
140 | defer file.Close()
141 | if erra != nil {
142 | Logger.Errorw("Error saving file"+link, err)
143 | return "", erra
144 | }
145 | changeOwnership(finalPath)
146 | return finalPath, nil
147 | }
148 |
149 | func DownloadImage(link string, episodeId string, podcastName string) (string, error) {
150 | if link == "" {
151 | return "", errors.New("Download path empty")
152 | }
153 | client := httpClient()
154 | req, err := getRequest(link)
155 | if err != nil {
156 | Logger.Errorw("Error creating request: "+link, err)
157 | return "", err
158 | }
159 |
160 | resp, err := client.Do(req)
161 | if err != nil {
162 | Logger.Errorw("Error getting response: "+link, err)
163 | return "", err
164 | }
165 |
166 | fileName := getFileName(link, episodeId, ".jpg")
167 | folder := createDataFolderIfNotExists(podcastName)
168 | imageFolder := createFolder("images", folder)
169 | finalPath := path.Join(imageFolder, fileName)
170 |
171 | if _, err := os.Stat(finalPath); !os.IsNotExist(err) {
172 | changeOwnership(finalPath)
173 | return finalPath, nil
174 | }
175 |
176 | file, err := os.Create(finalPath)
177 | if err != nil {
178 | Logger.Errorw("Error creating file"+link, err)
179 | return "", err
180 | }
181 | defer resp.Body.Close()
182 | _, erra := io.Copy(file, resp.Body)
183 | //fmt.Println(size)
184 | defer file.Close()
185 | if erra != nil {
186 | Logger.Errorw("Error saving file"+link, err)
187 | return "", erra
188 | }
189 | changeOwnership(finalPath)
190 | return finalPath, nil
191 |
192 | }
193 | func changeOwnership(path string) {
194 | uid, err1 := strconv.Atoi(os.Getenv("PUID"))
195 | gid, err2 := strconv.Atoi(os.Getenv("PGID"))
196 | fmt.Println(path)
197 | if err1 == nil && err2 == nil {
198 | fmt.Println(path + " : Attempting change")
199 | os.Chown(path, uid, gid)
200 | }
201 |
202 | }
203 | func DeleteFile(filePath string) error {
204 | if _, err := os.Stat(filePath); os.IsNotExist(err) {
205 | return err
206 | }
207 | if err := os.Remove(filePath); err != nil {
208 | return err
209 | }
210 | return nil
211 | }
212 | func FileExists(filePath string) bool {
213 | _, err := os.Stat(filePath)
214 | return err == nil
215 |
216 | }
217 |
218 | func GetAllBackupFiles() ([]string, error) {
219 | var files []string
220 | folder := createConfigFolderIfNotExists("backups")
221 | err := filepath.Walk(folder, func(path string, info os.FileInfo, err error) error {
222 | if !info.IsDir() {
223 | files = append(files, path)
224 | }
225 | return nil
226 | })
227 | sort.Sort(sort.Reverse(sort.StringSlice(files)))
228 | return files, err
229 | }
230 |
231 | func GetFileSize(path string) (int64, error) {
232 | info, err := os.Stat(path)
233 | if err != nil {
234 | return 0, err
235 | }
236 | return info.Size(), nil
237 | }
238 |
239 | func deleteOldBackup() {
240 | files, err := GetAllBackupFiles()
241 | if err != nil {
242 | return
243 | }
244 | if len(files) <= 5 {
245 | return
246 | }
247 |
248 | toDelete := files[5:]
249 | for _, file := range toDelete {
250 | fmt.Println(file)
251 | DeleteFile(file)
252 | }
253 | }
254 |
255 | func GetFileSizeFromUrl(url string) (int64, error) {
256 | resp, err := http.Head(url)
257 | if err != nil {
258 | return 0, err
259 | }
260 |
261 | // Is our request ok?
262 |
263 | if resp.StatusCode != http.StatusOK {
264 | return 0, fmt.Errorf("Did not receive 200")
265 | }
266 |
267 | size, err := strconv.Atoi(resp.Header.Get("Content-Length"))
268 | if err != nil {
269 | return 0, err
270 | }
271 |
272 | return int64(size), nil
273 | }
274 |
275 | func CreateBackup() (string, error) {
276 |
277 | backupFileName := "podgrab_backup_" + time.Now().Format("2006.01.02_150405") + ".tar.gz"
278 | folder := createConfigFolderIfNotExists("backups")
279 | configPath := os.Getenv("CONFIG")
280 | tarballFilePath := path.Join(folder, backupFileName)
281 | file, err := os.Create(tarballFilePath)
282 | if err != nil {
283 | return "", errors.New(fmt.Sprintf("Could not create tarball file '%s', got error '%s'", tarballFilePath, err.Error()))
284 | }
285 | defer file.Close()
286 |
287 | dbPath := path.Join(configPath, "podgrab.db")
288 | _, err = os.Stat(dbPath)
289 | if err != nil {
290 | return "", errors.New(fmt.Sprintf("Could not find db file '%s', got error '%s'", dbPath, err.Error()))
291 | }
292 | gzipWriter := gzip.NewWriter(file)
293 | defer gzipWriter.Close()
294 |
295 | tarWriter := tar.NewWriter(gzipWriter)
296 | defer tarWriter.Close()
297 |
298 | err = addFileToTarWriter(dbPath, tarWriter)
299 | if err == nil {
300 | deleteOldBackup()
301 | }
302 | return backupFileName, err
303 | }
304 |
305 | func addFileToTarWriter(filePath string, tarWriter *tar.Writer) error {
306 | file, err := os.Open(filePath)
307 | if err != nil {
308 | return errors.New(fmt.Sprintf("Could not open file '%s', got error '%s'", filePath, err.Error()))
309 | }
310 | defer file.Close()
311 |
312 | stat, err := file.Stat()
313 | if err != nil {
314 | return errors.New(fmt.Sprintf("Could not get stat for file '%s', got error '%s'", filePath, err.Error()))
315 | }
316 |
317 | header := &tar.Header{
318 | Name: filePath,
319 | Size: stat.Size(),
320 | Mode: int64(stat.Mode()),
321 | ModTime: stat.ModTime(),
322 | }
323 |
324 | err = tarWriter.WriteHeader(header)
325 | if err != nil {
326 | return errors.New(fmt.Sprintf("Could not write header for file '%s', got error '%s'", filePath, err.Error()))
327 | }
328 |
329 | _, err = io.Copy(tarWriter, file)
330 | if err != nil {
331 | return errors.New(fmt.Sprintf("Could not copy the file '%s' data to the tarball, got error '%s'", filePath, err.Error()))
332 | }
333 |
334 | return nil
335 | }
336 | func httpClient() *http.Client {
337 | client := http.Client{
338 | CheckRedirect: func(r *http.Request, via []*http.Request) error {
339 | // r.URL.Opaque = r.URL.Path
340 | return nil
341 | },
342 | }
343 |
344 | return &client
345 | }
346 |
347 | func getRequest(url string) (*http.Request, error) {
348 | req, err := http.NewRequest("GET", url, nil)
349 | if err != nil {
350 | return nil, err
351 | }
352 |
353 | setting := db.GetOrCreateSetting()
354 | if len(setting.UserAgent) > 0 {
355 | req.Header.Add("User-Agent", setting.UserAgent)
356 | }
357 |
358 | return req, nil
359 | }
360 |
361 | func createFolder(folder string, parent string) string {
362 | folder = cleanFileName(folder)
363 | //str := stringy.New(folder)
364 | folderPath := path.Join(parent, folder)
365 | if _, err := os.Stat(folderPath); os.IsNotExist(err) {
366 | os.MkdirAll(folderPath, 0777)
367 | changeOwnership(folderPath)
368 | }
369 | return folderPath
370 | }
371 |
372 | func createDataFolderIfNotExists(folder string) string {
373 | dataPath := os.Getenv("DATA")
374 | return createFolder(folder, dataPath)
375 | }
376 | func createConfigFolderIfNotExists(folder string) string {
377 | dataPath := os.Getenv("CONFIG")
378 | return createFolder(folder, dataPath)
379 | }
380 |
381 | func deletePodcastFolder(folder string) error {
382 | return os.RemoveAll(createDataFolderIfNotExists(folder))
383 | }
384 |
385 | func getFileName(link string, title string, defaultExtension string) string {
386 | fileUrl, err := url.Parse(link)
387 | checkError(err)
388 |
389 | parsed := fileUrl.Path
390 | ext := filepath.Ext(parsed)
391 |
392 | if len(ext) == 0 {
393 | ext = defaultExtension
394 | }
395 | //str := stringy.New(title)
396 | str := stringy.New(cleanFileName(title))
397 | return str.KebabCase().Get() + ext
398 |
399 | }
400 |
401 | func cleanFileName(original string) string {
402 | return sanitize.Name(original)
403 | }
404 |
405 | func checkError(err error) {
406 | if err != nil {
407 | panic(err)
408 | }
409 | }
410 |
--------------------------------------------------------------------------------
/service/gpodderService.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/url"
7 |
8 | "github.com/akhilrex/podgrab/model"
9 | )
10 |
11 | // type GoodReadsService struct {
12 | // }
13 |
14 | const BASE = "https://gpodder.net"
15 |
16 | func Query(q string) []*model.CommonSearchResultModel {
17 | url := fmt.Sprintf("%s/search.json?q=%s", BASE, url.QueryEscape(q))
18 |
19 | body, _ := makeQuery(url)
20 | var response []model.GPodcast
21 | json.Unmarshal(body, &response)
22 |
23 | var toReturn []*model.CommonSearchResultModel
24 |
25 | for _, obj := range response {
26 | toReturn = append(toReturn, GetSearchFromGpodder(obj))
27 | }
28 |
29 | return toReturn
30 | }
31 | func ByTag(tag string, count int) []model.GPodcast {
32 | url := fmt.Sprintf("%s/api/2/tag/%s/%d.json", BASE, url.QueryEscape(tag), count)
33 |
34 | body, _ := makeQuery(url)
35 | var response []model.GPodcast
36 | json.Unmarshal(body, &response)
37 | return response
38 | }
39 | func Top(count int) []model.GPodcast {
40 | url := fmt.Sprintf("%s/toplist/%d.json", BASE, count)
41 |
42 | body, _ := makeQuery(url)
43 | var response []model.GPodcast
44 | json.Unmarshal(body, &response)
45 | return response
46 | }
47 | func Tags(count int) []model.GPodcastTag {
48 | url := fmt.Sprintf("%s/api/2/tags/%d.json", BASE, count)
49 |
50 | body, _ := makeQuery(url)
51 | var response []model.GPodcastTag
52 | json.Unmarshal(body, &response)
53 | return response
54 | }
55 |
--------------------------------------------------------------------------------
/service/itunesService.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "log"
7 | "net/url"
8 |
9 | "github.com/TheHippo/podcastindex"
10 | "github.com/akhilrex/podgrab/model"
11 | )
12 |
13 | type SearchService interface {
14 | Query(q string) []*model.CommonSearchResultModel
15 | }
16 |
17 | type ItunesService struct {
18 | }
19 |
20 | const ITUNES_BASE = "https://itunes.apple.com"
21 |
22 | func (service ItunesService) Query(q string) []*model.CommonSearchResultModel {
23 | url := fmt.Sprintf("%s/search?term=%s&entity=podcast", ITUNES_BASE, url.QueryEscape(q))
24 |
25 | body, _ := makeQuery(url)
26 | var response model.ItunesResponse
27 | json.Unmarshal(body, &response)
28 |
29 | var toReturn []*model.CommonSearchResultModel
30 |
31 | for _, obj := range response.Results {
32 | toReturn = append(toReturn, GetSearchFromItunes(obj))
33 | }
34 |
35 | return toReturn
36 | }
37 |
38 | type PodcastIndexService struct {
39 | }
40 |
41 | const (
42 | PODCASTINDEX_KEY = "LNGTNUAFVL9W2AQKVZ49"
43 | PODCASTINDEX_SECRET = "H8tq^CZWYmAywbnngTwB$rwQHwMSR8#fJb#Bhgb3"
44 | )
45 |
46 | func (service PodcastIndexService) Query(q string) []*model.CommonSearchResultModel {
47 |
48 | c := podcastindex.NewClient(PODCASTINDEX_KEY, PODCASTINDEX_SECRET)
49 | var toReturn []*model.CommonSearchResultModel
50 | podcasts, err := c.Search(q)
51 | if err != nil {
52 | log.Fatal(err.Error())
53 | return toReturn
54 | }
55 |
56 | for _, obj := range podcasts {
57 | toReturn = append(toReturn, GetSearchFromPodcastIndex(obj))
58 | }
59 |
60 | return toReturn
61 | }
62 |
--------------------------------------------------------------------------------
/service/naturaltime.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "fmt"
5 | "math"
6 | "time"
7 | )
8 |
9 | func NatualTime(base, value time.Time) string {
10 | if value.Before(base) {
11 | return pastNaturalTime(base, value)
12 | } else {
13 | return futureNaturalTime(base, value)
14 | }
15 | }
16 |
17 | func futureNaturalTime(base, value time.Time) string {
18 | dur := value.Sub(base)
19 | if dur.Seconds() <= 60 {
20 | return "in a few seconds"
21 | }
22 | if dur.Minutes() < 5 {
23 | return "in a few minutes"
24 | }
25 | if dur.Minutes() < 60 {
26 | return fmt.Sprintf("in %.0f minutes", dur.Minutes())
27 | }
28 | if dur.Hours() < 24 {
29 | return fmt.Sprintf("in %.0f hours", dur.Hours())
30 | }
31 | days := math.Floor(dur.Hours() / 24)
32 | if days == 1 {
33 | return "tomorrow"
34 | }
35 | if days == 2 {
36 | return "day after tomorrow"
37 | }
38 | if days < 30 {
39 | return fmt.Sprintf("in %.0f days", days)
40 | }
41 | months := math.Floor(days / 30)
42 | if months == 1 {
43 | return "next month"
44 | }
45 | if months < 12 {
46 | return fmt.Sprintf("in %.0f months", months)
47 | }
48 |
49 | years := math.Floor(months / 12)
50 | if years == 1 {
51 | return "next year"
52 | }
53 |
54 | return fmt.Sprintf("in %.0f years", years)
55 |
56 | }
57 | func pastNaturalTime(base, value time.Time) string {
58 | dur := base.Sub(value)
59 | if dur.Seconds() <= 60 {
60 | return "a few seconds ago"
61 | }
62 | if dur.Minutes() < 5 {
63 | return "a few minutes ago"
64 | }
65 | if dur.Minutes() < 60 {
66 | return fmt.Sprintf("%.0f minutes ago", dur.Minutes())
67 | }
68 |
69 | days := math.Floor(dur.Hours() / 24)
70 | startBase := time.Date(base.Year(), base.Month(), base.Day(), 0, 0, 0, 0, time.UTC)
71 | yesterday := startBase.Add(-24 * time.Hour)
72 | dayBeforeYesterday := yesterday.Add(-24 * time.Hour)
73 |
74 | //fmt.Println(value, days, startBase, yesterday, dayBeforeYesterday)
75 |
76 | if value.After(startBase) {
77 | return fmt.Sprintf("%.0f hours ago", dur.Hours())
78 | }
79 | if value.After(yesterday) {
80 | return "yesterday"
81 | }
82 | if value.After(dayBeforeYesterday) {
83 | return "day before yesterday"
84 | }
85 | if days < 30 {
86 | return fmt.Sprintf("%.0f days ago", days)
87 | }
88 | months := math.Floor(days / 30)
89 | if months == 1 {
90 | return "last month"
91 | }
92 | if months < 12 {
93 | return fmt.Sprintf("%.0f months ago", months)
94 | }
95 |
96 | years := math.Floor(months / 12)
97 | if years == 1 {
98 | return "last year"
99 | }
100 |
101 | return fmt.Sprintf("%.0f years ago", years)
102 | }
103 |
--------------------------------------------------------------------------------
/webassets/blank.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/blank.png
--------------------------------------------------------------------------------
/webassets/fa/regular.min.css:
--------------------------------------------------------------------------------
1 | /*!
2 | * Font Awesome Free 5.15.2 by @fontawesome - https://fontawesome.com
3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
4 | */
5 | @font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.eot);src:url(../webfonts/fa-regular-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.woff) format("woff"),url(../webfonts/fa-regular-400.ttf) format("truetype"),url(../webfonts/fa-regular-400.svg#fontawesome) format("svg")}.far{font-family:"Font Awesome 5 Free";font-weight:400}
--------------------------------------------------------------------------------
/webassets/fa/solid.min.css:
--------------------------------------------------------------------------------
1 | /*!
2 | * Font Awesome Free 5.15.1 by @fontawesome - https://fontawesome.com
3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
4 | */
5 | @font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.eot);src:url(../webfonts/fa-solid-900.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.woff) format("woff"),url(../webfonts/fa-solid-900.ttf) format("truetype"),url(../webfonts/fa-solid-900.svg#fontawesome) format("svg")}.fa,.fas{font-family:"Font Awesome 5 Free";font-weight:900}
--------------------------------------------------------------------------------
/webassets/list-play-hover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/list-play-hover.png
--------------------------------------------------------------------------------
/webassets/list-play-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/list-play-light.png
--------------------------------------------------------------------------------
/webassets/modal/vue-modal.css:
--------------------------------------------------------------------------------
1 | .vm-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background-color:rgba(0,0,0,0.5)}.vm-wrapper{position:fixed;top:0;right:0;bottom:0;left:0;overflow-x:hidden;overflow-y:auto;outline:0}.vm{position:relative;margin:0px auto;width:calc(100% - 20px);min-width:110px;max-width:500px;background-color:#fff;top:30px;cursor:default;box-shadow:0 5px 15px rgba(0,0,0,0.5)}.vm-titlebar{padding:10px 15px 10px 15px;overflow:auto;border-bottom:1px solid #e5e5e5}.vm-title{margin-top:2px;margin-bottom:0px;display:inline-block;font-size:18px;font-weight:normal}.vm-btn-close{color:#ccc;padding:0px;cursor:pointer;background:0 0;border:0;float:right;font-size:24px;line-height:1em}.vm-btn-close:before{content:'×';font-family:Arial}.vm-btn-close:hover,.vm-btn-close:focus,.vm-btn-close:focus:hover{color:#bbb;border-color:transparent;background-color:transparent}.vm-content{padding:10px 15px 15px 15px}.vm-content .full-hr{width:auto;border:0;border-top:1px solid #e5e5e5;margin-top:15px;margin-bottom:15px;margin-left:-14px;margin-right:-14px}.vm-fadeIn{animation-name:vm-fadeIn}@keyframes vm-fadeIn{0%{opacity:0}100%{opacity:1}}.vm-fadeOut{animation-name:vm-fadeOut}@keyframes vm-fadeOut{0%{opacity:1}100%{opacity:0}}.vm-fadeIn,.vm-fadeOut{animation-duration:0.25s;animation-fill-mode:both}
2 |
--------------------------------------------------------------------------------
/webassets/modal/vue-modal.umd.min.js:
--------------------------------------------------------------------------------
1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("vue")):"function"==typeof define&&define.amd?define(["vue"],t):(e="undefined"!=typeof globalThis?globalThis:e||self).VueModal=t(e.Vue)}(this,(function(e){"use strict";function t(e){return e&&"object"==typeof e&&"default"in e?e:{default:e}}for(var n=t(e),o="-_",s=36;s--;)o+=s.toString(36);for(s=36;s---10;)o+=s.toString(36).toUpperCase();function i(e){return(i="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}var a={selector:"vue-portal-target-".concat(function(e){var t="";for(s=e||21;s--;)t+=o[64*Math.random()|0];return t}())},r=function(e){return a.selector=e},l="undefined"!=typeof window&&void 0!==("undefined"==typeof document?"undefined":i(document)),d=n.default.extend({abstract:!0,name:"PortalOutlet",props:["nodes","tag"],data:function(e){return{updatedNodes:e.nodes}},render:function(e){var t=this.updatedNodes&&this.updatedNodes();return t?t.length<2&&!t[0].text?t:e(this.tag||"DIV",t):e()},destroyed:function(){var e=this.$el;e.parentNode.removeChild(e)}}),u=n.default.extend({name:"VueSimplePortal",props:{disabled:{type:Boolean},prepend:{type:Boolean},selector:{type:String,default:function(){return"#".concat(a.selector)}},tag:{type:String,default:"DIV"}},render:function(e){if(this.disabled){var t=this.$scopedSlots&&this.$scopedSlots.default();return t?t.length<2&&!t[0].text?t:e(this.tag,t):e()}return e()},created:function(){this.getTargetEl()||this.insertTargetEl()},updated:function(){var e=this;this.$nextTick((function(){e.disabled||e.slotFn===e.$scopedSlots.default||(e.container.updatedNodes=e.$scopedSlots.default),e.slotFn=e.$scopedSlots.default}))},beforeDestroy:function(){this.unmount()},watch:{disabled:{immediate:!0,handler:function(e){e?this.unmount():this.$nextTick(this.mount)}}},methods:{getTargetEl:function(){if(l)return document.querySelector(this.selector)},insertTargetEl:function(){if(l){var e=document.querySelector("body"),t=document.createElement(this.tag);t.id=this.selector.substring(1),e.appendChild(t)}},mount:function(){var e=this.getTargetEl(),t=document.createElement("DIV");this.prepend&&e.firstChild?e.insertBefore(t,e.firstChild):e.appendChild(t),this.container=new d({el:t,parent:this,propsData:{tag:this.tag,nodes:this.$scopedSlots.default}})},unmount:function(){this.container&&(this.container.$destroy(),delete this.container)}}});"undefined"!=typeof window&&window.Vue&&window.Vue===n.default&&n.default.use((function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};e.component(t.name||"portal",u),t.defaultSelector&&r(t.defaultSelector)}));var c={type:[String,Object,Array],default:""},f='a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])',p=0;function h(e,t,n,o,s,i,a,r,l,d){"boolean"!=typeof a&&(l=r,r=a,a=!1);var u,c="function"==typeof n?n.options:n;if(e&&e.render&&(c.render=e.render,c.staticRenderFns=e.staticRenderFns,c._compiled=!0,s&&(c.functional=!0)),o&&(c._scopeId=o),i?(u=function(e){(e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext)||"undefined"==typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),t&&t.call(this,l(e)),e&&e._registeredComponents&&e._registeredComponents.add(i)},c._ssrRegister=u):t&&(u=a?function(e){t.call(this,d(e,this.$root.$options.shadowRoot))}:function(e){t.call(this,r(e))}),u)if(c.functional){var f=c.render;c.render=function(e,t){return u.call(t),f(e,t)}}else{var p=c.beforeCreate;c.beforeCreate=p?[].concat(p,u):[u]}return n}var m={name:"VueModal",components:{Portal:u},model:{prop:"basedOn",event:"close"},props:{title:{type:String,default:""},baseZindex:{type:Number,default:1051},bgClass:c,wrapperClass:c,modalClass:c,modalStyle:c,inClass:Object.assign({},c,{default:"vm-fadeIn"}),outClass:Object.assign({},c,{default:"vm-fadeOut"}),bgInClass:Object.assign({},c,{default:"vm-fadeIn"}),bgOutClass:Object.assign({},c,{default:"vm-fadeOut"}),appendTo:{type:String,default:"body"},live:{type:Boolean,default:!1},enableClose:{type:Boolean,default:!0},basedOn:{type:Boolean,default:!1}},data:function(){return{zIndex:0,id:null,show:!1,mount:!1,elToFocus:null}},created:function(){this.live&&(this.mount=!0)},mounted:function(){this.id="vm-"+this._uid,this.$watch("basedOn",(function(e){var t=this;e?(this.mount=!0,this.$nextTick((function(){t.show=!0}))):this.show=!1}),{immediate:!0})},beforeDestroy:function(){this.elToFocus=null},methods:{close:function(){!0===this.enableClose&&this.$emit("close",!1)},clickOutside:function(e){e.target===this.$refs["vm-wrapper"]&&this.close()},keydown:function(e){if(27===e.which&&this.close(),9===e.which){var t=[].slice.call(this.$refs["vm-wrapper"].querySelectorAll(f)).filter((function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)}));e.shiftKey?e.target!==t[0]&&e.target!==this.$refs["vm-wrapper"]||(e.preventDefault(),t[t.length-1].focus()):e.target===t[t.length-1]&&(e.preventDefault(),t[0].focus())}},getAllVisibleWrappers:function(){return[].slice.call(document.querySelectorAll("[data-vm-wrapper-id]")).filter((function(e){return"none"!==e.display}))},getTopZindex:function(){return this.getAllVisibleWrappers().reduce((function(e,t){return parseInt(t.style.zIndex)>e?parseInt(t.style.zIndex):e}),0)},handleFocus:function(e){var t=e.querySelector("[autofocus]");if(t)t.focus();else{var n=e.querySelectorAll(f);n.length?n[0].focus():e.focus()}},beforeOpen:function(){this.elToFocus=document.activeElement;var e=this.getTopZindex();this.zIndex=p?p+2:0===e?this.baseZindex:e+2,p=this.zIndex,this.$emit("before-open")},opening:function(){this.$emit("opening")},afterOpen:function(){this.handleFocus(this.$refs["vm-wrapper"]),this.$emit("after-open")},beforeClose:function(){this.$emit("before-close")},closing:function(){this.$emit("closing")},afterClose:function(){var e=this;this.zIndex=0,this.live||(this.mount=!1),this.$nextTick((function(){window.requestAnimationFrame((function(){var t=e.getTopZindex();if(t>0)for(var n=e.getAllVisibleWrappers(),o=0;o
2 |
3 |
4 | volume-x
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/webassets/next.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Next
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/webassets/now-playing.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Now Playing
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/webassets/pause.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Oval 1
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/webassets/play.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Oval 1
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/webassets/prev.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Previous
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/webassets/repeat-off.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fill 39
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/webassets/repeat-on.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fill 39
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/webassets/shuffle-off.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fill 83
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/webassets/shuffle-on.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fill 83
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/webassets/skeleton.min.css:
--------------------------------------------------------------------------------
1 | .container{position:relative;width:100%;max-width:960px;margin:0 auto;padding:0 20px;box-sizing:border-box}.column,.columns{width:100%;float:left;box-sizing:border-box}@media (min-width:400px){.container{width:85%;padding:0}}@media (min-width:550px){.container{width:80%}.column,.columns{margin-left:4%}.column:first-child,.columns:first-child{margin-left:0}.one.column,.one.columns{width:4.66666666667%}.two.columns{width:13.3333333333%}.three.columns{width:22%}.four.columns{width:30.6666666667%}.five.columns{width:39.3333333333%}.six.columns{width:48%}.seven.columns{width:56.6666666667%}.eight.columns{width:65.3333333333%}.nine.columns{width:74%}.ten.columns{width:82.6666666667%}.eleven.columns{width:91.3333333333%}.twelve.columns{width:100%;margin-left:0}.one-third.column{width:30.6666666667%}.two-thirds.column{width:65.3333333333%}.one-half.column{width:48%}.offset-by-one.column,.offset-by-one.columns{margin-left:8.66666666667%}.offset-by-two.column,.offset-by-two.columns{margin-left:17.3333333333%}.offset-by-three.column,.offset-by-three.columns{margin-left:26%}.offset-by-four.column,.offset-by-four.columns{margin-left:34.6666666667%}.offset-by-five.column,.offset-by-five.columns{margin-left:43.3333333333%}.offset-by-six.column,.offset-by-six.columns{margin-left:52%}.offset-by-seven.column,.offset-by-seven.columns{margin-left:60.6666666667%}.offset-by-eight.column,.offset-by-eight.columns{margin-left:69.3333333333%}.offset-by-nine.column,.offset-by-nine.columns{margin-left:78%}.offset-by-ten.column,.offset-by-ten.columns{margin-left:86.6666666667%}.offset-by-eleven.column,.offset-by-eleven.columns{margin-left:95.3333333333%}.offset-by-one-third.column,.offset-by-one-third.columns{margin-left:34.6666666667%}.offset-by-two-thirds.column,.offset-by-two-thirds.columns{margin-left:69.3333333333%}.offset-by-one-half.column,.offset-by-one-half.columns{margin-left:52%}}html{font-size:62.5%}body{font-size:1.5em;line-height:1.6;font-weight:400;font-family:Raleway,HelveticaNeue,"Helvetica Neue",Helvetica,Arial,sans-serif;color:#222}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:2rem;font-weight:300}h1{font-size:4rem;line-height:1.2;letter-spacing:-.1rem}h2{font-size:3.6rem;line-height:1.25;letter-spacing:-.1rem}h3{font-size:3rem;line-height:1.3;letter-spacing:-.1rem}h4{font-size:2.4rem;line-height:1.35;letter-spacing:-.08rem}h5{font-size:1.8rem;line-height:1.5;letter-spacing:-.05rem}h6{font-size:1.5rem;line-height:1.6;letter-spacing:0}@media (min-width:550px){h1{font-size:5rem}h2{font-size:4.2rem}h3{font-size:3.6rem}h4{font-size:3rem}h5{font-size:2.4rem}h6{font-size:1.5rem}}p{margin-top:0}a{color:#1EAEDB}a:hover{color:#0FA0CE}.button,button,input[type=button],input[type=reset],input[type=submit]{display:inline-block;height:38px;padding:0 30px;color:#555;text-align:center;font-size:11px;font-weight:600;line-height:38px;letter-spacing:.1rem;text-transform:uppercase;text-decoration:none;white-space:nowrap;background-color:transparent;border-radius:4px;border:1px solid #bbb;cursor:pointer;box-sizing:border-box}.button:focus,.button:hover,button:focus,button:hover,input[type=button]:focus,input[type=button]:hover,input[type=reset]:focus,input[type=reset]:hover,input[type=submit]:focus,input[type=submit]:hover{color:#333;border-color:#888;outline:0}.button.button-primary,button.button-primary,input[type=button].button-primary,input[type=reset].button-primary,input[type=submit].button-primary{color:#FFF;background-color:#33C3F0;border-color:#33C3F0}.button.button-primary:focus,.button.button-primary:hover,button.button-primary:focus,button.button-primary:hover,input[type=button].button-primary:focus,input[type=button].button-primary:hover,input[type=reset].button-primary:focus,input[type=reset].button-primary:hover,input[type=submit].button-primary:focus,input[type=submit].button-primary:hover{color:#FFF;background-color:#1EAEDB;border-color:#1EAEDB}input[type=email],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=url],select,textarea{height:38px;padding:6px 10px;background-color:#fff;border:1px solid #D1D1D1;border-radius:4px;box-shadow:none;box-sizing:border-box}input[type=email],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=url],textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none}textarea{min-height:65px;padding-top:6px;padding-bottom:6px}input[type=email]:focus,input[type=number]:focus,input[type=password]:focus,input[type=search]:focus,input[type=tel]:focus,input[type=text]:focus,input[type=url]:focus,select:focus,textarea:focus{border:1px solid #33C3F0;outline:0}label,legend{display:block;margin-bottom:.5rem;font-weight:600}fieldset{padding:0;border-width:0}input[type=checkbox],input[type=radio]{display:inline}label>.label-body{display:inline-block;margin-left:.5rem;font-weight:400}ul{list-style:circle inside}ol{list-style:decimal inside}ol,ul{padding-left:0;margin-top:0}ol ol,ol ul,ul ol,ul ul{margin:1.5rem 0 1.5rem 3rem;font-size:90%}li{margin-bottom:1rem}code{padding:.2rem .5rem;margin:0 .2rem;font-size:90%;white-space:nowrap;background:#F1F1F1;border:1px solid #E1E1E1;border-radius:4px}pre>code{display:block;padding:1rem 1.5rem;white-space:pre}td,th{padding:12px 15px;text-align:left;border-bottom:1px solid #E1E1E1}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}.button,button{margin-bottom:1rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}.u-full-width{width:100%;box-sizing:border-box}.u-max-full-width{max-width:100%;box-sizing:border-box}.u-pull-right{float:right}.u-pull-left{float:left}hr{margin-top:3rem;margin-bottom:3.5rem;border-width:0;border-top:1px solid #E1E1E1}.container:after,.row:after,.u-cf{content:"";display:table;clear:both}
--------------------------------------------------------------------------------
/webassets/volume.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | volume-2
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/webassets/vue-multiselect.min.css:
--------------------------------------------------------------------------------
1 | fieldset[disabled] .multiselect {
2 | pointer-events: none;
3 | }
4 | .multiselect__spinner {
5 | position: absolute;
6 | right: 1px;
7 | top: 1px;
8 | width: 48px;
9 | height: 35px;
10 | background: #fff;
11 | display: block;
12 | }
13 | .multiselect__spinner:after,
14 | .multiselect__spinner:before {
15 | position: absolute;
16 | content: "";
17 | top: 50%;
18 | left: 50%;
19 | margin: -8px 0 0 -8px;
20 | width: 16px;
21 | height: 16px;
22 | border-radius: 100%;
23 | border-color: #41b883 transparent transparent;
24 | border-style: solid;
25 | border-width: 2px;
26 | box-shadow: 0 0 0 1px transparent;
27 | }
28 | .multiselect__spinner:before {
29 | animation: a 2.4s cubic-bezier(0.41, 0.26, 0.2, 0.62);
30 | animation-iteration-count: infinite;
31 | }
32 | .multiselect__spinner:after {
33 | animation: a 2.4s cubic-bezier(0.51, 0.09, 0.21, 0.8);
34 | animation-iteration-count: infinite;
35 | }
36 | .multiselect__loading-enter-active,
37 | .multiselect__loading-leave-active {
38 | transition: opacity 0.4s ease-in-out;
39 | opacity: 1;
40 | }
41 | .multiselect__loading-enter,
42 | .multiselect__loading-leave-active {
43 | opacity: 0;
44 | }
45 | .multiselect,
46 | .multiselect__input,
47 | .multiselect__single {
48 | font-family: inherit;
49 | font-size: 16px;
50 | -ms-touch-action: manipulation;
51 | touch-action: manipulation;
52 | }
53 | .multiselect {
54 | box-sizing: content-box;
55 | display: block;
56 | position: relative;
57 | width: 100%;
58 | min-height: 40px;
59 | text-align: left;
60 | color: #35495e;
61 | }
62 | .multiselect * {
63 | box-sizing: border-box;
64 | }
65 | .multiselect:focus {
66 | outline: none;
67 | }
68 | .multiselect--disabled {
69 | opacity: 0.6;
70 | }
71 | .multiselect--active {
72 | z-index: 1;
73 | }
74 | .multiselect--active:not(.multiselect--above) .multiselect__current,
75 | .multiselect--active:not(.multiselect--above) .multiselect__input,
76 | .multiselect--active:not(.multiselect--above) .multiselect__tags {
77 | border-bottom-left-radius: 0;
78 | border-bottom-right-radius: 0;
79 | }
80 | .multiselect--active .multiselect__select {
81 | transform: rotate(180deg);
82 | }
83 | .multiselect--above.multiselect--active .multiselect__current,
84 | .multiselect--above.multiselect--active .multiselect__input,
85 | .multiselect--above.multiselect--active .multiselect__tags {
86 | border-top-left-radius: 0;
87 | border-top-right-radius: 0;
88 | }
89 | .multiselect__input,
90 | .multiselect__single {
91 | position: relative;
92 | display: inline-block;
93 | min-height: 20px;
94 | line-height: 20px;
95 | border: none;
96 | border-radius: 5px;
97 | padding: 0 0 0 5px;
98 | width: 100%;
99 | transition: border 0.1s ease;
100 | box-sizing: border-box;
101 | margin-bottom: 8px;
102 | vertical-align: top;
103 | }
104 | .multiselect__input::-webkit-input-placeholder {
105 | color: #35495e;
106 | }
107 | .multiselect__input:-ms-input-placeholder {
108 | color: #35495e;
109 | }
110 | .multiselect__input::placeholder {
111 | color: #35495e;
112 | }
113 | .multiselect__tag ~ .multiselect__input,
114 | .multiselect__tag ~ .multiselect__single {
115 | width: auto;
116 | }
117 | .multiselect__input:hover,
118 | .multiselect__single:hover {
119 | border-color: #cfcfcf;
120 | }
121 | .multiselect__input:focus,
122 | .multiselect__single:focus {
123 | border-color: #a8a8a8;
124 | outline: none;
125 | }
126 | .multiselect__single {
127 | padding-left: 5px;
128 | margin-bottom: 8px;
129 | }
130 | .multiselect__tags-wrap {
131 | display: inline;
132 | }
133 | .multiselect__tags {
134 | min-height: 40px;
135 | display: block;
136 | padding: 8px 40px 0 8px;
137 | border-radius: 5px;
138 | border: 1px solid #e8e8e8;
139 |
140 | font-size: 14px;
141 | }
142 | .multiselect__tag {
143 | position: relative;
144 | display: inline-block;
145 | padding: 4px 26px 4px 10px;
146 | border-radius: 5px;
147 | margin-right: 10px;
148 | color: #fff;
149 | line-height: 1;
150 | /* background: #41b883; */
151 | margin-bottom: 5px;
152 | white-space: nowrap;
153 | overflow: hidden;
154 | max-width: 100%;
155 | text-overflow: ellipsis;
156 | }
157 | .multiselect__tag-icon {
158 | cursor: pointer;
159 | margin-left: 7px;
160 | position: absolute;
161 | right: 0;
162 | top: 0;
163 | bottom: 0;
164 | font-weight: 700;
165 | font-style: normal;
166 | width: 22px;
167 | text-align: center;
168 | line-height: 22px;
169 | transition: all 0.2s ease;
170 | border-radius: 5px;
171 | }
172 | .multiselect__tag-icon:after {
173 | content: "\D7";
174 | color: #266d4d;
175 | font-size: 14px;
176 | }
177 | .multiselect__tag-icon:focus,
178 | .multiselect__tag-icon:hover {
179 | background: #369a6e;
180 | }
181 | .multiselect__tag-icon:focus:after,
182 | .multiselect__tag-icon:hover:after {
183 | color: #fff;
184 | }
185 | .multiselect__current {
186 | min-height: 40px;
187 | overflow: hidden;
188 | padding: 8px 12px 0;
189 | padding-right: 30px;
190 | white-space: nowrap;
191 | border-radius: 5px;
192 | border: 1px solid #e8e8e8;
193 | }
194 | .multiselect__current,
195 | .multiselect__select {
196 | line-height: 16px;
197 | box-sizing: border-box;
198 | display: block;
199 | margin: 0;
200 | text-decoration: none;
201 | cursor: pointer;
202 | }
203 | .multiselect__select {
204 | position: absolute;
205 | width: 40px;
206 | height: 38px;
207 | right: 1px;
208 | top: 1px;
209 | padding: 4px 8px;
210 | text-align: center;
211 | transition: transform 0.2s ease;
212 | }
213 | .multiselect__select:before {
214 | position: relative;
215 | right: 0;
216 | top: 65%;
217 | color: #999;
218 | margin-top: 4px;
219 | border-style: solid;
220 | border-width: 5px 5px 0;
221 | border-color: #999 transparent transparent;
222 | content: "";
223 | }
224 | .multiselect__placeholder {
225 | color: #adadad;
226 | display: inline-block;
227 | margin-bottom: 10px;
228 | padding-top: 2px;
229 | }
230 | .multiselect--active .multiselect__placeholder {
231 | display: none;
232 | }
233 | .multiselect__content-wrapper {
234 | position: absolute;
235 | display: block;
236 | background: #fff;
237 | width: 100%;
238 | max-height: 240px;
239 | overflow: auto;
240 | border: 1px solid #e8e8e8;
241 | border-top: none;
242 | border-bottom-left-radius: 5px;
243 | border-bottom-right-radius: 5px;
244 | z-index: 1;
245 | -webkit-overflow-scrolling: touch;
246 | }
247 | .multiselect__content {
248 | list-style: none;
249 | display: inline-block;
250 | padding: 0;
251 | margin: 0;
252 | min-width: 100%;
253 | vertical-align: top;
254 | }
255 | .multiselect--above .multiselect__content-wrapper {
256 | bottom: 100%;
257 | border-bottom-left-radius: 0;
258 | border-bottom-right-radius: 0;
259 | border-top-left-radius: 5px;
260 | border-top-right-radius: 5px;
261 | border-bottom: none;
262 | border-top: 1px solid #e8e8e8;
263 | }
264 | .multiselect__content::webkit-scrollbar {
265 | display: none;
266 | }
267 | .multiselect__element {
268 | display: block;
269 | }
270 | .multiselect__option {
271 | display: block;
272 | padding: 12px;
273 | min-height: 40px;
274 | line-height: 16px;
275 | text-decoration: none;
276 | text-transform: none;
277 | vertical-align: middle;
278 | position: relative;
279 | cursor: pointer;
280 | white-space: nowrap;
281 | }
282 | .multiselect__option:after {
283 | top: 0;
284 | right: 0;
285 | position: absolute;
286 | line-height: 40px;
287 | padding-right: 12px;
288 | padding-left: 20px;
289 | font-size: 13px;
290 | }
291 | .multiselect__option--highlight {
292 | /* background: #41b883; */
293 | outline: none;
294 | color: #fff;
295 | }
296 | .multiselect__option--highlight:after {
297 | content: attr(data-select);
298 | /* background: #41b883; */
299 | color: #fff;
300 | }
301 | .multiselect__option--selected {
302 | /* background: #f3f3f3;
303 | color: #35495e; */
304 | font-weight: 700;
305 | }
306 | .multiselect__option--selected:after {
307 | content: attr(data-selected);
308 | color: silver;
309 | }
310 | .multiselect__option--selected.multiselect__option--highlight {
311 | /* background: #cccccc; */
312 | /* color: #fff; */
313 | }
314 | .multiselect__option--selected.multiselect__option--highlight:after {
315 | /* background: #cccccc; */
316 | content: attr(data-deselect);
317 | /* color: #fff; */
318 | }
319 | .multiselect--disabled {
320 | background: #ededed;
321 | pointer-events: none;
322 | }
323 | .multiselect--disabled .multiselect__current,
324 | .multiselect--disabled .multiselect__select,
325 | .multiselect__option--disabled {
326 | background: #ededed;
327 | color: #a6a6a6;
328 | }
329 | .multiselect__option--disabled {
330 | cursor: text;
331 | pointer-events: none;
332 | }
333 | .multiselect__option--group {
334 | background: #ededed;
335 | color: #35495e;
336 | }
337 | .multiselect__option--group.multiselect__option--highlight {
338 | background: #35495e;
339 | color: #fff;
340 | }
341 | .multiselect__option--group.multiselect__option--highlight:after {
342 | background: #35495e;
343 | }
344 | .multiselect__option--disabled.multiselect__option--highlight {
345 | background: #dedede;
346 | }
347 | .multiselect__option--group-selected.multiselect__option--highlight {
348 | background: #ff6a6a;
349 | color: #fff;
350 | }
351 | .multiselect__option--group-selected.multiselect__option--highlight:after {
352 | background: #ff6a6a;
353 | content: attr(data-deselect);
354 | color: #fff;
355 | }
356 | .multiselect-enter-active,
357 | .multiselect-leave-active {
358 | transition: all 0.15s ease;
359 | }
360 | .multiselect-enter,
361 | .multiselect-leave-active {
362 | opacity: 0;
363 | }
364 | .multiselect__strong {
365 | margin-bottom: 8px;
366 | line-height: 20px;
367 | display: inline-block;
368 | vertical-align: top;
369 | }
370 | [dir="rtl"] .multiselect {
371 | text-align: right;
372 | }
373 | [dir="rtl"] .multiselect__select {
374 | right: auto;
375 | left: 1px;
376 | }
377 | [dir="rtl"] .multiselect__tags {
378 | padding: 8px 8px 0 40px;
379 | }
380 | [dir="rtl"] .multiselect__content {
381 | text-align: right;
382 | }
383 | [dir="rtl"] .multiselect__option:after {
384 | right: auto;
385 | left: 0;
386 | }
387 | [dir="rtl"] .multiselect__clear {
388 | right: auto;
389 | left: 12px;
390 | }
391 | [dir="rtl"] .multiselect__spinner {
392 | right: auto;
393 | left: 1px;
394 | }
395 | @keyframes a {
396 | 0% {
397 | transform: rotate(0);
398 | }
399 | to {
400 | transform: rotate(2turn);
401 | }
402 | }
403 |
--------------------------------------------------------------------------------
/webassets/webfonts/fa-brands-400.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/webfonts/fa-brands-400.eot
--------------------------------------------------------------------------------
/webassets/webfonts/fa-brands-400.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/webfonts/fa-brands-400.ttf
--------------------------------------------------------------------------------
/webassets/webfonts/fa-brands-400.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/webfonts/fa-brands-400.woff
--------------------------------------------------------------------------------
/webassets/webfonts/fa-brands-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/webfonts/fa-brands-400.woff2
--------------------------------------------------------------------------------
/webassets/webfonts/fa-regular-400.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/webfonts/fa-regular-400.eot
--------------------------------------------------------------------------------
/webassets/webfonts/fa-regular-400.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/webfonts/fa-regular-400.ttf
--------------------------------------------------------------------------------
/webassets/webfonts/fa-regular-400.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/webfonts/fa-regular-400.woff
--------------------------------------------------------------------------------
/webassets/webfonts/fa-regular-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/webfonts/fa-regular-400.woff2
--------------------------------------------------------------------------------
/webassets/webfonts/fa-solid-900.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/webfonts/fa-solid-900.eot
--------------------------------------------------------------------------------
/webassets/webfonts/fa-solid-900.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/webfonts/fa-solid-900.ttf
--------------------------------------------------------------------------------
/webassets/webfonts/fa-solid-900.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/webfonts/fa-solid-900.woff
--------------------------------------------------------------------------------
/webassets/webfonts/fa-solid-900.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akhilrex/podgrab/44e2b1c207288bb8a84ecb64424e0a501fa02510/webassets/webfonts/fa-solid-900.woff2
--------------------------------------------------------------------------------