├── .env ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── hub.yml ├── .gitignore ├── .vscode └── launch.json ├── Dockerfile ├── LICENSE ├── Readme.md ├── Screenshots.md ├── client ├── addPodcast.html ├── backups.html ├── commoncss.html ├── episodes.html ├── episodes_new.html ├── index.html ├── navbar.html ├── player.html ├── podcast.html ├── podcastlist.html ├── scripts.html ├── settings.html └── tags.html ├── controllers ├── pages.go ├── podcast.go └── websockets.go ├── db ├── base.go ├── db.go ├── dbfunctions.go ├── migrations.go └── podcast.go ├── docker-compose.yml ├── docs └── ubuntu-install.md ├── go.mod ├── go.sum ├── images ├── add_podcast.jpg ├── all_episodes.jpg ├── player.jpg ├── podcast_episodes.jpg ├── screenshot.jpg ├── screenshot_1.jpg └── settings.jpg ├── internal └── sanitize │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ └── sanitize.go ├── main.go ├── model ├── errors.go ├── gpodderModels.go ├── itunesModel.go ├── opmlModels.go ├── podcastModels.go ├── queryModels.go └── rssModels.go ├── service ├── fileService.go ├── gpodderService.go ├── itunesService.go ├── naturaltime.go └── podcastService.go └── webassets ├── amplitude.min.js ├── axios.min.js ├── axios.min.map ├── blank.png ├── fa ├── fontawesome.min.css ├── regular.min.css └── solid.min.css ├── list-play-hover.png ├── list-play-light.png ├── luxon.min.js ├── modal ├── vue-modal.css ├── vue-modal.umd.min.js └── vue-modal.umd.min.js.map ├── mute.svg ├── next.svg ├── now-playing.svg ├── pause.svg ├── play.svg ├── popper.min.js ├── prev.svg ├── repeat-off.svg ├── repeat-on.svg ├── shuffle-off.svg ├── shuffle-on.svg ├── skeleton.min.css ├── stopword.js ├── stopword.map.js ├── tippy-bundle.umd.min.js ├── volume.svg ├── vue-multiselect.min.css ├── vue-multiselect.min.js ├── vue-toasted.min.js ├── vue.js ├── vue.min.js └── webfonts ├── fa-brands-400.eot ├── fa-brands-400.svg ├── fa-brands-400.ttf ├── fa-brands-400.woff ├── fa-brands-400.woff2 ├── fa-regular-400.eot ├── fa-regular-400.svg ├── fa-regular-400.ttf ├── fa-regular-400.woff ├── fa-regular-400.woff2 ├── fa-solid-900.eot ├── fa-solid-900.svg ├── fa-solid-900.ttf ├── fa-solid-900.woff └── fa-solid-900.woff2 /.env: -------------------------------------------------------------------------------- 1 | CONFIG=. 2 | DATA=./assets 3 | CHECK_FREQUENCY = 10 4 | PASSWORD= -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | *Before creating a bug report please make sure you are using the latest docker image / code base.* 11 | 12 | **Please complete the following information** 13 | - Installation Type: [Docker/Native] 14 | - Have you tried using the latest docker image / code base [yes/no] 15 | 16 | **Describe the bug** 17 | A clear and concise description of what the bug is. 18 | 19 | **To Reproduce** 20 | Steps to reproduce the behavior: 21 | 1. Go to '...' 22 | 2. Click on '....' 23 | 3. Scroll down to '....' 24 | 4. See error 25 | 26 | **Expected behavior** 27 | A clear and concise description of what you expected to happen. 28 | 29 | **Screenshots** 30 | If applicable, add screenshots to help explain your problem. 31 | 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/workflows/hub.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: master 6 | 7 | jobs: 8 | multi: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - 12 | name: Checkout 13 | uses: actions/checkout@v2 14 | - 15 | name: Set up QEMU 16 | uses: docker/setup-qemu-action@v1 17 | - 18 | name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v1 20 | - 21 | name: Set up build cache 22 | uses: actions/cache@v2 23 | with: 24 | path: /tmp/.buildx-cache 25 | key: ${{ runner.os }}-buildx-${{ github.sha }} 26 | restore-keys: | 27 | ${{ runner.os }}-buildx- 28 | - 29 | name: Login to DockerHub 30 | uses: docker/login-action@v1 31 | with: 32 | username: ${{ secrets.DOCKER_USERNAME }} 33 | password: ${{ secrets.DOCKERHUB_TOKEN }} 34 | - 35 | name: Login to GitHub 36 | uses: docker/login-action@v1 37 | with: 38 | registry: ghcr.io 39 | username: ${{ github.repository_owner }} 40 | password: ${{ secrets.CR_PAT }} 41 | - 42 | name: Build and push 43 | uses: docker/build-push-action@v2 44 | with: 45 | context: . 46 | file: ./Dockerfile 47 | #platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 48 | platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 49 | push: true 50 | cache-from: type=local,src=/tmp/.buildx-cache 51 | cache-to: type=local,dest=/tmp/.buildx-cache 52 | tags: | 53 | akhilrex/podgrab:latest 54 | akhilrex/podgrab:1.0.0 55 | ghcr.io/akhilrex/podgrab:latest 56 | ghcr.io/akhilrex/podgrab:1.0.0 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | *.db 14 | 15 | # Dependency directories (remove the comment below to include it) 16 | # vendor/ 17 | assets/* 18 | keys/* 19 | backups/* 20 | nodemon.json -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [{ 7 | "program": "${workspaceRoot}/main.go", 8 | "type": "go", 9 | "mode": "auto" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GO_VERSION=1.15.2 2 | 3 | FROM golang:${GO_VERSION}-alpine AS builder 4 | 5 | RUN apk update && apk add alpine-sdk git && rm -rf /var/cache/apk/* 6 | 7 | RUN mkdir -p /api 8 | WORKDIR /api 9 | 10 | COPY go.mod . 11 | COPY go.sum . 12 | RUN go mod download 13 | 14 | COPY . . 15 | RUN go build -o ./app ./main.go 16 | 17 | FROM alpine:latest 18 | 19 | LABEL org.opencontainers.image.source="https://github.com/akhilrex/podgrab" 20 | 21 | ENV CONFIG=/config 22 | ENV DATA=/assets 23 | ENV UID=998 24 | ENV PID=100 25 | ENV GIN_MODE=release 26 | VOLUME ["/config", "/assets"] 27 | RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/* 28 | RUN mkdir -p /config; \ 29 | mkdir -p /assets; \ 30 | mkdir -p /api 31 | 32 | RUN chmod 777 /config; \ 33 | chmod 777 /assets 34 | 35 | WORKDIR /api 36 | COPY --from=builder /api/app . 37 | COPY client ./client 38 | COPY webassets ./webassets 39 | 40 | EXPOSE 8080 41 | 42 | ENTRYPOINT ["./app"] -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | 2 | [![Contributors][contributors-shield]][contributors-url] 3 | [![Forks][forks-shield]][forks-url] 4 | [![Stargazers][stars-shield]][stars-url] 5 | [![Issues][issues-shield]][issues-url] 6 | [![MIT License][license-shield]][license-url] 7 | [![LinkedIn][linkedin-shield]][linkedin-url] 8 | 9 | 10 |
11 |

12 | 15 | 16 |

Podgrab

17 |

Current Version -2022.07.07

18 | 19 |

20 | A self-hosted podcast manager to download episodes as soon as they become live 21 |
22 | Explore the docs » 23 |
24 |
25 | 27 | Report Bug 28 | · 29 | Request Feature 30 | · 31 | Screenshots 32 |

33 |

34 | 35 | 36 | 37 | ## Table of Contents 38 | 39 | - [About the Project](#about-the-project) 40 | - [Motivation](#motivation) 41 | - [Built With](#built-with) 42 | - [Features](#features) 43 | - [Installation](#installation) 44 | - [License](#license) 45 | - [Roadmap](#roadmap) 46 | - [Contact](#contact) 47 | 48 | 49 | 50 | ## About The Project 51 | 52 | Podgrab is a is a self-hosted podcast manager which automatically downloads latest podcast episodes. It is a light-weight application built using GO. 53 | 54 | It works best if you already know which podcasts you want to monitor. However there is a podcast search system powered by iTunes built into Podgrab 55 | 56 | *Developers Note: This project is under active development which means I release new updates very frequently. It is recommended that you use something like [watchtower](https://github.com/containrrr/watchtower) which will automatically update your containers whenever I release a new version or periodically rebuild the container with the latest image manually.* 57 | 58 | __Also check out my other self-hosted, open-source solution - [Hammond](https://github.com/akhilrex/hammond) - Vehicle and Expense management system.__ 59 | 60 | ### Motivation 61 | 62 | Podgrab started as a tool that I initially built to solve a specific problem I had. During the COVID pandemic times I started going for a run. I do not prefer taking my phone along so I would add podcast episodes to my smart watch which could be connected with my bluetooth earphones. Most podcasting apps do not expose the mp3 files directly which is why I decided to build this quick tool for myself. Once it reached a stage where my requirements were fulfilled I decided to make it a little pretty and share it with everyone else. 63 | 64 | ![Product Name Screen Shot][product-screenshot] 65 | [More Screenshots](Screenshots.md) 66 | 67 | ### Built With 68 | 69 | - [Go](https://golang.org/) 70 | - [Go-Gin](https://github.com/gin-gonic/gin) 71 | - [GORM](https://github.com/go-gorm/gorm) 72 | - [SQLite](https://www.sqlite.org/index.html) 73 | 74 | ### Features 75 | - Download/Archive complete podcast 76 | - Auto-download new episodes 77 | - Tag/Label podcasts into groups 78 | - Download on demand 79 | - Podcast Discovery - Search and Add podcasts using iTunes API 80 | - Full-fledged podcast player - Play downloaded files or stream from original source. Play single episodes, full podcasts and podcast groups(tags) 81 | - Add using direct RSS feed URL / OMPL import / Search 82 | - Basic Authentication 83 | - Existing episode file detection - Prevent re-downloading files if already present 84 | - Easy OPML import/export 85 | - Customizable episode names 86 | - Dark Mode 87 | - Self Hosted / Open Source 88 | - Docker support 89 | 90 | ## Installation 91 | 92 | The easiest way to run Podgrab is to run it as a docker container. 93 | 94 | ### Using Docker 95 | 96 | Simple setup without mounted volumes (for testing and evaluation) 97 | 98 | ```sh 99 | docker run -d -p 8080:8080 --name=podgrab akhilrex/podgrab 100 | ``` 101 | 102 | Binding local volumes to the container 103 | 104 | ```sh 105 | docker run -d -p 8080:8080 --name=podgrab -v "/host/path/to/assets:/assets" -v "/host/path/to/config:/config" akhilrex/podgrab 106 | ``` 107 | 108 | ### Using Docker-Compose 109 | 110 | Modify the docker compose file provided [here](https://github.com/akhilrex/podgrab/blob/master/docker-compose.yml) to update the volume and port binding and run the following command 111 | 112 | ```yaml 113 | version: "2.1" 114 | services: 115 | podgrab: 116 | image: akhilrex/podgrab 117 | container_name: podgrab 118 | environment: 119 | - CHECK_FREQUENCY=240 120 | # - PASSWORD=password ## Uncomment to enable basic authentication, username = podgrab 121 | volumes: 122 | - /path/to/config:/config 123 | - /path/to/data:/assets 124 | ports: 125 | - 8080:8080 126 | restart: unless-stopped 127 | ``` 128 | 129 | ```sh 130 | docker-compose up -d 131 | ``` 132 | ### Build from Source / Ubuntu Installation 133 | 134 | 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. Follow the link below to get a guide on how to build Podgrab from source. 135 | 136 | [Build from source / Ubuntu Guide](docs/ubuntu-install.md) 137 | ### Environment Variables 138 | 139 | | Name | Description | Default | 140 | | --------------- | ----------------------------------------------------------------------- | ------- | 141 | | CHECK_FREQUENCY | How frequently to check for new episodes and missing files (in minutes) | 30 | 142 | | PASSWORD | Set to some non empty value to enable Basic Authentication, username `podgrab`|(empty)| 143 | | PORT | Change the internal port of the application. If you change this you might have to change your docker configuration as well | (empty) | 144 | 145 | ### Setup 146 | 147 | - Enable *websocket support* if running behind a reverse proxy. This is needed for the "Add to playlist" functionality. 148 | - Go through the settings page once and change relevant settings before adding podcasts. 149 | 150 | ## License 151 | 152 | Distributed under the GPL-3.0 License. See `LICENSE` for more information. 153 | 154 | ## Roadmap 155 | 156 | - [x] Basic Authentication 157 | - [x] Append Date to filename 158 | - [x] iTunes Search 159 | - [x] Existing episodes detection (Will not redownload if files exist even with a fresh install) 160 | - [x] Downloading/downloaded indicator 161 | - [x] Played/Unplayed Flag 162 | - [x] OPML import 163 | - [x] OPML export 164 | - [x] In built podcast player 165 | - [ ] Set ID3 tags if not set 166 | - [ ] Filtering and Sorting options 167 | - [ ] Native installer for Windows/Linux/MacOS 168 | 169 | 170 | 171 | 172 | 173 | 174 | ## Contact 175 | 176 | Akhil Gupta - [@akhilrex](https://twitter.com/akhilrex) 177 | 178 | Project Link: [https://github.com/akhilrex/podgrab](https://github.com/akhilrex/podgrab) 179 | 180 | Buy Me A Coffee 181 | 182 | 183 | 184 | 185 | [contributors-shield]: https://img.shields.io/github/contributors/akhilrex/podgrab.svg?style=flat-square 186 | [contributors-url]: https://github.com/akhilrex/podgrab/graphs/contributors 187 | [forks-shield]: https://img.shields.io/github/forks/akhilrex/podgrab.svg?style=flat-square 188 | [forks-url]: https://github.com/akhilrex/podgrab/network/members 189 | [stars-shield]: https://img.shields.io/github/stars/akhilrex/podgrab.svg?style=flat-square 190 | [stars-url]: https://github.com/akhilrex/podgrab/stargazers 191 | [issues-shield]: https://img.shields.io/github/issues/akhilrex/podgrab.svg?style=flat-square 192 | [issues-url]: https://github.com/akhilrex/podgrab/issues 193 | [license-shield]: https://img.shields.io/github/license/akhilrex/podgrab.svg?style=flat-square 194 | [license-url]: https://github.com/akhilrex/podgrab/blob/master/LICENSE.txt 195 | [linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555 196 | [linkedin-url]: https://linkedin.com/in/akhilrex 197 | [product-screenshot]: images/screenshot.jpg 198 | -------------------------------------------------------------------------------- /Screenshots.md: -------------------------------------------------------------------------------- 1 | ## Home Page / Podcast Listing 2 | 3 | ![Product Name Screen Shot][product-screenshot] 4 | 5 | ## Add Podcast 6 | 7 | ![Podcast Episodes](images/add_podcast.jpg) 8 | 9 | ## All Episodes Chronologically 10 | 11 | ![All Episodes](images/all_episodes.jpg) 12 | 13 | ## Podcast Episodes 14 | 15 | ![Podcast Episodes](images/podcast_episodes.jpg) 16 | 17 | 18 | ## Player 19 | 20 | ![Player](images/player.jpg) 21 | 22 | ## Settings 23 | 24 | ![Podcast Episodes](images/settings.jpg) 25 | 26 | [product-screenshot]: images/screenshot.jpg 27 | -------------------------------------------------------------------------------- /client/addPodcast.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Add Podcast - PodGrab 7 | {{template "commoncss" .}} 8 | 13 | 14 | 15 |
16 | {{template "navbar" .}} 17 |
18 |
19 |
20 |

Add using the direct link to rss feed

21 |
22 |
23 | 31 |
32 |
33 | 38 |
39 |
40 |
41 |
42 |
43 |
44 |

Import OPML file

45 | Most of the major podcast manager apps (eg. Podcast Addict) 48 | have the option to export add your podcast subscriptions in the 49 | opml format. You can migrate all your podcast in one go by 50 | exporting that OPML file and importing it here. 53 |
54 |
55 | 56 |
62 |
63 | 69 |
70 | 71 |
72 | 77 |
78 |
79 |
80 |
81 |
82 |

Search for your favorite podcast

83 | Experimental: Uses iTunes API to show search results. 88 |
89 |
90 |
91 | 99 |
100 |
101 | 111 | 112 |
113 |
114 | 119 |
120 |
121 |
122 | 123 |
124 |
125 | 126 |
127 | 132 |
133 |
134 |
${item.title}
135 | 136 | 137 | Categories: ${item.categories.join(', ')} 138 | 139 |

${ item.description }

140 |
141 |
142 | 149 | 156 |
157 | 158 |
159 |
160 |
161 |
162 |
163 |
164 | {{template "scripts"}} 165 | 166 | 299 | 300 | 301 | -------------------------------------------------------------------------------- /client/backups.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PodGrab 7 | {{template "commoncss" .}} 8 | 29 | 30 | 31 |
32 | 33 | {{template "navbar" .}} 34 |
35 |
36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | {{ range .backups}} 44 | 45 | 46 | 47 | 48 | {{end}} 49 | 50 |
DatePath
{{ formatDate .date }}{{.name}}
51 |
52 |
53 | 54 | {{template "scripts"}} 55 | 57 | 58 | -------------------------------------------------------------------------------- /client/commoncss.html: -------------------------------------------------------------------------------- 1 | {{define "commoncss"}} 2 | 3 | 4 | 10 | 11 | 12 | 13 | {{if .setting.DarkMode}} 14 | 23 | {{else}} 24 | 33 | {{end}} 34 | 44 | 45 | 195 | 202 | {{end}} 203 | -------------------------------------------------------------------------------- /client/episodes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{.title}} - PodGrab 7 | {{template "commoncss" .}} 8 | 56 | 57 | 58 |
59 | {{template "navbar" .}} 60 | 61 |
{{$setting := .setting}} {{range .podcastItems}} 62 | 63 |
64 |
65 | {{ .Title }} 72 |
73 |
74 |
75 |
76 |

77 | {{if .IsPlayed }} 82 | {{end}} {{.Title}} {{if .Podcast.Title }} // {{ .Podcast.Title}} 83 | {{end}} 84 |

85 |
86 |
87 | {{ naturalDate .PubDate }} 90 |
91 |
92 | {{ formatDuration .Duration}} 93 |
94 |
95 | 96 |

{{ .Summary }}

97 | 98 | {{if .IsPlayed }} 99 | 105 | {{ else }} 106 | 112 | {{ end }} 113 | 114 | {{if isDateNull .BookmarkDate }} 115 | 121 | {{ else }} 122 | 128 | 129 | {{ end }} 130 | 131 | {{if .DownloadPath}} 132 | 139 | 145 | 146 | 147 | {{else}} {{if not $setting.AutoDownload}} 148 | 155 | {{else}} {{if eq .DownloadStatus 3}} 156 | 163 | {{end}} {{end}} {{end }} 164 | 170 | 176 |
177 |
178 |
179 |
180 | {{else}} 181 |
182 |

183 | Click here to add 184 | a new podcast to start downloading. 185 |

186 |
187 | {{end}} 188 | 189 |
190 |
191 | {{if .previousPage }} 192 | First 197 | {{end}} 198 | {{if .previousPage }} 199 | Previous 204 | {{end}} 205 | 206 | 212 | 213 | {{if .nextPage }} 214 | Next 219 | {{end}} 220 | {{if gt .totalPages .page }} 221 | 222 | Last 227 | {{end}} 228 |
229 |
230 |
231 | 232 | {{template "scripts"}} 233 | 359 | 389 | 390 | 391 | -------------------------------------------------------------------------------- /client/navbar.html: -------------------------------------------------------------------------------- 1 | {{define "navbar"}} 2 | 138 |
139 | 140 |
141 |
142 |
143 |
144 |

{{ .title }}

145 | {{if .podcastId }} 146 | 153 | 154 | 161 | 162 | 169 | {{end}} 170 |
171 | 183 | 184 |
185 | 193 |
194 |
195 |
196 | 197 |
198 |
199 |
200 | 201 | 219 | 220 | {{end}} -------------------------------------------------------------------------------- /client/podcast.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Episodes - PodGrab 8 | 9 | 127 | 128 | 129 |
130 |
131 | 132 |

{{ .title }}

133 |
134 | {{template "navbar"}} 135 |
136 | {{$setting := .setting}} 137 | {{range .podcast.PodcastItems}} 138 |
139 |
140 | {{ .Title }} 141 |
142 |
143 |

{{.Title}}

144 | {{ formatDate .PubDate }} 145 |

{{ .Summary }}

146 | 147 | {{if .DownloadPath}} 148 | Download 149 | {{else}} 150 | {{if not $setting.AutoDownload}} 151 | Download to Disk 152 | {{end}} 153 | {{end }} 154 | 155 |
156 |
157 | 158 |
159 | 160 |
161 |
162 | {{end}} 163 |
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 | 5 | 6 | PodGrab 7 | {{template "commoncss" .}} 8 | 29 | 30 | 31 |
32 | 33 | {{template "navbar" .}} 34 |
35 |
36 |
37 | Backups 38 |
39 | 42 | 45 |
46 | Rss Feed 47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |

Settings

55 | 59 |
60 | 61 |
62 | 63 | 68 |   69 | 70 |
71 | 72 | 76 | 77 | 81 | 82 | 86 | 87 | 91 | 95 | 99 | 103 | 107 | 111 | 115 | 116 | 117 |
118 |
119 |
120 |
121 |

Disk Stats

122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 |
Disk Used{{formatFileSize .diskStats.Downloaded}}
Pending Download{{ formatFileSize .diskStats.PendingDownload }}
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 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 |
Current Version 2022.07.07
Websitehttps://github.com/akhilrex/podgrab
Found a bugReport here
Feature RequestRequest here
Support the developerBuy him a beer!!
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 | {{.Title}} 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 [![GoDoc](https://godoc.org/github.com/kennygrant/sanitize?status.svg)](https://godoc.org/github.com/kennygrant/sanitize) [![Go Report Card](https://goreportcard.com/badge/github.com/kennygrant/sanitize)](https://goreportcard.com/report/github.com/kennygrant/sanitize) [![CircleCI](https://circleci.com/gh/kennygrant/sanitize.svg?style=svg)](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, "&amp; ", "& ", -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 --------------------------------------------------------------------------------