├── .gitattributes ├── .github ├── actions │ └── release │ │ └── action.yaml ├── release.yml └── workflows │ ├── deploy-pages.yaml │ ├── release.yaml │ ├── reviewdog.yaml │ ├── tagpr.yaml │ └── test.yaml ├── .gitignore ├── .tagpr ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── TODO.md ├── action.yml ├── cmd └── podbard │ └── main.go ├── cmd_build.go ├── cmd_episode.go ├── cmd_init.go ├── codecov.yml ├── docs └── ja │ ├── audio │ ├── 1.mp3 │ ├── 2.mp3 │ └── configuration.mp3 │ ├── episode │ ├── configuration.md │ ├── introduction.md │ └── quick-start.md │ ├── index.md │ ├── podbard.yaml │ ├── static │ ├── css │ │ └── style.css │ ├── favicon.ico │ └── images │ │ └── artwork.jpg │ └── template │ ├── _layout.tmpl │ ├── episode.tmpl │ └── index.tmpl ├── go.mod ├── go.sum ├── go.work.example ├── install.sh ├── internal └── cast │ ├── audio.go │ ├── builder.go │ ├── category.go │ ├── chapter.go │ ├── chapter_test.go │ ├── config.go │ ├── episode.go │ ├── feed.go │ ├── index.go │ ├── markdown.go │ ├── mediatype.go │ ├── mediatype_string.go │ ├── page.go │ ├── scaffold.go │ ├── template.go │ └── testdata │ └── init │ ├── README.md │ ├── audio │ └── sample.mp3 │ ├── episode │ └── sample.md │ ├── index.md │ ├── podbard.yaml │ ├── static │ ├── css │ │ └── style.css │ ├── favicon.ico │ └── images │ │ └── artwork.jpg │ └── template │ ├── _layout.tmpl │ ├── episode.tmpl │ └── index.tmpl ├── main_test.go ├── podbard.go ├── schema.yaml ├── scripts ├── check_mp3.go ├── go.mod ├── go.sum ├── rename.go └── text_to_speech.go ├── testdata └── dev │ ├── audio │ ├── 1.mp3 │ ├── 2.mp3 │ └── 3.mp3 │ ├── episode │ ├── 1.md │ ├── 2.md │ ├── 3.md │ └── 4.md │ ├── index.md │ ├── podbard.yaml │ ├── static │ ├── css │ │ └── style.css │ ├── favicon.ico │ └── images │ │ └── artwork.jpg │ └── template │ ├── _layout.tmpl │ ├── episode.tmpl │ └── index.tmpl ├── tools.go └── version.go /.gitattributes: -------------------------------------------------------------------------------- 1 | install.sh linguist-generated 2 | -------------------------------------------------------------------------------- /.github/actions/release/action.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | description: release podbard 3 | inputs: 4 | tag: 5 | description: tag name to be released 6 | default: '' 7 | token: 8 | description: GitHub token 9 | required: true 10 | runs: 11 | using: composite 12 | steps: 13 | - name: setup go 14 | uses: actions/setup-go@v5 15 | with: 16 | go-version: stable 17 | - name: release 18 | run: | 19 | make crossbuild upload 20 | shell: bash 21 | env: 22 | GITHUB_TOKEN: ${{ inputs.token }} 23 | - uses: haya14busa/action-update-semver@v1 24 | with: 25 | tag: ${{ inputs.tag }} 26 | major_version_tag_only: true 27 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - tagpr 5 | -------------------------------------------------------------------------------- /.github/workflows/deploy-pages.yaml: -------------------------------------------------------------------------------- 1 | name: deploy-pages 2 | on: 3 | push: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | jobs: 8 | build-deploy: 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | environment: 14 | name: github-pages 15 | url: ${{ steps.deployment.outputs.page_url }} 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/configure-pages@v5 20 | id: pages 21 | - uses: actions/setup-go@v5 22 | with: 23 | go-version: stable 24 | - name: build pages 25 | run: | 26 | make build 27 | ./podbard -C docs/ja build 28 | - uses: actions/upload-pages-artifact@v3 29 | with: 30 | path: docs/ja/public 31 | - uses: actions/deploy-pages@v4 32 | id: deployment 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+.[0-9]+.[0-9]+" 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: checkout 11 | uses: actions/checkout@v4 12 | - uses: ./.github/actions/release 13 | with: 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.github/workflows/reviewdog.yaml: -------------------------------------------------------------------------------- 1 | name: reviewdog 2 | on: [pull_request] 3 | jobs: 4 | staticcheck: 5 | name: staticcheck 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | with: 10 | persist-credentials: false 11 | - uses: reviewdog/action-staticcheck@v1 12 | with: 13 | reporter: github-pr-review 14 | fail_on_error: true 15 | misspell: 16 | name: misspell 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | persist-credentials: false 22 | - name: misspell 23 | uses: reviewdog/action-misspell@v1 24 | with: 25 | reporter: github-pr-review 26 | level: warning 27 | locale: "US" 28 | actionlint: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | with: 33 | persist-credentials: false 34 | - uses: reviewdog/action-actionlint@v1 35 | with: 36 | reporter: github-pr-review 37 | -------------------------------------------------------------------------------- /.github/workflows/tagpr.yaml: -------------------------------------------------------------------------------- 1 | name: tagpr 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | jobs: 7 | tagpr: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: setup go 11 | uses: actions/setup-go@v5 12 | with: 13 | go-version: stable 14 | - name: checkout 15 | uses: actions/checkout@v4 16 | - name: tagpr 17 | id: tagpr 18 | uses: Songmu/tagpr@v1 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | - uses: ./.github/actions/release 22 | with: 23 | tag: ${{ steps.tagpr.outputs.tag }} 24 | token: ${{ secrets.GITHUB_TOKEN }} 25 | if: "steps.tagpr.outputs.tag != ''" 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | jobs: 7 | test: 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | os: 13 | - ubuntu-latest 14 | - macOS-latest 15 | - windows-latest 16 | steps: 17 | - name: Set git to use LF 18 | run: | 19 | git config --global core.autocrlf false 20 | git config --global core.eol lf 21 | if: "matrix.os == 'windows-latest'" 22 | - name: checkout 23 | uses: actions/checkout@v4 24 | - name: setup go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: stable 28 | - name: test 29 | run: go test -race -coverprofile coverage.out -covermode atomic ./... 30 | - name: Upload coverage to Codecov 31 | uses: codecov/codecov-action@v4 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | public/ 2 | testdata/public 3 | 4 | go.work 5 | go.work.sum 6 | podbard 7 | 8 | testdata/dev/audio/.1.mp3.json 9 | -------------------------------------------------------------------------------- /.tagpr: -------------------------------------------------------------------------------- 1 | [tagpr] 2 | vPrefix = true 3 | releaseBranch = main 4 | versionFile = version.go,action.yml 5 | release = draft 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v0.0.16](https://github.com/Songmu/podbard/compare/v0.0.15...v0.0.16) - 2025-01-19 4 | - fix page handling by @Songmu in https://github.com/Songmu/podbard/pull/78 5 | - update deps by @Songmu in https://github.com/Songmu/podbard/pull/79 6 | 7 | ## [v0.0.15](https://github.com/Songmu/podbard/compare/v0.0.14...v0.0.15) - 2025-01-19 8 | - fix chapter handling with caring quotes by @Songmu in https://github.com/Songmu/podbard/pull/75 9 | 10 | ## [v0.0.14](https://github.com/Songmu/podbard/compare/v0.0.13...v0.0.14) - 2024-11-21 11 | - Fix typo. by @toohsk in https://github.com/Songmu/podbard/pull/72 12 | - support standalone pages by @Songmu in https://github.com/Songmu/podbard/pull/71 13 | 14 | ## [v0.0.13](https://github.com/Songmu/podbard/compare/v0.0.12...v0.0.13) - 2024-10-09 15 | - support chapter in frontmatter by @Songmu in https://github.com/Songmu/podbard/pull/69 16 | - configuration for private podcast by @Songmu in https://github.com/Songmu/podbard/pull/70 17 | 18 | ## [v0.0.12](https://github.com/Songmu/podbard/compare/v0.0.11...v0.0.12) - 2024-09-30 19 | - time element in chapters by @Songmu in https://github.com/Songmu/podbard/pull/67 20 | 21 | ## [v0.0.11](https://github.com/Songmu/podbard/compare/v0.0.10...v0.0.11) - 2024-09-26 22 | - fix interface around chapter by @Songmu in https://github.com/Songmu/podbard/pull/64 23 | 24 | ## [v0.0.10](https://github.com/Songmu/podbard/compare/v0.0.9...v0.0.10) - 2024-09-25 25 | - fix description again by @Songmu in https://github.com/Songmu/podbard/pull/62 26 | 27 | ## [v0.0.9](https://github.com/Songmu/podbard/compare/v0.0.8...v0.0.9) - 2024-09-25 28 | - fix CDATA by @Songmu in https://github.com/Songmu/podbard/pull/61 29 | 30 | ## [v0.0.8](https://github.com/Songmu/podbard/compare/v0.0.7...v0.0.8) - 2024-09-25 31 | - retrieve chapter data from mp3 by @Songmu in https://github.com/Songmu/podbard/pull/55 32 | - Display chapter information in episode by @Songmu in https://github.com/Songmu/podbard/pull/57 33 | - rename description to subtitle in episode by @Songmu in https://github.com/Songmu/podbard/pull/58 34 | - full text in RSS using description field by @Songmu in https://github.com/Songmu/podbard/pull/59 35 | 36 | ## [v0.0.7](https://github.com/Songmu/podbard/compare/v0.0.6...v0.0.7) - 2024-09-22 37 | - Enhance log messages by @Songmu in https://github.com/Songmu/podbard/pull/49 38 | - update README.md by @Songmu in https://github.com/Songmu/podbard/pull/51 39 | - Docs by @Songmu in https://github.com/Songmu/podbard/pull/52 40 | - introduce urfave/cli/v2 for flags by @Songmu in https://github.com/Songmu/podbard/pull/53 41 | - remove unused src and task by @Songmu in https://github.com/Songmu/podbard/pull/54 42 | 43 | ## [v0.0.6](https://github.com/Songmu/podbard/compare/v0.0.5...v0.0.6) - 2024-09-11 44 | - [feature] add --destination, --parents and --clear option to build subcommand by @Songmu in https://github.com/Songmu/podbard/pull/46 45 | - build by default in GitHub Actions by @Songmu in https://github.com/Songmu/podbard/pull/47 46 | - when destination is explicitly specified, it is used as is by @Songmu in https://github.com/Songmu/podbard/pull/48 47 | 48 | ## [v0.0.5](https://github.com/Songmu/podbard/compare/v0.0.4...v0.0.5) - 2024-09-07 49 | - [docs] update by @Songmu in https://github.com/Songmu/podbard/pull/41 50 | - add action.yml by @Songmu in https://github.com/Songmu/podbard/pull/43 51 | - update versioning setting by @Songmu in https://github.com/Songmu/podbard/pull/44 52 | 53 | ## [v0.0.4](https://github.com/Songmu/podbard/compare/v0.0.3...v0.0.4) - 2024-09-06 54 | - [docs] update artwork icon by @Songmu in https://github.com/Songmu/podbard/pull/38 55 | - [bugfix] locate main.go by @Songmu in https://github.com/Songmu/podbard/pull/40 56 | 57 | ## [v0.0.3](https://github.com/Songmu/podbard/compare/v0.0.2...v0.0.3) - 2024-09-06 58 | - [maint] adjust text_to_speech by @Songmu in https://github.com/Songmu/podbard/pull/33 59 | - [docs] quick start by @Songmu in https://github.com/Songmu/podbard/pull/34 60 | - [docs] update default css by @Songmu in https://github.com/Songmu/podbard/pull/35 61 | - [docs] update css by @Songmu in https://github.com/Songmu/podbard/pull/36 62 | - rename software name to podbard by @Songmu in https://github.com/Songmu/podbard/pull/37 63 | 64 | ## [v0.0.2](https://github.com/Songmu/podbard/compare/v0.0.1...v0.0.2) - 2024-09-05 65 | - introduce sprig to build index.md by @Songmu in https://github.com/Songmu/podbard/pull/30 66 | 67 | ## [v0.0.1](https://github.com/Songmu/podbard/commits/v0.0.1) - 2024-09-05 68 | - episode subcommand by @Songmu in https://github.com/Songmu/podbard/pull/1 69 | - fix template directory by @Songmu in https://github.com/Songmu/podbard/pull/2 70 | - handle static files by @Songmu in https://github.com/Songmu/podbard/pull/3 71 | - copy audio files to the build directory if there is no audio bucket setting by @Songmu in https://github.com/Songmu/podbard/pull/4 72 | - implement init subcommand by @Songmu in https://github.com/Songmu/podbard/pull/5 73 | - fix init by @Songmu in https://github.com/Songmu/podbard/pull/6 74 | - open EDITOR when creating episode by @Songmu in https://github.com/Songmu/podbard/pull/7 75 | - Don't prompt while directory is empty when init by @Songmu in https://github.com/Songmu/podbard/pull/8 76 | - locate empty css file by @Songmu in https://github.com/Songmu/podbard/pull/9 77 | - open episode file even when the file exists by @Songmu in https://github.com/Songmu/podbard/pull/10 78 | - define Episode.AudioURL by @Songmu in https://github.com/Songmu/podbard/pull/11 79 | - proper lastBuildDate by @Songmu in https://github.com/Songmu/podbard/pull/12 80 | - support sprig by @Songmu in https://github.com/Songmu/podbard/pull/13 81 | - update default footer by @Songmu in https://github.com/Songmu/podbard/pull/14 82 | - Default css by @Songmu in https://github.com/Songmu/podbard/pull/15 83 | - adjacent episodes by @Songmu in https://github.com/Songmu/podbard/pull/16 84 | - add required field and cleanup deprecated field in RSS by @Songmu in https://github.com/Songmu/podbard/pull/17 85 | - use audio tag by @Songmu in https://github.com/Songmu/podbard/pull/18 86 | - dummy favicon by @Songmu in https://github.com/Songmu/podbard/pull/19 87 | - update default style by @Songmu in https://github.com/Songmu/podbard/pull/20 88 | - episode --no-edit option by @Songmu in https://github.com/Songmu/podbard/pull/21 89 | - define MediaType by @Songmu in https://github.com/Songmu/podbard/pull/22 90 | - episode --ignore-missing option by @Songmu in https://github.com/Songmu/podbard/pull/23 91 | - get episode body via Stdin in subcommand episode by @Songmu in https://github.com/Songmu/podbard/pull/24 92 | - --save-meta option by @Songmu in https://github.com/Songmu/podbard/pull/25 93 | - update README.md by @Songmu in https://github.com/Songmu/podbard/pull/26 94 | - text-to-speech by @Songmu in https://github.com/Songmu/podbard/pull/27 95 | - Add document site by @Songmu in https://github.com/Songmu/podbard/pull/29 96 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Songmu 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION = $(shell godzil show-version) 2 | CURRENT_REVISION = $(shell git rev-parse --short HEAD) 3 | BUILD_LDFLAGS = "-s -w -X github.com/Songmu/podbard.revision=$(CURRENT_REVISION)" 4 | u := $(if $(update),-u) 5 | 6 | .PHONY: doc 7 | doc: 8 | exec go run cmd/podbard/main.go -C docs/ja episode --ignore-missing --slug ${doc} ${doc}.mp3 9 | 10 | .PHONY: deps 11 | deps: 12 | go get ${u} 13 | go mod tidy 14 | 15 | .PHONY: devel-deps 16 | devel-deps: 17 | go install github.com/Songmu/godzil/cmd/godzil@latest 18 | go install github.com/tcnksm/ghr@latest 19 | 20 | .PHONY: test 21 | test: 22 | go test 23 | 24 | .PHONY: build 25 | build: 26 | go build -ldflags=$(BUILD_LDFLAGS) ./cmd/podbard 27 | 28 | .PHONY: install 29 | install: 30 | go install -ldflags=$(BUILD_LDFLAGS) ./cmd/podbard 31 | 32 | .PHONY: release 33 | release: devel-deps 34 | godzil release 35 | 36 | CREDITS: go.sum deps devel-deps 37 | godzil credits -w 38 | 39 | DIST_DIR = dist/v$(VERSION) 40 | .PHONY: crossbuild 41 | crossbuild: CREDITS 42 | rm -rf $(DIST_DIR) 43 | godzil crossbuild -pv=v$(VERSION) -build-ldflags=$(BUILD_LDFLAGS) \ 44 | -os=linux,darwin -d=$(DIST_DIR) ./cmd/* 45 | cd $(DIST_DIR) && shasum -a 256 $$(find * -type f -maxdepth 0) > SHA256SUMS 46 | 47 | .PHONY: upload 48 | upload: 49 | ghr -body="$$(godzil changelog --latest -F markdown)" v$(VERSION) dist/v$(VERSION) 50 | 51 | 52 | .PHONY: sync 53 | sync: 54 | cp testdata/dev/index.md internal/cast/testdata/init/index.md 55 | cp testdata/dev/static/css/style.css internal/cast/testdata/init/static/css/style.css 56 | cp testdata/dev/static/css/style.css docs/ja/static/css/style.css 57 | cp -r testdata/dev/template/ internal/cast/testdata/init/template/ 58 | cp -r testdata/dev/static/ internal/cast/testdata/init/static/ 59 | 60 | .PHONY: sync-starter 61 | sync-starter: 62 | cp -r internal/cast/testdata/init/template/ ../podbard-starter/template/ 63 | cp -r internal/cast/testdata/init/static/ ../podbard-starter/static/ 64 | cp internal/cast/testdata/init/index.md ../podbard-starter/index.md 65 | 66 | cp -r internal/cast/testdata/init/template/ ../podbard-cloudflare-starter/template/ 67 | cp -r internal/cast/testdata/init/static/ ../podbard-cloudflare-starter/static/ 68 | cp internal/cast/testdata/init/index.md ../podbard-cloudflare-starter/index.md 69 | 70 | cp -r internal/cast/testdata/init/template/ ../podbard-private-podcast-starter/template/ 71 | cp -r internal/cast/testdata/init/static/ ../podbard-private-podcast-starter/static/ 72 | cp internal/cast/testdata/init/index.md ../podbard-private-podcast-starter/index.md 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Podbard 2 | ======= 3 | 4 | [![Test Status](https://github.com/Songmu/podbard/workflows/test/badge.svg?branch=main)][actions] 5 | [![Coverage Status](https://codecov.io/gh/Songmu/podbard/branch/main/graph/badge.svg)][codecov] 6 | [![MIT License](https://img.shields.io/github/license/Songmu/podbard)][license] 7 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/Songmu/podbard)][PkgGoDev] 8 | 9 | [actions]: https://github.com/Songmu/podbard/actions?workflow=test 10 | [codecov]: https://codecov.io/gh/Songmu/podbard 11 | [license]: https://github.com/Songmu/podbard/blob/main/LICENSE 12 | [PkgGoDev]: https://pkg.go.dev/github.com/Songmu/podbard 13 | 14 | The Podbard is a primitive podcast site generator. 15 | 16 | ![](docs/ja/static/images/artwork.jpg) 17 | 18 | [Document site (Japanese)](https://junkyard.song.mu/podbard/) 19 | 20 | ## Synopsis 21 | 22 | ```console 23 | # Initialize the site 24 | $ podbard init . 25 | ✨ Initialized your brand new podcast project under "." directory 26 | 27 | # Locate the audio file and create a new episode page 28 | $ podbard episode audio/1.mp3 29 | 🔍 The episode file "episode/1.md" corresponding to the "1.mp3" was created. 30 | 31 | # Build the site 32 | $ podbard build 33 | 🔨 Generating a site under the "public" directrory 34 | 🎤 Your podcast site has been generated and is ready to cast. 35 | ``` 36 | 37 | ## Description 38 | 39 | The podbard is software that generates a minimum podcast sites from a list of audio files. 40 | 41 | ## Template Repository 42 | 43 | - 44 | - GitHub Pages 45 | - 46 | - Cloudflare Pages + R2 47 | - 48 | - Cloudflare Pages + R2 (Private Podcast) 49 | 50 | You can start a new podcast site by using the template repository without installing the `podbard`. 51 | 52 | ## Installation 53 | 54 |
55 | How to install on terminal 56 | 57 | ```console 58 | # Homebrew 59 | % brew install Songmu/tap/podbard 60 | 61 | # Install the latest version. (Install it into ./bin/ by default). 62 | % curl -sfL https://raw.githubusercontent.com/Songmu/podbard/main/install.sh | sh -s 63 | 64 | # Specify installation directory ($(go env GOPATH)/bin/) and version. 65 | % curl -sfL https://raw.githubusercontent.com/Songmu/podbard/main/install.sh | sh -s -- -b $(go env GOPATH)/bin [vX.Y.Z] 66 | 67 | # In alpine linux (as it does not come with curl by default) 68 | % wget -O - -q https://raw.githubusercontent.com/Songmu/podbard/main/install.sh | sh -s [vX.Y.Z] 69 | 70 | # go install 71 | % go install github.com/Songmu/podbard/cmd/podbard@latest 72 | ``` 73 |
74 | 75 | ## Directory Structure 76 | 77 | - **index.md** 78 | - index page 79 | - **podbard.yaml** 80 | - configuration file 81 | - **episode/** 82 | - episode pages in markdown 83 | - **audio/** 84 | - audio files (mp3 or m4a) 85 | - **template/** 86 | - template files (tmpl files in Go's text/template syntax) 87 | - **static/** 88 | - static files 89 | 90 | ## Sub Commmands 91 | 92 | ### init 93 | 94 | ```console 95 | $ podbard init . 96 | ``` 97 | 98 | ### episode 99 | 100 | ``` 101 | $ podbard episode [-slug=hoge -date=2024-09-01 -title=title] audio/1.mp3 102 | ``` 103 | 104 | create a new epoisode page with the specified audio file. 105 | 106 | ### build 107 | 108 | ``` 109 | $ podbard build 110 | ``` 111 | 112 | build the site and output to the `public` directory. 113 | 114 | ## Author 115 | 116 | [Songmu](https://github.com/Songmu) 117 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | ## Must 4 | - [ ] Document 5 | - [ ] Testing 6 | 7 | ## Should 8 | - [ ] Refine RSS Feed 9 | - [ ] support major RSS 2.0 tags 10 | - [ ] taxonomy 11 | 12 | ## Nice to have 13 | - [ ] "params" property in frontmatter 14 | - [ ] subcommand `dev` to run a local server 15 | - [ ] Templatize the `episode/*.md` files when build 16 | - [ ] og:image (eyecatch, artwork) 17 | - [ ] configurable in a episode Markdown 18 | - [ ] doctor/lint subocommand like `brew doctor` to check settings 19 | - [ ] check required files 20 | - [ ] check config fields 21 | - [ ] check episode files to check required fields 22 | - [ ] check the audio existence 23 | - [ ] buildable 24 | - [ ] template 25 | - [ ] warning when the sample files are still there 26 | - [ ] configurable episode markdown template 27 | - [ ] if the destination is specified, don't we consider the rootDir? 28 | - [ ] draft 29 | 30 | ## Never do 31 | - configure Google Analytics, Google Tag Manager, etc. 32 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: The podbard 2 | author: Songmu 3 | description: Install podbard and build 4 | inputs: 5 | setup: 6 | description: "Just setup podbard without build" 7 | directory: 8 | description: "A directory to build" 9 | default: "." 10 | destination: 11 | description: "A destinaion to build your site" 12 | parents: 13 | description: "Whether to dig the site's path structure as a parent directory under destination." 14 | version: 15 | description: "A version to install podbard" 16 | default: "v0.0.16" 17 | runs: 18 | using: "composite" 19 | steps: 20 | - run: | 21 | TEMP_PATH="$(mktemp -d)" 22 | curl -sfL https://raw.githubusercontent.com/Songmu/podbard/main/install.sh | sh -s -- -b "${TEMP_PATH}" "${{ inputs.version }}" 2>&1 23 | sudo mv ${TEMP_PATH}/podbard /usr/local/bin/podbard 24 | rm -rf ${TEMP_PATH} 25 | 26 | if [ "${{ inputs.setup }}" = "true" ]; then 27 | exit 0 28 | fi 29 | 30 | parents="" 31 | if [ "${{ inputs.parents }}" = "true" ]; then 32 | parents="--parents" 33 | fi 34 | 35 | destination="" 36 | if [ -n "${{ inputs.destination }}" ]; then 37 | destination="--destination ${inputs.destination}" 38 | fi 39 | 40 | podbard -C "${{ inputs.directory }}" build ${destination} ${parents} 41 | 42 | shell: bash 43 | -------------------------------------------------------------------------------- /cmd/podbard/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | "os" 8 | 9 | "github.com/Songmu/podbard" 10 | ) 11 | 12 | func main() { 13 | log.SetFlags(0) 14 | err := podbard.Run(context.Background(), os.Args[1:], os.Stdout, os.Stderr) 15 | if err != nil && err != flag.ErrHelp { 16 | log.Println(err) 17 | exitCode := 1 18 | if ecoder, ok := err.(interface{ ExitCode() int }); ok { 19 | exitCode = ecoder.ExitCode() 20 | } 21 | os.Exit(exitCode) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /cmd_build.go: -------------------------------------------------------------------------------- 1 | package podbard 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/Songmu/podbard/internal/cast" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | var commandBuild = &cli.Command{ 12 | Name: "build", 13 | Usage: "build the podcast", 14 | Flags: []cli.Flag{ 15 | &cli.StringFlag{ 16 | Name: "destination", 17 | Usage: "destination of the build", 18 | }, 19 | &cli.BoolFlag{ 20 | Name: "parents", 21 | Usage: "make parent directories as needed", 22 | }, 23 | &cli.BoolFlag{ 24 | Name: "clear", 25 | Usage: "clear destination before build", 26 | }, 27 | }, 28 | Action: func(c *cli.Context) error { 29 | rootDir := c.String("C") 30 | 31 | dest := c.String("destination") 32 | parents := c.Bool("parents") 33 | doClear := c.Bool("clear") 34 | 35 | cfg, err := cast.LoadConfig(rootDir) 36 | if err != nil { 37 | return err 38 | } 39 | episodes, err := cast.LoadEpisodes( 40 | rootDir, cfg.Channel.Link.URL, cfg.AudioBucketURL.URL, cfg.Location()) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | generator := fmt.Sprintf("github.com/Songmu/podbard %s", version) 46 | buildDate := time.Now() 47 | return cast.Build(cfg, episodes, rootDir, generator, dest, parents, doClear, buildDate) 48 | }, 49 | } 50 | -------------------------------------------------------------------------------- /cmd_episode.go: -------------------------------------------------------------------------------- 1 | package podbard 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "time" 11 | 12 | "github.com/Songmu/go-httpdate" 13 | "github.com/Songmu/podbard/internal/cast" 14 | "github.com/mattn/go-isatty" 15 | "github.com/urfave/cli/v2" 16 | ) 17 | 18 | var commandEpisode = &cli.Command{ 19 | Name: "episode", 20 | Usage: "manage episodes", 21 | Flags: []cli.Flag{ 22 | &cli.StringFlag{ 23 | Name: "slug", 24 | Usage: "slug of the episode", 25 | }, 26 | &cli.StringFlag{ 27 | Name: "date", 28 | Usage: "date of the episode", 29 | }, 30 | &cli.StringFlag{ 31 | Name: "title", 32 | Usage: "title of the episode", 33 | }, 34 | &cli.StringFlag{ 35 | Name: "subtitle", 36 | Usage: "subtitle of the episode", 37 | }, 38 | &cli.BoolFlag{ 39 | Name: "no-edit", 40 | Usage: "do not open the editor", 41 | }, 42 | &cli.BoolFlag{ 43 | Name: "ignore-missing", 44 | Usage: "ignore missing audio file", 45 | }, 46 | &cli.BoolFlag{ 47 | Name: "save-meta", 48 | Usage: "save meta file of audio", 49 | }, 50 | }, 51 | Action: func(c *cli.Context) error { 52 | rootDir := c.String("C") 53 | 54 | args := c.Args().Slice() 55 | if len(args) < 1 { 56 | return errors.New("no audio files specified") 57 | } 58 | audioFiles := args 59 | 60 | slug := c.String("slug") 61 | date := c.String("date") 62 | title := c.String("title") 63 | subtitle := c.String("subtitle") 64 | noEdit := c.Bool("no-edit") 65 | ignoreMissing := c.Bool("ignore-missing") 66 | saveMeta := c.Bool("save-meta") 67 | 68 | cfg, err := cast.LoadConfig(rootDir) 69 | if err != nil { 70 | return err 71 | } 72 | loc := cfg.Location() 73 | var pubDate time.Time 74 | if date != "" { 75 | var err error 76 | pubDate, err = httpdate.Str2Time(date, loc) 77 | if err != nil { 78 | return err 79 | } 80 | } 81 | var body string 82 | if !isTTY(os.Stdin) { 83 | b, err := io.ReadAll(os.Stdin) 84 | if err != nil { 85 | return err 86 | } 87 | body = string(b) 88 | } 89 | editor := os.Getenv("EDITOR") 90 | if editor == "" || noEdit || !isTTY(os.Stdin) || !isTTY(os.Stdout) || !isTTY(os.Stderr) { 91 | editor = "" 92 | } 93 | for _, audioFile := range audioFiles { 94 | fpath, isNew, err := cast.LoadEpisode( 95 | rootDir, audioFile, body, ignoreMissing, saveMeta, pubDate, slug, title, subtitle, loc) 96 | if err != nil { 97 | return err 98 | } 99 | if isNew { 100 | log.Printf("📝 The episode file %q corresponding to the %q was created.\n", fpath, audioFile) 101 | } else { 102 | log.Printf("🔍 The episode file %q corresponding to the %q was found.\n", fpath, audioFile) 103 | } 104 | fmt.Fprintln(c.App.Writer, fpath) 105 | 106 | if editor != "" { 107 | com := exec.Command(editor, fpath) 108 | com.Stdin = os.Stdin 109 | com.Stdout = os.Stdout 110 | com.Stderr = os.Stderr 111 | 112 | if err := com.Run(); err != nil { 113 | return err 114 | } 115 | } 116 | } 117 | return nil 118 | }, 119 | } 120 | 121 | func isTTY(f *os.File) bool { 122 | fd := f.Fd() 123 | return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) 124 | } 125 | -------------------------------------------------------------------------------- /cmd_init.go: -------------------------------------------------------------------------------- 1 | package podbard 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/Songmu/podbard/internal/cast" 10 | "github.com/Songmu/prompter" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | var commandInit = &cli.Command{ 15 | Name: "init", 16 | Usage: "initialize podbard", 17 | Action: func(c *cli.Context) error { 18 | args := c.Args().Slice() 19 | if len(args) < 1 { 20 | return errors.New("no target directories specified") 21 | } 22 | if len(args) > 1 { 23 | log.Printf("[warn] two or more arguments are specified and they will be ignored: %v", args[1:]) 24 | } 25 | dir := args[0] 26 | 27 | if _, err := os.Stat(dir); err == nil { 28 | entries, err := os.ReadDir(dir) 29 | if err != nil { 30 | return err 31 | } 32 | if len(entries) > 0 && 33 | !prompter.YN(fmt.Sprintf("directory %q already exist. Do you continue to init?", dir), false) { 34 | 35 | return nil 36 | } 37 | } 38 | return cast.Scaffold(dir) 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | github_checks: false 3 | -------------------------------------------------------------------------------- /docs/ja/audio/1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Songmu/podbard/7c911caa5253e989c409bfe5a87dbdca5bab93b9/docs/ja/audio/1.mp3 -------------------------------------------------------------------------------- /docs/ja/audio/2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Songmu/podbard/7c911caa5253e989c409bfe5a87dbdca5bab93b9/docs/ja/audio/2.mp3 -------------------------------------------------------------------------------- /docs/ja/audio/configuration.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Songmu/podbard/7c911caa5253e989c409bfe5a87dbdca5bab93b9/docs/ja/audio/configuration.mp3 -------------------------------------------------------------------------------- /docs/ja/episode/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | audio: configuration.mp3 3 | title: 設定ファイル 4 | date: 2024-09-15T01:02:35+09:00 5 | description: 設定ファイル 6 | --- 7 | 8 | # 設定ファイル 9 | 10 | `podbard.yaml` の設定項目については を参照してください。 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs/ja/episode/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | audio: 1.mp3 3 | title: podbardの紹介 4 | date: 2024-09-06T00:49:39+09:00 5 | description: podbardの紹介と導入方法、使い方について解説します。 6 | --- 7 | 8 | ## podbardとは 9 | podbardは簡単にポッドキャストサイトを作るためのSite Generatorです。コマンドラインツールで、オーディオファイルを元にエピソードのMarkdownを生成し、最終的には静的なサイトを生成します。とにかく音源があれば、すぐにポッドキャストサイトを作成できます。 10 | 11 | ### 特色 12 | - **手軽**: コマンドラインツールをインストール後、即始められます 13 | - **簡潔**: 覚えることが少なく、すぐに使いこなせます 14 | - **俊敏**: オーディオファイルを配置するだけでサイトが生成できます 15 | - **自由**: 生成内容を自由にカスタマイズできます 16 | - **ポータブル**: 静的サイトなので、ポッドキャスト配信サービスを使わずどこでもホスティングできます 17 | - **仕様準拠**: ポッドキャスト仕様に準拠したRSSフィードを生成するため、あらゆるポッドキャストアプリで購読できます 18 | - **OSS**: MITライセンスで公開されているため、自由に利用できます 19 | 20 | ### コンセプト 21 | podbardは、ポッドキャストを独自サイトで配信したい人が、ポッドキャストを簡単に始められるようにするソフトウェアです。 22 | 23 | 音声ファイルを `audio/` ディレクトリに配置し、それに対応するエピソードファイルをMarkdown形式で `episode/` ディレクトリに配置するだけで、ポッドキャストサイトを生成できます。エピソードファイルは自動生成可能なので、最低限音声ファイルさえ配置すれば、すぐに最小構成のサイトを構築できます。 24 | 25 | コンセプトは[Yattecast](https://r7kamura.github.io/yattecast/)に近いですが、podbardはGoで書かれており、コマンドラインツールを使ってサイトを再生する点が異なります。ゆくゆくはテンプレートリポジトリやGitHub Actionsを用意するかもしれません。 26 | 27 | ## 導入方法 28 | 29 | Homebrewやgo installを使って `podbard` コマンドをインストールします。 30 | 31 | ```console 32 | # Homebrew 33 | $ brew install Songmu/tap/podbard 34 | 35 | # go install 36 | $ go install github.com/Songmu/podbard/cmd/podbard@latest 37 | ``` 38 | 39 | ## 使い方 40 | 41 | 大まかな流れは以下のとおりです。`audio/` ディレクトリにオーディオファイルを配置するだけですぐにサイトの生成ができます。 42 | 43 | 1. サイト構築: `podbard init ` でサイトの雛形を作成します 44 | 2. 設定ファイル: `podbard.yaml` をあなたのサイトに合わせて編集します 45 | 3. 音声配置: `audio/` ディレクトリにオーディオファイルを配置します 46 | 4. エピーソードファイル作成: `podbard episode audio/1.mp3` コマンドでエピソードのMarkdownを生成します 47 | 5. サイトのビルド: `podbard build` コマンドでサイトをビルドします 48 | 49 | ### 1. サイト構築 50 | `podbard init sitename` でサイトの雛形を作成します。雛形のディレクトリには以下のファイルが作成されます。 51 | 52 | - **podbard.yaml** 53 | - 設定ファイル 54 | - **index.md** 55 | - トップページ用のMarkdownファイル 56 | - **episode/** 57 | - エピソードのMarkdownファイル格納ディレクトリ 58 | - **audio/** 59 | - MP3やM4Aファイル格納ディレクトリ 60 | - **template/** 61 | - Goのhtml/template形式のテンプレートファイル格納ディレクトリ 62 | - **static/** 63 | - 静的ファイル格納ディレクトリ 64 | 65 | #### テンプレートリポジトリ 66 | 67 | `podbard init` コマンドを使わず、以下のテンプレートリポジトリを使うこともできます。これらは、GitHub Actionsの雛形も含まれているので便利です。 68 | 69 | - 70 | - GitHub Pages 71 | - 72 | - Cloudflare Pages + R2 73 | - 74 | - Cloudflare Pages + R2 (Private Podcast) 75 | 76 | ### 2. 設定ファイル 77 | `podbard.yaml` を開いて適宜調整してください。`artwork`にはダミーの画像が指定されているので、それは適切な画像に差し替えてください。正方形で1400×1400px以上のJPEGまたはPNGファイルを指定してください。 78 | 79 | ### 3. 音声配置 80 | `audio/` ディレクトリにオーディオファイルを配置します。ファイル形式は、MP3またはM4Aをサポートしています。 81 | 82 | ### 4. エピソードファイル作成 83 | 4で配置したオーディオに対応するエピソードのMarkdownファイルを作成します。例えば、`podbard episode audio/1.mp3` で1.mp3に対応するエピソードのMarkdownを生成します。エピソードファイルは、`episode/` ディレクトリに保存され、この場合は `episode/1.md` に保存されます。Markdown内には、エピソードのタイトルや説明、公開日時などの必要最低限の情報に加え、本文にShow Noteを記述できます。 84 | 85 | ### 5. サイトのビルド 86 | サイトのビルドは `podbard build` コマンドで行います。ビルドされたファイルは `public/` ディレクトリに出力されます。このディレクトリを適切なホスティング環境にdeployすることで、ポッドキャストサイトが完成します。 87 | 88 | ## まとめ 89 | podbardは、`audio/` ディレクトリに配置された音声ファイルから簡単にポッドキャストサイトを作成することができるツールです。ここでは最低限の使い方をざっと説明しましたが、詳細な使い方や設定方法については次回以降に解説していきます。 90 | -------------------------------------------------------------------------------- /docs/ja/episode/quick-start.md: -------------------------------------------------------------------------------- 1 | --- 2 | audio: 2.mp3 3 | title: 最速でポッドキャスサイトを構築する 4 | date: 2024-09-06T18:00:00+09:00 5 | description: podbardを使って最速でポッドキャスサイトを構築する方法を解説します。 6 | --- 7 | 8 | このページでは、podbardで最短でポッドキャスサイトを構築する方法を説明します。 9 | 10 | ## インストール 11 | まずは、`podbard` コマンドをインストールします。Homebrewまたはgo installでインストールできます。 12 | 13 | ```console 14 | # Homebrew 15 | $ brew install Songmu/tap/podbard 16 | 17 | # go install 18 | $ go install github.com/Songmu/podbard/cmd/podbard@latest 19 | ``` 20 | 21 | ## サイトの雛形作成 22 | 次に、サイトの雛形を作成します。 23 | 24 | ```console 25 | $ podbard init 26 | ``` 27 | 28 | #### テンプレートリポジトリ 29 | 30 | `podbard init` コマンドを使わず、以下のテンプレートリポジトリを使うこともできます。これらは、GitHub Actionsの雛形も含まれているので、サイトのデプロイまでも簡単に行えます。 31 | 32 | - 33 | - GitHub Pages 34 | - 35 | - Cloudflare Pages + R2 36 | - 37 | - Cloudflare Pages + R2 (Private Podcast) 38 | 39 | ## 雛形の調整 40 | 41 | ### 設定ファイル `podbard.yaml` 42 | `podbard.yaml` を開いて適宜調整してください。コメントアウトされていない項目は必須か推奨項目です。artwork指定は消しても大丈夫ですが、Apple Podcastsに登録する場合は必須です。 43 | 44 | ### 不要なサンプルファイルの削除及び差し替え 45 | `audio/sample.mp3`, `episode/1.md` は不要なので削除してください。また、`static/images/artwork.jpg` はダミーの画像なので、適切な画像に差し替えてください。 46 | 47 | ## 音声ファイルの配置 48 | `audio/` ディレクトリ直下に配信する音声ファイルを配置してください。MP3またはM4Aをサポートしています。`audio/abc/` のようなサブディレクトリ階層はサポートしていないので、直下にフラットに配置してください。 49 | 50 | ## エピソードの作成 51 | 音声に対応するエピソードファイルを作成します。エピソードファイルは、`episode/` ディレクトリ直下にMarkdownファイル形式で保存します。このファイルは、`podbard episode` サブコマンドで以下のようにベースを生成できます。 52 | 53 | ```console 54 | $ podbard episode audio/1.mp3 55 | ``` 56 | 57 | このとき、`episode/1.md`という以下のようなMarkdownファイルが生成されます。 58 | 59 | ```markdown 60 | --- 61 | audio: 1.mp3 62 | title: "1" 63 | date: 2024-09-06T21:29:28+09:00 64 | description: "1" 65 | --- 66 | 67 | 68 | ``` 69 | 70 | ターミナル操作の場合にはエディタが自動起動して編集まで行えます。エディタを自動起動しない `--no-edit` オプションもあります。`episode`サブコマンドにはその他多くのオプションがありますが、ここでは取り上げません。 71 | 72 | このファイルを適宜編集してエピソードの情報を記述してください。本文部分がShow Noteになります。 73 | 74 | ## サイトのビルド 75 | あとは、サイトをビルドするだけです。サイトのビルドは `podbard build` コマンドで行います。 76 | 77 | ```console 78 | $ podbard build 79 | ``` 80 | 81 | サイトは `public/` ディレクトリに出力されます。このディレクトリを適切なホスティング環境にdeployすることで、ポッドキャストサイトが完成します。 82 | 83 | このサイト自体もpodbardで作られており、GitHub ActionsでビルドしてGitHub Pagesへdeployしています。[具体的なワークフローの設定](https://github.com/Songmu/podbard/blob/main/.github/workflows/deploy-pages.yaml)も参考にしてください。 84 | 85 | ## まとめ 86 | 87 | ここでは、必要最小限でサイトを構築する方法を説明しました。詳細な使い方やカスタマイズ方法、様々なユースケースの対応については、次回以降に解説していきます。 88 | -------------------------------------------------------------------------------- /docs/ja/index.md: -------------------------------------------------------------------------------- 1 | # {{.Channel.Title}} 2 | 3 | {{.Channel.Description}} 4 | 5 | ## RSS Feed 6 | 7 | 8 | ## Episodes 9 | {{range .Episodes | reverse -}} 10 | - [{{.Title}}]({{.URL.Path}}) ({{.PubDate.Format "2006-01-02 15:04"}}) 11 | {{end}} 12 | -------------------------------------------------------------------------------- /docs/ja/podbard.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Songmu/podbard/refs/heads/main/schema.yaml 2 | 3 | # sample 4 | channel: 5 | link: https://junkyard.song.mu/podbard/ 6 | title: podbard 7 | description: Podcast site generator, podbardの紹介 8 | language: ja-JP 9 | category: "Technology" 10 | author: Songmu 11 | email: y.songmu@gmail.com 12 | artwork: "images/artwork.jpg" 13 | 14 | timezone: Asia/Tokyo 15 | -------------------------------------------------------------------------------- /docs/ja/static/css/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #5a3d31; 3 | --secondary-color: #f4f4f4; 4 | --highlight-color: #e9d9c7; 5 | --background-color: #f5f5f0; 6 | --text-color: #333; 7 | --border-color: #c4c4b7; 8 | --light-border-color: #ccc; 9 | --block-background: #fff8e7; 10 | --blockquote-bg: #f5f5f0; 11 | --blockquote-border: 4px solid var(--primary-color); 12 | --padding: 1em 1.25em; 13 | } 14 | 15 | body { 16 | font-family: "Helvetica Neue", Arial, "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo, sans-serif; 17 | line-height: 1.7; 18 | color: var(--text-color); 19 | background-color: var(--background-color); 20 | margin: 0; 21 | padding: 0; 22 | 23 | a { 24 | color: var(--primary-color); 25 | text-decoration: none; 26 | 27 | &:hover { 28 | text-decoration: underline; 29 | } 30 | } 31 | 32 | header { 33 | background-color: var(--primary-color); 34 | color: var(--secondary-color); 35 | padding: var(--padding); 36 | 37 | h1 { 38 | margin: 0; 39 | font-size: 1.9em; 40 | font-weight: bold; 41 | a { 42 | color: var(--secondary-color); 43 | 44 | &:hover { 45 | text-decoration: none; 46 | } 47 | } 48 | } 49 | 50 | p { 51 | margin: 0.3em 0 0; 52 | font-size: 1em; 53 | color: #e0d6d1; 54 | } 55 | } 56 | 57 | main { 58 | max-width: 80em; 59 | padding: var(--padding); 60 | border: 1px solid var(--border-color); 61 | margin: auto; 62 | background-color: var(--block-background); 63 | 64 | h1 { font-size: 2.00em; margin-bottom: 0.5em; } 65 | h2 { font-size: 1.68em; margin-bottom: 0.5em; } 66 | h3 { font-size: 1.41em; margin-bottom: 0.5em; } 67 | h4 { font-size: 1.18em; margin-bottom: 0.5em; } 68 | h5, h6 { font-size: 1em; margin-bottom: 0.5em; } 69 | h6 { font-weight: normal; } 70 | p { font-size: 1em; margin-bottom: 0.7em; } 71 | 72 | input { 73 | border: 1px solid var(--border-color); 74 | padding: 0.5em; 75 | font-size: 1em; 76 | width: 100%; 77 | max-width: 40em; 78 | margin-bottom: 1em; 79 | background-color: #fff; 80 | } 81 | 82 | figure { margin: 1.5em 0; text-align: center; } 83 | audio { width: 100%; max-width: 600px; } 84 | 85 | code { 86 | background-color: #f4f4f4; 87 | border: 1px solid var(--light-border-color); 88 | padding: 0.2em 0.4em; 89 | border-radius: 3px; 90 | font-family: Consolas, Monaco, 'Courier New', monospace; 91 | font-size: 0.95em; 92 | } 93 | 94 | pre { 95 | background-color: #f9f9f9; 96 | border: 1px solid var(--light-border-color); 97 | padding: 1em; 98 | overflow-x: auto; 99 | border-radius: 5px; 100 | max-width: 100%; 101 | 102 | code { 103 | background: none; 104 | border: none; 105 | padding: 0; 106 | font-size: 1em; 107 | color: var(--text-color); 108 | } 109 | } 110 | 111 | blockquote { 112 | background-color: var(--blockquote-bg); 113 | border-left: var(--blockquote-border); 114 | margin: 0.7em 0; 115 | padding: 0.75em 1.5em; 116 | font-style: italic; 117 | color: var(--text-color); 118 | 119 | p { 120 | margin: 0; 121 | font-size: 1em; 122 | } 123 | } 124 | 125 | table { 126 | width: 100%; 127 | border-collapse: collapse; 128 | margin-bottom: 1.5em; 129 | background-color: var(--block-background); 130 | 131 | th, td { 132 | border: 1px solid var(--border-color); 133 | padding: 0.75em; 134 | text-align: left; 135 | font-size: 1em; 136 | color: var(--text-color); 137 | } 138 | 139 | th { background-color: var(--highlight-color); font-weight: bold; } 140 | td { background-color: #fff; } 141 | tr:nth-child(even) td { background-color: var(--background-color); } 142 | } 143 | 144 | dl { 145 | margin-bottom: 1.5em; 146 | 147 | dt { 148 | font-weight: bold; 149 | margin-top: 0.5em; 150 | color: #3b2f2f; 151 | } 152 | 153 | dd { 154 | margin-left: 1.5em; 155 | margin-bottom: 0.5em; 156 | font-size: 1em; 157 | color: var(--text-color); 158 | } 159 | } 160 | 161 | aside.post-meta { 162 | background-color: var(--highlight-color); 163 | padding: 0.7em; 164 | margin-top: 1.25em; 165 | border-right: 4px solid var(--primary-color); 166 | text-align: right; 167 | } 168 | 169 | nav.adjacent-episodes ul { 170 | list-style: none; 171 | padding: 0; 172 | margin: 1em 0; 173 | text-align: right; 174 | 175 | li { 176 | display: inline; 177 | padding: 0 0.7em; 178 | &:nth-child(n+2) { border-left: 1px solid var(--primary-color); } 179 | 180 | a[rel="prev"]::before { content: '\02190 '; } /* larr */ 181 | a[rel="next"]::after { content: ' \02192'; } /* rarr */ 182 | } 183 | } 184 | } 185 | 186 | footer { 187 | background-color: var(--primary-color); 188 | color: white; 189 | padding: var(--padding); 190 | text-align: right; 191 | margin-top: 2em; 192 | 193 | section h2 { 194 | font-size: 1.18em; 195 | padding: 0; 196 | margin: 0; 197 | } 198 | 199 | ul { 200 | list-style: none; 201 | padding: 0; 202 | margin: 0; 203 | li { display: inline; margin-right: 0.7em; } 204 | } 205 | 206 | a, ul li a { color: #e0d6d1; } 207 | small { display: block; margin-top: 0.7em; } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /docs/ja/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Songmu/podbard/7c911caa5253e989c409bfe5a87dbdca5bab93b9/docs/ja/static/favicon.ico -------------------------------------------------------------------------------- /docs/ja/static/images/artwork.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Songmu/podbard/7c911caa5253e989c409bfe5a87dbdca5bab93b9/docs/ja/static/images/artwork.jpg -------------------------------------------------------------------------------- /docs/ja/template/_layout.tmpl: -------------------------------------------------------------------------------- 1 | {{define "layout" -}} 2 | 3 | 4 | 5 | 6 | {{.Page.Title}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |

{{.Channel.Title}}

31 |

{{.Channel.Description}}

32 |
33 | 34 |
35 | {{- template "content" . -}} 36 |
37 | 38 |
39 |
40 |

Subscribe

41 |
    42 |
  • RSS
  • 43 |
44 |
45 |
© 2024 {{.Channel.Author}}
46 |
Generated by podbard
47 |
48 | 49 | 50 | 51 | 52 | {{- end}} 53 | -------------------------------------------------------------------------------- /docs/ja/template/episode.tmpl: -------------------------------------------------------------------------------- 1 | {{define "episode" -}} 2 | 3 |

{{.Episode.Title}}

4 |
5 | 6 |
7 | 8 | {{.Body}} 9 | 10 | 15 | 16 | 30 | 31 | {{- end}} 32 | -------------------------------------------------------------------------------- /docs/ja/template/index.tmpl: -------------------------------------------------------------------------------- 1 | {{define "index" -}} 2 | {{.Body}} 3 | {{- end}} 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Songmu/podbard 2 | 3 | go 1.23.4 4 | 5 | require ( 6 | github.com/Masterminds/sprig/v3 v3.3.0 7 | github.com/Songmu/go-httpdate v1.0.0 8 | github.com/Songmu/prompter v0.5.1 9 | github.com/abema/go-mp4 v1.4.1 10 | github.com/bogem/id3v2/v2 v2.1.4 11 | github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 12 | github.com/eduncan911/podcast v1.4.2 13 | github.com/goccy/go-yaml v1.15.15 14 | github.com/mattn/go-isatty v0.0.20 15 | github.com/otiai10/copy v1.14.1 16 | github.com/sergi/go-diff v1.3.1 17 | github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 18 | github.com/urfave/cli/v2 v2.27.5 19 | github.com/yuin/goldmark v1.7.8 20 | golang.org/x/text v0.21.0 21 | golang.org/x/tools v0.29.0 22 | ) 23 | 24 | require ( 25 | dario.cat/mergo v1.0.1 // indirect 26 | github.com/Masterminds/goutils v1.1.1 // indirect 27 | github.com/Masterminds/semver/v3 v3.3.1 // indirect 28 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 29 | github.com/google/uuid v1.6.0 // indirect 30 | github.com/huandu/xstrings v1.5.0 // indirect 31 | github.com/mitchellh/copystructure v1.2.0 // indirect 32 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 33 | github.com/otiai10/mint v1.6.3 // indirect 34 | github.com/pkg/errors v0.9.1 // indirect 35 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 36 | github.com/shopspring/decimal v1.4.0 // indirect 37 | github.com/spf13/cast v1.7.1 // indirect 38 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 39 | golang.org/x/crypto v0.32.0 // indirect 40 | golang.org/x/mod v0.22.0 // indirect 41 | golang.org/x/sync v0.10.0 // indirect 42 | golang.org/x/sys v0.29.0 // indirect 43 | golang.org/x/term v0.28.0 // indirect 44 | ) 45 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 2 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 4 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 5 | github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= 6 | github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 7 | github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= 8 | github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= 9 | github.com/Songmu/go-httpdate v1.0.0 h1:39S00oyg9q+kMso2ahhK4pvD4EXk4zQWzt/AMqGlH3o= 10 | github.com/Songmu/go-httpdate v1.0.0/go.mod h1:QPvdlIAR7M8UtklJx5CMOOCIq7hbx2QdxyEPvTF5QVs= 11 | github.com/Songmu/prompter v0.5.1 h1:IAsttKsOZWSDw7bV1mtGn9TAmLFAjXbp9I/eYmUUogo= 12 | github.com/Songmu/prompter v0.5.1/go.mod h1:CS3jEPD6h9IaLaG6afrl1orTgII9+uDWuw95dr6xHSw= 13 | github.com/abema/go-mp4 v1.4.1 h1:YoS4VRqd+pAmddRPLFf8vMk74kuGl6ULSjzhsIqwr6M= 14 | github.com/abema/go-mp4 v1.4.1/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws= 15 | github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= 16 | github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= 17 | github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= 18 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 19 | github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= 20 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 22 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg= 24 | github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E= 25 | github.com/eduncan911/podcast v1.4.2 h1:S+fsUlbR2ULFou2Mc52G/MZI8JVJHedbxLQnoA+MY/w= 26 | github.com/eduncan911/podcast v1.4.2/go.mod h1:mSxiK1z5KeNO0YFaQ3ElJlUZbbDV9dA7R9c1coeeXkc= 27 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 28 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 29 | github.com/goccy/go-yaml v1.15.15 h1:5turdzAlutS2Q7/QR/9R99Z1K0J00qDb4T0pHJcZ5ew= 30 | github.com/goccy/go-yaml v1.15.15/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 31 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 32 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 33 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 34 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 35 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 36 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 37 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 38 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 39 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 40 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 41 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 42 | github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= 43 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 44 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 45 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 46 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 47 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 48 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 49 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 50 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 51 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 52 | github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw= 53 | github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0= 54 | github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= 55 | github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I= 56 | github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= 57 | github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= 58 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 59 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 60 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 61 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 62 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 63 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 64 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 65 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 66 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 67 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 68 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 69 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 70 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 71 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 72 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 73 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 74 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 75 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 76 | github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I= 77 | github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vAjVY4qbSr8rQ610YzTkWcxzxSI= 78 | github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI= 79 | github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= 80 | github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= 81 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 82 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 83 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 84 | github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= 85 | github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 86 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 87 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 88 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 89 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 90 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 91 | golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= 92 | golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 93 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 94 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 95 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 96 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 97 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 98 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 99 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 100 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 101 | golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 102 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 103 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 104 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 105 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 106 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 107 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 108 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 109 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 110 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 111 | golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= 112 | golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 113 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 114 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 115 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 116 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 117 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 118 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 119 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 120 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 121 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 122 | golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= 123 | golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= 124 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 125 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 126 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 127 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 128 | gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= 129 | gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= 130 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 131 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 132 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 133 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 134 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 135 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 136 | -------------------------------------------------------------------------------- /go.work.example: -------------------------------------------------------------------------------- 1 | go 1.23.0 2 | 3 | use ( 4 | . 5 | ./scripts 6 | ) 7 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | # Code generated by godzil. DO NOT EDIT. 4 | # It is based on the one generated by godownloader. 5 | 6 | usage() { 7 | this=$1 8 | cat </dev/null 126 | } 127 | echoerr() { 128 | echo "$@" 1>&2 129 | } 130 | log_prefix() { 131 | echo "$0" 132 | } 133 | _logp=6 134 | log_set_priority() { 135 | _logp="$1" 136 | } 137 | log_priority() { 138 | if test -z "$1"; then 139 | echo "$_logp" 140 | return 141 | fi 142 | [ "$1" -le "$_logp" ] 143 | } 144 | log_tag() { 145 | case $1 in 146 | 0) echo "emerg" ;; 147 | 1) echo "alert" ;; 148 | 2) echo "crit" ;; 149 | 3) echo "err" ;; 150 | 4) echo "warning" ;; 151 | 5) echo "notice" ;; 152 | 6) echo "info" ;; 153 | 7) echo "debug" ;; 154 | *) echo "$1" ;; 155 | esac 156 | } 157 | log_debug() { 158 | log_priority 7 || return 0 159 | echoerr "$(log_prefix)" "$(log_tag 7)" "$@" 160 | } 161 | log_info() { 162 | log_priority 6 || return 0 163 | echoerr "$(log_prefix)" "$(log_tag 6)" "$@" 164 | } 165 | log_err() { 166 | log_priority 3 || return 0 167 | echoerr "$(log_prefix)" "$(log_tag 3)" "$@" 168 | } 169 | log_crit() { 170 | log_priority 2 || return 0 171 | echoerr "$(log_prefix)" "$(log_tag 2)" "$@" 172 | } 173 | uname_os() { 174 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 175 | case "$os" in 176 | cygwin_nt*) os="windows" ;; 177 | mingw*) os="windows" ;; 178 | msys_nt*) os="windows" ;; 179 | esac 180 | echo "$os" 181 | } 182 | uname_arch() { 183 | arch=$(uname -m) 184 | case $arch in 185 | x86_64) arch="amd64" ;; 186 | x86) arch="386" ;; 187 | i686) arch="386" ;; 188 | i386) arch="386" ;; 189 | aarch64) arch="arm64" ;; 190 | armv5*) arch="armv5" ;; 191 | armv6*) arch="armv6" ;; 192 | armv7*) arch="armv7" ;; 193 | esac 194 | echo ${arch} 195 | } 196 | uname_os_check() { 197 | os=$(uname_os) 198 | case "$os" in 199 | darwin) return 0 ;; 200 | dragonfly) return 0 ;; 201 | freebsd) return 0 ;; 202 | linux) return 0 ;; 203 | android) return 0 ;; 204 | nacl) return 0 ;; 205 | netbsd) return 0 ;; 206 | openbsd) return 0 ;; 207 | plan9) return 0 ;; 208 | solaris) return 0 ;; 209 | windows) return 0 ;; 210 | esac 211 | log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib" 212 | return 1 213 | } 214 | uname_arch_check() { 215 | arch=$(uname_arch) 216 | case "$arch" in 217 | 386) return 0 ;; 218 | amd64) return 0 ;; 219 | arm64) return 0 ;; 220 | armv5) return 0 ;; 221 | armv6) return 0 ;; 222 | armv7) return 0 ;; 223 | ppc64) return 0 ;; 224 | ppc64le) return 0 ;; 225 | mips) return 0 ;; 226 | mipsle) return 0 ;; 227 | mips64) return 0 ;; 228 | mips64le) return 0 ;; 229 | s390x) return 0 ;; 230 | amd64p32) return 0 ;; 231 | esac 232 | log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib" 233 | return 1 234 | } 235 | untar() { 236 | tarball=$1 237 | case "${tarball}" in 238 | *.tar.gz | *.tgz) tar --no-same-owner -xzf "${tarball}" ;; 239 | *.tar) tar --no-same-owner -xf "${tarball}" ;; 240 | *.zip) unzip "${tarball}" ;; 241 | *) 242 | log_err "untar unknown archive format for ${tarball}" 243 | return 1 244 | ;; 245 | esac 246 | } 247 | http_download_curl() { 248 | local_file=$1 249 | source_url=$2 250 | header=$3 251 | if [ -z "$header" ]; then 252 | code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url") 253 | else 254 | code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url") 255 | fi 256 | if [ "$code" != "200" ]; then 257 | log_debug "http_download_curl received HTTP status $code" 258 | return 1 259 | fi 260 | return 0 261 | } 262 | http_download_wget() { 263 | local_file=$1 264 | source_url=$2 265 | header=$3 266 | if [ -z "$header" ]; then 267 | wget -q -O "$local_file" "$source_url" 268 | else 269 | wget -q --header "$header" -O "$local_file" "$source_url" 270 | fi 271 | } 272 | http_download() { 273 | log_debug "http_download $2" 274 | if is_command curl; then 275 | http_download_curl "$@" 276 | return 277 | elif is_command wget; then 278 | http_download_wget "$@" 279 | return 280 | fi 281 | log_crit "http_download unable to find wget or curl" 282 | return 1 283 | } 284 | http_copy() { 285 | tmp=$(mktemp) 286 | http_download "${tmp}" "$1" "$2" || return 1 287 | body=$(cat "$tmp") 288 | rm -f "${tmp}" 289 | echo "$body" 290 | } 291 | github_release() { 292 | owner_repo=$1 293 | version=$2 294 | test -z "$version" && version="latest" 295 | giturl="https://github.com/${owner_repo}/releases/${version}" 296 | json=$(http_copy "$giturl" "Accept:application/json") 297 | test -z "$json" && return 1 298 | version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//') 299 | test -z "$version" && return 1 300 | echo "$version" 301 | } 302 | hash_sha256() { 303 | TARGET=${1:-/dev/stdin} 304 | if is_command gsha256sum; then 305 | hash=$(gsha256sum "$TARGET") || return 1 306 | echo "$hash" | cut -d ' ' -f 1 307 | elif is_command sha256sum; then 308 | hash=$(sha256sum "$TARGET") || return 1 309 | echo "$hash" | cut -d ' ' -f 1 310 | elif is_command shasum; then 311 | hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1 312 | echo "$hash" | cut -d ' ' -f 1 313 | elif is_command openssl; then 314 | hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1 315 | echo "$hash" | cut -d ' ' -f a 316 | else 317 | log_crit "hash_sha256 unable to find command to compute sha-256 hash" 318 | return 1 319 | fi 320 | } 321 | hash_sha256_verify() { 322 | TARGET=$1 323 | checksums=$2 324 | if [ -z "$checksums" ]; then 325 | log_err "hash_sha256_verify checksum file not specified in arg2" 326 | return 1 327 | fi 328 | BASENAME=${TARGET##*/} 329 | want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1) 330 | if [ -z "$want" ]; then 331 | log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'" 332 | return 1 333 | fi 334 | got=$(hash_sha256 "$TARGET") 335 | if [ "$want" != "$got" ]; then 336 | log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got" 337 | return 1 338 | fi 339 | } 340 | cat /dev/null < endTime { 144 | return fmt.Errorf("invalid chapter start time: %s", ch.Title) 145 | } 146 | tag.AddChapterFrame(id3v2.ChapterFrame{ 147 | ElementID: fmt.Sprintf("chp%d", i), 148 | StartTime: startTime, 149 | EndTime: endTime, 150 | // If these bytes are all set to 0xFF then the value should be ignored and 151 | // the start/end time value should be utilized. 152 | // cf. https://id3.org/id3v2-chapters-1.0 153 | StartOffset: math.MaxUint32, 154 | EndOffset: math.MaxUint32, 155 | Title: &id3v2.TextFrame{Encoding: id3v2.EncodingUTF8, Text: ch.Title}, 156 | Description: &id3v2.TextFrame{Encoding: id3v2.EncodingUTF8, Text: ""}, 157 | }) 158 | } 159 | if err := tag.Save(); err != nil { 160 | return err 161 | } 162 | au.Chapters = chs 163 | 164 | metaFilePath := getMetaFilePath(filepath.Dir(fpath), filepath.Base(fpath)) 165 | if _, err := os.Stat(metaFilePath); err == nil { 166 | return au.SaveMeta(filepath.Dir(fpath)) 167 | } 168 | return nil 169 | } 170 | 171 | func loadAudioMeta(metaPath string) (*Audio, error) { 172 | au := &Audio{} 173 | f, err := os.Open(metaPath) 174 | if err != nil { 175 | return nil, err 176 | } 177 | defer f.Close() 178 | if err := json.NewDecoder(f).Decode(au); err != nil { 179 | return nil, err 180 | } 181 | au.Name = strings.TrimPrefix(".", strings.TrimSuffix(filepath.Base(metaPath), ".json")) 182 | fi, err := f.Stat() 183 | if err != nil { 184 | return nil, err 185 | } 186 | au.modTime = fi.ModTime() 187 | 188 | return au, nil 189 | } 190 | 191 | func (au *Audio) readMP4(rs io.ReadSeeker) error { 192 | prove, err := mp4.Probe(rs) 193 | if err != nil { 194 | return err 195 | } 196 | au.Duration = prove.Duration / uint64(prove.Timescale) 197 | return nil 198 | } 199 | 200 | var skipped int = 0 201 | 202 | func (au *Audio) readMP3(r io.ReadSeeker) error { 203 | var ( 204 | t time.Duration 205 | f mp3.Frame 206 | d = mp3.NewDecoder(r) 207 | ) 208 | for { 209 | if err := d.Decode(&f, &skipped); err != nil { 210 | if err == io.EOF { 211 | break 212 | } 213 | return err 214 | } 215 | t = t + f.Duration() 216 | } 217 | au.Duration = uint64(t.Seconds()) 218 | au.rawDuration = t 219 | 220 | r.Seek(0, 0) 221 | 222 | tag, err := id3v2.ParseReader(r, id3v2.Options{Parse: true}) 223 | if err != nil { 224 | return nil 225 | } 226 | for _, frame := range tag.GetFrames("CHAP") { 227 | chapterFrame, ok := frame.(id3v2.ChapterFrame) 228 | if ok { 229 | au.Chapters = append(au.Chapters, &Chapter{ 230 | Title: chapterFrame.Title.Text, 231 | Start: uint64(chapterFrame.StartTime.Seconds()), 232 | }) 233 | } 234 | } 235 | sort.Slice(au.Chapters, func(i, j int) bool { 236 | return au.Chapters[i].Start < au.Chapters[j].Start 237 | }) 238 | return nil 239 | } 240 | -------------------------------------------------------------------------------- /internal/cast/builder.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import ( 4 | "html/template" 5 | "log" 6 | "net/url" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "github.com/otiai10/copy" 13 | ) 14 | 15 | const defaultBuildDir = "public" 16 | 17 | func Build( 18 | cfg *Config, episodes []*Episode, rootDir, generator, destination string, 19 | parents, doClear bool, buildDate time.Time) error { 20 | 21 | if doClear { 22 | destBase := getDestDir(rootDir, destination) 23 | if err := os.RemoveAll(destBase); err != nil { 24 | return err 25 | } 26 | } 27 | bdr, err := NewBuilder(cfg, episodes, rootDir, generator, destination, parents, buildDate) 28 | if err != nil { 29 | return err 30 | } 31 | if err := bdr.Build(); err != nil { 32 | return err 33 | } 34 | log.Println("🎤 Your podcast site has been generated and is ready to cast.") 35 | return nil 36 | } 37 | 38 | func NewBuilder( 39 | cfg *Config, episodes []*Episode, rootDir, generator, dest string, parents bool, buildDate time.Time) (*Builder, error) { 40 | 41 | buildDir := getBuildDir(rootDir, cfg.Channel.Link.Path, dest, parents) 42 | 43 | tmpl, err := loadTemplate(rootDir) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | return &Builder{ 49 | Config: cfg, 50 | Episodes: episodes, 51 | RootDir: rootDir, 52 | Generator: generator, 53 | BuildDir: buildDir, 54 | BuildDate: buildDate, 55 | 56 | template: tmpl, 57 | }, nil 58 | } 59 | 60 | type Builder struct { 61 | Config *Config 62 | Episodes []*Episode 63 | RootDir string 64 | Generator string 65 | BuildDir string 66 | BuildDate time.Time 67 | 68 | template *castTemplate 69 | } 70 | 71 | func getDestDir(rootDir, dest string) string { 72 | if dest != "" { 73 | return dest 74 | } 75 | return filepath.Join(rootDir, defaultBuildDir) 76 | } 77 | 78 | func getBuildDir(rootDir, path, dest string, parents bool) string { 79 | dir := getDestDir(rootDir, dest) 80 | if parents { 81 | dir = filepath.Join(dir, strings.TrimLeft(path, "/")) 82 | } 83 | return dir 84 | } 85 | 86 | func (bdr *Builder) Build() error { 87 | log.Printf("🔨 Generating a site under the %q directrory", bdr.BuildDir) 88 | 89 | if err := os.MkdirAll(bdr.BuildDir, os.ModePerm); err != nil { 90 | return err 91 | } 92 | 93 | log.Println("Building a podcast feed...") 94 | if err := bdr.buildFeed(bdr.BuildDate); err != nil { 95 | return err 96 | } 97 | 98 | log.Println("Building episodes...") 99 | for i, ep := range bdr.Episodes { 100 | var prev, next *Episode 101 | if i > 0 { 102 | next = bdr.Episodes[i-1] 103 | } 104 | if i < len(bdr.Episodes)-1 { 105 | prev = bdr.Episodes[i+1] 106 | } 107 | if err := bdr.buildEpisode(ep, prev, next); err != nil { 108 | return err 109 | } 110 | } 111 | 112 | log.Println("Build pages...") 113 | if err := bdr.buildPages(); err != nil { 114 | return err 115 | } 116 | 117 | log.Println("Copying static files...") 118 | if err := bdr.buildStatic(); err != nil { 119 | return err 120 | } 121 | 122 | if err := bdr.copyAudio(); err != nil { 123 | return err 124 | } 125 | 126 | log.Println("Building an index page...") 127 | return bdr.buildIndex() 128 | } 129 | 130 | func (bdr *Builder) buildFeed(buildDate time.Time) error { 131 | pubDate := buildDate // XXX 132 | if len(bdr.Episodes) > 0 { 133 | pubDate = bdr.Episodes[0].PubDate() 134 | } 135 | 136 | feed := NewFeed(bdr.Generator, bdr.Config.Channel, pubDate, buildDate) 137 | for _, ep := range bdr.Episodes { 138 | if _, err := feed.AddEpisode(ep, bdr.Config.AudioBaseURL()); err != nil { 139 | return err 140 | } 141 | } 142 | 143 | feedPath := filepath.Join(bdr.BuildDir, feedFile) 144 | f, err := os.Create(feedPath) 145 | if err != nil { 146 | return err 147 | } 148 | defer f.Close() 149 | 150 | return feed.Podcast.Encode(f) 151 | } 152 | 153 | func (bdr *Builder) buildEpisode(ep, prev, next *Episode) error { 154 | episodePath := filepath.Join(bdr.BuildDir, episodeDir, ep.Slug, "index.html") 155 | if err := os.MkdirAll(filepath.Dir(episodePath), os.ModePerm); err != nil { 156 | return err 157 | } 158 | 159 | arg := struct { 160 | Title string 161 | Page *PageInfo 162 | Body template.HTML 163 | Episode *Episode 164 | PreviousEpisode *Episode 165 | NextEpisode *Episode 166 | Channel *ChannelConfig 167 | }{ 168 | Title: ep.Title, 169 | Page: &PageInfo{ 170 | Title: ep.Title, 171 | Description: ep.Subtitle, 172 | URL: ep.URL, 173 | }, 174 | Body: template.HTML(ep.Body), 175 | Episode: ep, 176 | PreviousEpisode: prev, 177 | NextEpisode: next, 178 | Channel: bdr.Config.Channel, 179 | } 180 | f, err := os.Create(episodePath) 181 | if err != nil { 182 | return err 183 | } 184 | defer f.Close() 185 | 186 | return bdr.template.Execute(f, "layout", "episode", arg) 187 | } 188 | 189 | type PageInfo struct { 190 | Title string 191 | Description string 192 | URL *url.URL 193 | } 194 | 195 | type PageArg struct { 196 | Page *PageInfo 197 | Body template.HTML 198 | Episodes []*Episode 199 | Channel *ChannelConfig 200 | } 201 | 202 | func newPageArg(cfg *Config, episodes []*Episode, page *Page) *PageArg { 203 | return &PageArg{ 204 | Page: &PageInfo{ 205 | Title: cfg.Channel.Title, 206 | Description: cfg.Channel.Description, 207 | URL: cfg.Channel.Link.URL, 208 | }, 209 | Body: template.HTML(page.Body), 210 | Episodes: episodes, 211 | Channel: cfg.Channel, 212 | } 213 | } 214 | 215 | func (bdr *Builder) buildIndex() error { 216 | idx, err := LoadIndex(bdr.RootDir, bdr.Config, bdr.Episodes) 217 | if err != nil { 218 | return err 219 | } 220 | indexPath := filepath.Join(bdr.BuildDir, "index.html") 221 | 222 | arg := newPageArg(bdr.Config, bdr.Episodes, idx) 223 | f, err := os.Create(indexPath) 224 | if err != nil { 225 | return err 226 | } 227 | defer f.Close() 228 | 229 | return bdr.template.Execute(f, "layout", "index", arg) 230 | } 231 | 232 | func (bdr *Builder) copyAudio() error { 233 | // If we upload the audio files to a different URL, do not copy them. 234 | if bdr.Config.AudioBucketURL.URL != nil { 235 | return nil 236 | } 237 | src := filepath.Join(bdr.RootDir, audioDir) 238 | if _, err := os.Stat(src); err != nil { 239 | return nil 240 | } 241 | log.Println("Copying audio files...") 242 | return copy.Copy(src, filepath.Join(bdr.BuildDir, audioDir), copy.Options{ 243 | Skip: func(fi os.FileInfo, src, dest string) (bool, error) { 244 | n := fi.Name() 245 | skip := fi.IsDir() || strings.HasPrefix(".", n) || 246 | !IsSupportedMediaExt(filepath.Ext(n)) 247 | 248 | return skip, nil 249 | }, 250 | }) 251 | } 252 | 253 | func (bdr *Builder) buildPages() error { 254 | pdir := filepath.Join(bdr.RootDir, pageDir) 255 | if _, err := os.Stat(pdir); err != nil { 256 | return nil 257 | } 258 | dir, err := os.ReadDir(pdir) 259 | if err != nil { 260 | return err 261 | } 262 | for _, fi := range dir { 263 | if fi.IsDir() || !strings.HasSuffix(fi.Name(), ".md") { 264 | continue 265 | } 266 | mdPath := filepath.Join(pdir, fi.Name()) 267 | if err := bdr.buildPage(mdPath); err != nil { 268 | return err 269 | } 270 | } 271 | return nil 272 | } 273 | 274 | func (bdr *Builder) buildPage(mdPath string) error { 275 | page, err := LoadPage(mdPath, bdr.Config, bdr.Episodes) 276 | if err != nil { 277 | return err 278 | } 279 | arg := newPageArg(bdr.Config, bdr.Episodes, page) 280 | htmlPath := filepath.Join( 281 | bdr.BuildDir, 282 | strings.TrimSuffix(filepath.Base(mdPath), ".md"), 283 | "index.html") 284 | if err := os.MkdirAll(filepath.Dir(htmlPath), os.ModePerm); err != nil { 285 | return err 286 | } 287 | f, err := os.Create(htmlPath) 288 | if err != nil { 289 | return err 290 | } 291 | defer f.Close() 292 | 293 | // use index template for pages for now, we would need to arrange the templates 294 | return bdr.template.Execute(f, "layout", "index", arg) 295 | } 296 | 297 | func (bdr *Builder) buildStatic() error { 298 | src := filepath.Join(bdr.RootDir, staticDir) 299 | if _, err := os.Stat(src); err != nil { 300 | return nil 301 | } 302 | return copy.Copy(src, bdr.BuildDir, copy.Options{ 303 | Skip: func(fi os.FileInfo, src, dest string) (bool, error) { 304 | return strings.HasPrefix(".", fi.Name()), nil 305 | }, 306 | }) 307 | } 308 | -------------------------------------------------------------------------------- /internal/cast/category.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import "fmt" 4 | 5 | type Categories []string 6 | 7 | func (cats *Categories) UnmarshalYAML(unmarshal func(interface{}) error) error { 8 | var str string 9 | var strSlice []string 10 | 11 | if err := unmarshal(&str); err == nil { 12 | *cats = []string{str} 13 | return nil 14 | } 15 | err := unmarshal(&strSlice) 16 | if err == nil { 17 | *cats = strSlice 18 | return nil 19 | } 20 | return fmt.Errorf("failed to unmarshal field into Categories: %w", err) 21 | } 22 | -------------------------------------------------------------------------------- /internal/cast/chapter.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/goccy/go-yaml/token" 9 | ) 10 | 11 | type Chapter struct { 12 | Title string `json:"title"` 13 | Start uint64 `json:"start"` 14 | } 15 | 16 | func convertStartToString(start uint64) string { 17 | seconds := start % 60 18 | minutes := (start / 60) % 60 19 | hours := start / 3600 20 | startTime := fmt.Sprintf("%d:%02d", minutes, seconds) 21 | if hours > 0 { 22 | startTime = fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds) 23 | } 24 | return startTime 25 | } 26 | 27 | func convertStringToStart(str string) (uint64, error) { 28 | if l := len(strings.Split(str, ":")); l > 3 { 29 | return 0, fmt.Errorf("invalid time format: %s", str) 30 | } else if l == 2 { 31 | str = "0:" + str 32 | } 33 | var h, m, s uint64 34 | if _, err := fmt.Sscanf(str, "%d:%d:%d", &h, &m, &s); err != nil { 35 | return 0, fmt.Errorf("invalid time format: %s", str) 36 | } 37 | return h*3600 + m*60 + s, nil 38 | } 39 | 40 | func (chs *Chapter) String() string { 41 | return fmt.Sprintf("%s %s", convertStartToString(chs.Start), chs.Title) 42 | } 43 | 44 | func (chs *Chapter) UnmarshalYAML(b []byte) error { 45 | str := unquote(strings.TrimSpace(string(b))) 46 | stuff := strings.SplitN(str, " ", 2) 47 | if len(stuff) != 2 { 48 | return fmt.Errorf("invalid chapter format: %s", str) 49 | } 50 | start, err := convertStringToStart(stuff[0]) 51 | if err != nil { 52 | return fmt.Errorf("invalid chapter format: %s, %w", str, err) 53 | } 54 | *chs = Chapter{ 55 | Title: stuff[1], 56 | Start: start, 57 | } 58 | return nil 59 | } 60 | 61 | func unquote(s string) string { 62 | if len(s) <= 1 { 63 | return s 64 | } 65 | if s[0] == '\'' && s[len(s)-1] == '\'' { 66 | return s[1 : len(s)-1] 67 | } 68 | if s[0] == '"' { 69 | str, err := strconv.Unquote(s) 70 | if err == nil { 71 | return str 72 | } 73 | } 74 | return s 75 | } 76 | 77 | func (chs *Chapter) MarshalYAML() ([]byte, error) { 78 | s := chs.String() 79 | if token.IsNeedQuoted(s) { 80 | s = strconv.Quote(s) 81 | } 82 | return []byte(s), nil 83 | } 84 | -------------------------------------------------------------------------------- /internal/cast/chapter_test.go: -------------------------------------------------------------------------------- 1 | package cast_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/Songmu/podbard/internal/cast" 7 | "github.com/goccy/go-yaml" 8 | ) 9 | 10 | func TestChapterSegment_MarshalYAML(t *testing.T) { 11 | t.Parallel() 12 | 13 | tests := []struct { 14 | name string 15 | chapter struct { 16 | Segment *cast.Chapter 17 | } 18 | want string 19 | }{{ 20 | name: "simple", 21 | chapter: struct{ Segment *cast.Chapter }{ 22 | Segment: &cast.Chapter{ 23 | Title: "Chapter 1", 24 | Start: 0, 25 | }, 26 | }, 27 | want: "segment: 0:00 Chapter 1\n", 28 | }, { 29 | name: "with hours", 30 | chapter: struct{ Segment *cast.Chapter }{ 31 | Segment: &cast.Chapter{ 32 | Title: "Chapter 2", 33 | Start: 3600, 34 | }, 35 | }, 36 | want: "segment: 1:00:00 Chapter 2\n", 37 | }, { 38 | 39 | name: "quoted", 40 | chapter: struct{ Segment *cast.Chapter }{ 41 | Segment: &cast.Chapter{ 42 | Title: "Chapter 2:", 43 | Start: 3600, 44 | }, 45 | }, 46 | want: `segment: "1:00:00 Chapter 2:"` + "\n", 47 | }} 48 | 49 | for _, tt := range tests { 50 | t.Run(tt.name, func(t *testing.T) { 51 | b, err := yaml.Marshal(tt.chapter) 52 | if err != nil { 53 | t.Fatalf("unexpected error: %v", err) 54 | } 55 | if got := string(b); got != tt.want { 56 | t.Errorf("got %q; want %q", got, tt.want) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | func TestChapterSegment_UnmarshalYAML(t *testing.T) { 63 | t.Parallel() 64 | 65 | tests := []struct { 66 | name string 67 | input string 68 | want cast.Chapter 69 | wantErr bool 70 | }{{ 71 | name: "simple", 72 | input: "0:00 Chapter 1", 73 | want: cast.Chapter{ 74 | Title: "Chapter 1", 75 | Start: 0, 76 | }, 77 | }, { 78 | name: "with hours", 79 | input: "1:00:00 Chapter 2", 80 | want: cast.Chapter{ 81 | Title: "Chapter 2", 82 | Start: 3600, 83 | }, 84 | }, { 85 | name: "with hours", 86 | input: `"1:00:00 Chapter 2:"`, 87 | want: cast.Chapter{ 88 | Title: "Chapter 2:", 89 | Start: 3600, 90 | }, 91 | }, { 92 | name: "with hours", 93 | input: `'1:00:00 Chapter 二 あいう'`, 94 | want: cast.Chapter{ 95 | Title: "Chapter 二 あいう", 96 | Start: 3600, 97 | }, 98 | }, { 99 | name: "invalid format", 100 | input: "invalid", 101 | wantErr: true, 102 | }, { 103 | name: "invalid time format", 104 | input: "1:00:00:00 Chapter 2", 105 | wantErr: true, 106 | }, { 107 | name: "invalid hours", 108 | input: "a:00:00 Chapter 2", 109 | wantErr: true, 110 | }, { 111 | name: "invalid minutes", 112 | input: "1:a:00 Chapter 2", 113 | wantErr: true, 114 | }, { 115 | name: "invalid seconds", 116 | input: "1:00:a Chapter 2", 117 | wantErr: true, 118 | }} 119 | 120 | for _, tt := range tests { 121 | t.Run(tt.name, func(t *testing.T) { 122 | var got cast.Chapter 123 | err := yaml.Unmarshal([]byte(tt.input), &got) 124 | if err != nil { 125 | if !tt.wantErr { 126 | t.Fatalf("unexpected error: %v", err) 127 | } 128 | return 129 | } 130 | if got != tt.want { 131 | t.Errorf("got %v; want %v", got, tt.want) 132 | } 133 | }) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /internal/cast/config.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "net/url" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "time" 12 | 13 | "github.com/goccy/go-yaml" 14 | "golang.org/x/text/language" 15 | ) 16 | 17 | const ( 18 | episodeDir = "episode" 19 | audioDir = "audio" 20 | pageDir = "page" 21 | staticDir = "static" 22 | 23 | configFile = "podbard.yaml" 24 | feedFile = "feed.xml" 25 | ) 26 | 27 | type YAMLURL struct { 28 | *url.URL 29 | } 30 | 31 | func (yu *YAMLURL) UnmarshalYAML(unmarshal func(interface{}) error) error { 32 | var s string 33 | err := unmarshal(&s) 34 | if err != nil { 35 | return err 36 | } 37 | if s == "" { 38 | return nil 39 | } 40 | url, err := url.Parse(s) 41 | if err != nil { 42 | return err 43 | } 44 | if url.Scheme != "https" && url.Scheme != "http" { 45 | return fmt.Errorf("invalid scheme in URL: %s", s) 46 | } 47 | yu.URL = url 48 | return nil 49 | } 50 | 51 | func (yu *YAMLURL) MarshalYAML() (interface{}, error) { 52 | return yu.String(), nil 53 | } 54 | 55 | type YAMLLang struct { 56 | *language.Tag 57 | } 58 | 59 | func (yl *YAMLLang) UnmarshalYAML(unmarshal func(interface{}) error) error { 60 | var s string 61 | err := unmarshal(&s) 62 | if err != nil { 63 | return err 64 | } 65 | if s == "" { 66 | return nil 67 | } 68 | lang, err := language.Parse(s) 69 | if err != nil { 70 | return err 71 | } 72 | yl.Tag = &lang 73 | return nil 74 | } 75 | 76 | func (yl *YAMLLang) MarshalYAML() (interface{}, error) { 77 | return yl.String(), nil 78 | } 79 | 80 | func (yl *YAMLLang) String() string { 81 | if yl.Tag == nil { 82 | return "" 83 | } 84 | return yl.Tag.String() 85 | } 86 | 87 | type Config struct { 88 | Channel *ChannelConfig `yaml:"channel"` 89 | 90 | TimeZone string `yaml:"timezone"` 91 | AudioBucketURL YAMLURL `yaml:"audio_bucket_url"` 92 | 93 | location *time.Location 94 | audioBaseURL *url.URL 95 | } 96 | 97 | type ChannelConfig struct { 98 | Link YAMLURL `yaml:"link"` 99 | Title string `yaml:"title"` 100 | Description string `yaml:"description"` 101 | Categories Categories `yaml:"category"` // XXX sub category is not supported yet 102 | Language YAMLLang `yaml:"language"` 103 | Author string `yaml:"author"` 104 | Email string `yaml:"email"` 105 | Artwork string `yaml:"artwork"` 106 | Copyright string `yaml:"copyright"` 107 | Explicit bool `yaml:"explicit"` 108 | Private bool `yaml:"private"` 109 | } 110 | 111 | func (cfg *Config) init() error { 112 | if cfg.Channel == nil { 113 | return errors.New("no channel configuration") 114 | } 115 | if cfg.Channel.Link.URL == nil { 116 | return errors.New("no link configuration is specified in configuration") 117 | } else if !strings.HasSuffix(cfg.Channel.Link.URL.Path, "/") { 118 | cfg.Channel.Link.URL.Path += "/" 119 | } 120 | if cfg.TimeZone != "" { 121 | loc, err := time.LoadLocation(cfg.TimeZone) 122 | if err != nil { 123 | return err 124 | } 125 | cfg.location = loc 126 | } else { 127 | cfg.location = time.Local 128 | } 129 | 130 | cfg.audioBaseURL = cfg.AudioBucketURL.URL 131 | if cfg.audioBaseURL == nil { 132 | cfg.audioBaseURL = cfg.Channel.Link.JoinPath(audioDir) 133 | } 134 | return nil 135 | } 136 | 137 | func (cfg *Config) Location() *time.Location { 138 | return cfg.location 139 | } 140 | 141 | func (cfg *Config) AudioBaseURL() *url.URL { 142 | return cfg.audioBaseURL 143 | } 144 | 145 | func (channel *ChannelConfig) FeedURL() *url.URL { 146 | return channel.Link.JoinPath(feedFile) 147 | } 148 | 149 | func (channel *ChannelConfig) ImageURL() string { 150 | img := channel.Artwork 151 | if img == "" { 152 | return "" 153 | } 154 | if strings.HasPrefix(img, "https://") || strings.HasPrefix(img, "http://") { 155 | return img 156 | } 157 | return channel.Link.JoinPath(img).String() 158 | } 159 | 160 | func LoadConfig(rootDir string) (*Config, error) { 161 | return loadConfigFromFile(filepath.Join(rootDir, configFile)) 162 | } 163 | 164 | func loadConfigFromFile(fname string) (*Config, error) { 165 | f, err := os.Open(fname) 166 | if err != nil { 167 | return nil, err 168 | } 169 | defer f.Close() 170 | return loadConfigFromReader(f) 171 | } 172 | 173 | func loadConfigFromReader(r io.Reader) (*Config, error) { 174 | cfg := &Config{} 175 | if err := yaml.NewDecoder(r).Decode(cfg); err != nil { 176 | return nil, err 177 | } 178 | if err := cfg.init(); err != nil { 179 | return nil, err 180 | } 181 | return cfg, nil 182 | } 183 | -------------------------------------------------------------------------------- /internal/cast/episode.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "path/filepath" 13 | "sort" 14 | "strings" 15 | "text/template" 16 | "time" 17 | 18 | "github.com/Songmu/go-httpdate" 19 | "github.com/Songmu/prompter" 20 | "github.com/goccy/go-yaml" 21 | "github.com/sergi/go-diff/diffmatchpatch" 22 | ) 23 | 24 | /* 25 | The `audioFile` is specified either by file path, filename in the audio placement directory or URL. 26 | This means there are follwing patterns for `audioFile`: 27 | 28 | - File path: 29 | - Relative path: "./audio/1.mp3" (this will be relative to the current directory, not the rootDir) 30 | - Absolute path: "/path/to/audio/mp.3" 31 | 32 | - File name: "1.mp3" (subdirectories are currently not supported) 33 | - URL: "https://example.com/audio/1.mp3" 34 | 35 | In any case, the audio files must exist under the audio placement directory. 36 | */ 37 | func LoadEpisode( 38 | rootDir, audioFile, body string, ignoreMissing, saveMeta bool, 39 | pubDate time.Time, slug, title, subtitle string, loc *time.Location) (string, bool, error) { 40 | 41 | var ( 42 | audioPath = filepath.ToSlash(audioFile) 43 | audioExists = true 44 | audioBasePath = filepath.Join(rootDir, audioDir) 45 | audioMetaPath = getMetaFilePath(audioBasePath, filepath.Base(audioPath)) 46 | audioMetaExists bool 47 | isAudioURL bool 48 | ) 49 | if _, err := os.Stat(audioMetaPath); err == nil { 50 | audioMetaExists = true 51 | } 52 | 53 | if !strings.Contains(audioPath, "/") { 54 | audioPath = filepath.Join(audioBasePath, audioFile) 55 | if _, err := os.Stat(audioPath); err != nil { 56 | if !os.IsNotExist(err) { 57 | return "", false, fmt.Errorf("can't find audio file: %s, %w", audioFile, err) 58 | } 59 | if !ignoreMissing && !audioMetaExists { 60 | return "", false, fmt.Errorf("audio file not found: %s, %w", audioFile, err) 61 | } 62 | audioExists = false 63 | } 64 | } else if strings.HasPrefix(audioPath, "http://") || strings.HasPrefix(audioPath, "https://") { 65 | audioExists = false 66 | isAudioURL = true 67 | 68 | } else { 69 | if _, err := os.Stat(audioPath); err != nil { 70 | return "", false, fmt.Errorf("can't find audio file: %s, %w", audioFile, err) 71 | } 72 | 73 | var absAudioPath = audioPath 74 | if !filepath.IsAbs(absAudioPath) { 75 | var err error 76 | absAudioPath, err = filepath.Abs(absAudioPath) 77 | if err != nil { 78 | return "", false, err 79 | } 80 | } 81 | var absAudioBasePath = audioBasePath 82 | if !filepath.IsAbs(absAudioBasePath) { 83 | var err error 84 | absAudioBasePath, err = filepath.Abs(absAudioBasePath) 85 | if err != nil { 86 | return "", false, err 87 | } 88 | } 89 | p, err := filepath.Rel(absAudioBasePath, absAudioPath) 90 | if err != nil { 91 | return "", false, err 92 | } 93 | if strings.ContainsAny(p, `/\`) && !saveMeta { 94 | return "", false, fmt.Errorf("audio files must be placed directory under the %q directory, but: %q", 95 | audioBasePath, audioPath) 96 | } 97 | } 98 | 99 | var au *Audio 100 | if audioExists && saveMeta { 101 | var err error 102 | au, err = LoadAudio(audioPath) 103 | if err != nil { 104 | return "", false, err 105 | } 106 | if err := au.SaveMeta(audioBasePath); err != nil { 107 | return "", false, err 108 | } 109 | } else if isAudioURL { 110 | // For URLs, save the metafile even if the --save-meta option is not specified. Is that ok? 111 | // It might be a good idea to check if the URL is under the audio bucket configuration. 112 | // In any case, the specification would be changed here. 113 | var err error 114 | au, err = NewAudio(audioPath) 115 | if err != nil { 116 | return "", false, err 117 | } 118 | data, size, lastModified, err := downloadAndGetSizeAndLastModified(audioPath) 119 | if err != nil { 120 | return "", false, err 121 | } 122 | r := bytes.NewReader(data) 123 | if err := au.ReadFrom(r); err != nil { 124 | return "", false, err 125 | } 126 | au.FileSize = size 127 | au.modTime = lastModified 128 | 129 | if err := au.SaveMeta(audioBasePath); err != nil { 130 | return "", false, err 131 | } 132 | } 133 | 134 | audioName := filepath.Base(audioPath) 135 | if slug == "" { 136 | slug = strings.TrimSuffix(audioName, filepath.Ext(audioName)) 137 | } 138 | filePath := filepath.Join(rootDir, episodeDir, slug+".md") 139 | 140 | // find existing episode file 141 | if _, err := os.Stat(filePath); err == nil { 142 | ef, err := loadMeta(filePath) 143 | if err != nil { 144 | return "", false, err 145 | } 146 | if audioName != ef.AudioFile { 147 | return "", false, fmt.Errorf("mismatch audio file in %q: %s, %s", 148 | filePath, audioName, ef.AudioFile) 149 | } 150 | return filePath, false, nil 151 | } 152 | efs, err := loadEpisodeMetas(rootDir) 153 | if err != nil { 154 | return "", false, err 155 | } 156 | for mdPath, ef := range efs { 157 | if ef.AudioFile == audioName { 158 | return mdPath, false, nil 159 | } 160 | } 161 | 162 | // create new episode file 163 | if audioExists || audioMetaExists { 164 | if au == nil { 165 | var err error 166 | au, err = LoadAudio(audioPath) 167 | if err != nil { 168 | return "", false, err 169 | } 170 | } 171 | if pubDate.IsZero() { 172 | pubDate = au.modTime 173 | } 174 | if title == "" { 175 | title = au.Title 176 | } 177 | } 178 | 179 | if title == "" { 180 | title = slug 181 | } 182 | if subtitle == "" { 183 | subtitle = title 184 | } 185 | if pubDate.IsZero() { 186 | pubDate = time.Now() 187 | } 188 | 189 | if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil { 190 | return "", false, err 191 | } 192 | 193 | epm := &EpisodeFrontMatter{ 194 | AudioFile: audioName, 195 | Title: title, 196 | Subtitle: subtitle, 197 | Date: pubDate.Format(time.RFC3339), 198 | Chapters: au.Chapters, 199 | } 200 | b, err := yaml.Marshal(epm) 201 | if err != nil { 202 | return "", false, err 203 | } 204 | if body == "" { 205 | body = "\n" 206 | } 207 | content := fmt.Sprintf(`--- 208 | %s--- 209 | 210 | %s`, string(b), body) 211 | if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { 212 | return "", false, err 213 | } 214 | return filePath, true, nil 215 | } 216 | 217 | func downloadAndGetSizeAndLastModified(url string) ([]byte, int64, time.Time, error) { 218 | resp, err := http.Get(url) 219 | if err != nil { 220 | return nil, 0, time.Time{}, err 221 | } 222 | defer resp.Body.Close() 223 | 224 | if resp.StatusCode != http.StatusOK { 225 | return nil, 0, time.Time{}, fmt.Errorf("failed to download: %s", resp.Status) 226 | } 227 | body, err := io.ReadAll(resp.Body) 228 | if err != nil { 229 | return nil, 0, time.Time{}, err 230 | } 231 | 232 | size := int64(len(body)) 233 | 234 | lastModifiedStr := resp.Header.Get("Last-Modified") 235 | var lastModified time.Time 236 | if lastModifiedStr != "" { 237 | lastModified, err = time.Parse(time.RFC1123, lastModifiedStr) 238 | if err != nil { 239 | return nil, 0, time.Time{}, err 240 | } 241 | } 242 | return body, size, lastModified, nil 243 | } 244 | 245 | func loadEpisodeMetas(rootDir string) (map[string]*EpisodeFrontMatter, error) { 246 | dirname := filepath.Join(rootDir, episodeDir) 247 | dir, err := os.ReadDir(dirname) 248 | if err != nil { 249 | return nil, err 250 | } 251 | var ret = make(map[string]*EpisodeFrontMatter) 252 | for _, f := range dir { 253 | if f.IsDir() || filepath.Ext(f.Name()) != ".md" { 254 | continue 255 | } 256 | mdPath := filepath.Join(dirname, f.Name()) 257 | ef, err := loadMeta(mdPath) 258 | if err != nil { 259 | return nil, err 260 | } 261 | ret[mdPath] = ef 262 | } 263 | return ret, nil 264 | } 265 | 266 | func loadMeta(fpath string) (*EpisodeFrontMatter, error) { 267 | content, err := os.ReadFile(fpath) 268 | if err != nil { 269 | return nil, err 270 | } 271 | frontMatter, _, err := splitFrontMatterAndBody(string(content)) 272 | if err != nil { 273 | return nil, err 274 | } 275 | var ef EpisodeFrontMatter 276 | if err := yaml.NewDecoder(strings.NewReader(frontMatter)).Decode(&ef); err != nil { 277 | return nil, err 278 | } 279 | if err != nil { 280 | return nil, err 281 | } 282 | return &ef, nil 283 | } 284 | 285 | func LoadEpisodes( 286 | rootDir string, rootURL *url.URL, audioBaseURL *url.URL, loc *time.Location) ([]*Episode, error) { 287 | if audioBaseURL == nil { 288 | audioBaseURL = rootURL.JoinPath(audioDir) 289 | } 290 | dirname := filepath.Join(rootDir, episodeDir) 291 | dir, err := os.ReadDir(dirname) 292 | if err != nil { 293 | return nil, err 294 | } 295 | var ret []*Episode 296 | for _, f := range dir { 297 | // no subdirectories support 298 | if f.IsDir() || filepath.Ext(f.Name()) != ".md" { 299 | continue 300 | } 301 | ep, err := loadEpisodeFromFile( 302 | rootDir, filepath.Join(dirname, f.Name()), rootURL, audioBaseURL, loc) 303 | if err != nil { 304 | return nil, err 305 | } 306 | ret = append(ret, ep) 307 | } 308 | sort.Slice(ret, func(i, j int) bool { 309 | // desc sort 310 | if ret[i].PubDate().Equal(ret[j].PubDate()) { 311 | return ret[j].AudioFile < ret[i].AudioFile 312 | } 313 | return ret[j].PubDate().Before(ret[i].PubDate()) 314 | }) 315 | 316 | return ret, nil 317 | } 318 | 319 | type Episode struct { 320 | EpisodeFrontMatter 321 | Slug string 322 | RawBody, Body string 323 | URL *url.URL 324 | ChaptersBody string 325 | 326 | rootDir string 327 | audioBaseURL *url.URL 328 | } 329 | 330 | type EpisodeFrontMatter struct { 331 | AudioFile string `yaml:"audio"` 332 | Title string `yaml:"title"` 333 | Date string `yaml:"date"` 334 | Subtitle string `yaml:"subtitle"` 335 | Chapters []*Chapter `yaml:"chapters,omitempty"` 336 | 337 | audio *Audio 338 | pubDate time.Time 339 | } 340 | 341 | func (ep *Episode) AudioURL() *url.URL { 342 | return ep.audioBaseURL.JoinPath(ep.AudioFile) 343 | } 344 | 345 | func (ep *Episode) init(loc *time.Location) error { 346 | if err := ep.loadAudio(ep.rootDir); err != nil { 347 | return err 348 | } 349 | 350 | var err error 351 | ep.pubDate, err = httpdate.Str2Time(ep.Date, loc) 352 | if err != nil { 353 | return err 354 | } 355 | 356 | if len(ep.EpisodeFrontMatter.Chapters) > 0 { 357 | var ( 358 | updateChapter bool = true 359 | audioPath = filepath.Join(ep.rootDir, audioDir, ep.AudioFile) 360 | ) 361 | var audioExists = func() bool { 362 | _, err := os.Stat(audioPath) 363 | return err == nil 364 | }() 365 | _, err := os.Stat(audioPath) 366 | audioExists = err == nil 367 | 368 | if len(ep.audio.Chapters) > 0 { 369 | var metaChapters string 370 | for _, ch := range ep.EpisodeFrontMatter.Chapters { 371 | metaChapters += ch.String() + "\n" 372 | } 373 | var auChapters string 374 | for _, ch := range ep.audio.Chapters { 375 | auChapters += ch.String() + "\n" 376 | } 377 | if metaChapters == auChapters { 378 | updateChapter = false 379 | } else { 380 | dmp := diffmatchpatch.New() 381 | d := dmp.DiffPrettyText(dmp.DiffMain(auChapters, metaChapters, false)) 382 | if audioExists { 383 | updateChapter = prompter.YN( 384 | fmt.Sprintf("Update chapter information? diff:\n%s", d), true) 385 | } else { 386 | updateChapter = false 387 | log.Printf("The following chapter differences have been detected, but the audio file does not exist, so it cannot be updated.\n%s", d) 388 | } 389 | } 390 | } 391 | 392 | if updateChapter { 393 | if audioExists { 394 | if err := ep.audio.UpdateChapter(audioPath, ep.EpisodeFrontMatter.Chapters); err != nil { 395 | return err 396 | } 397 | } else { 398 | log.Printf("The audio file does not exist, so the chapter information cannot be updated.") 399 | } 400 | } 401 | } 402 | 403 | if len(ep.audio.Chapters) > 0 { 404 | ep.ChaptersBody, err = buildChaptersBody(ep.audio.Chapters) 405 | if err != nil { 406 | return err 407 | } 408 | ep.EpisodeFrontMatter.Chapters = ep.audio.Chapters 409 | } 410 | md := NewMarkdown() 411 | var buf bytes.Buffer 412 | if err := md.Convert([]byte(ep.RawBody), &buf); err != nil { 413 | return err 414 | } 415 | ep.Body = buf.String() 416 | return nil 417 | } 418 | 419 | const chaperTmplStr = `
    420 | {{- range . -}} 421 |
  • {{ .Title }}
  • 422 | {{- end -}}
423 | ` 424 | 425 | var chapterTmpl = template.Must(template.New("chapters").Parse(chaperTmplStr)) 426 | 427 | func buildChaptersBody(chapters []*Chapter) (string, error) { 428 | data := []struct { 429 | Title string 430 | Start string 431 | }{} 432 | 433 | for _, ch := range chapters { 434 | data = append(data, struct { 435 | Title string 436 | Start string 437 | }{ 438 | Title: ch.Title, 439 | Start: convertStartToString(ch.Start), 440 | }) 441 | } 442 | 443 | var buf bytes.Buffer 444 | if err := chapterTmpl.Execute(&buf, data); err != nil { 445 | return "", err 446 | } 447 | return buf.String(), nil 448 | } 449 | 450 | func (epm *EpisodeFrontMatter) PubDate() time.Time { 451 | return epm.pubDate 452 | } 453 | 454 | func (epm *EpisodeFrontMatter) Audio() *Audio { 455 | return epm.audio 456 | } 457 | 458 | func (epm *EpisodeFrontMatter) loadAudio(rootDir string) error { 459 | if epm.AudioFile == "" { 460 | return errors.New("no audio") 461 | } 462 | if epm.AudioFile != filepath.Base(epm.AudioFile) { 463 | return fmt.Errorf("subdirectories are not supported of audio file: %s", epm.AudioFile) 464 | } 465 | var err error 466 | epm.audio, err = LoadAudio(filepath.Join(rootDir, audioDir, epm.AudioFile)) 467 | return err 468 | } 469 | 470 | func loadEpisodeFromFile( 471 | rootDir, fname string, rootURL *url.URL, audioBaseURL *url.URL, loc *time.Location) (*Episode, error) { 472 | f, err := os.Open(fname) 473 | if err != nil { 474 | return nil, err 475 | } 476 | defer f.Close() 477 | 478 | slug := strings.TrimSuffix(filepath.Base(fname), filepath.Ext(fname)) 479 | ep := &Episode{ 480 | Slug: slug, 481 | URL: rootURL.JoinPath(episodeDir, slug+"/"), 482 | rootDir: rootDir, 483 | audioBaseURL: audioBaseURL, 484 | } 485 | if err := ep.loadEpisode(f, loc); err != nil { 486 | return nil, err 487 | } 488 | return ep, nil 489 | } 490 | 491 | func (ep *Episode) loadEpisode(r io.Reader, loc *time.Location) error { 492 | content, err := io.ReadAll(r) 493 | if err != nil { 494 | return err 495 | } 496 | // TODO: template 497 | /* 498 | The following patterns are possible for template processing. 499 | - Batch template processing before splitting frontmatter and body. 500 | - Template processing after splitting frontmatter and body 501 | - After splitting frontmatter and body, template only body. 502 | - No template processing (<- current implementation) 503 | */ 504 | frontMatter, body, err := splitFrontMatterAndBody(string(content)) 505 | if err != nil { 506 | return err 507 | } 508 | var ef EpisodeFrontMatter 509 | if err := yaml.NewDecoder(strings.NewReader(frontMatter)).Decode(&ef); err != nil { 510 | return err 511 | } 512 | 513 | ep.EpisodeFrontMatter = ef 514 | ep.RawBody = body 515 | 516 | return ep.init(loc) 517 | } 518 | -------------------------------------------------------------------------------- /internal/cast/feed.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "time" 7 | 8 | "github.com/eduncan911/podcast" 9 | ) 10 | 11 | type Feed struct { 12 | Channel *ChannelConfig 13 | Podcast *podcast.Podcast 14 | } 15 | 16 | func NewFeed(generator string, channel *ChannelConfig, pubDate, lastBuildDate time.Time) *Feed { 17 | pdTmp := podcast.New( 18 | channel.Title, channel.Link.String(), channel.Description, &pubDate, &lastBuildDate) 19 | 20 | pd := &pdTmp 21 | pd.Language = channel.Language.String() 22 | pd.AddAuthor(channel.Author, "") 23 | pd.IAuthor = channel.Author 24 | pd.IOwner = &podcast.Author{ 25 | Name: channel.Author, 26 | Email: channel.Email, 27 | } 28 | pd.AddAtomLink(channel.FeedURL().String()) 29 | if img := channel.ImageURL(); img != "" { 30 | pd.AddImage(img) 31 | } 32 | if len(channel.Categories) == 0 { 33 | pd.AddCategory("Technology", nil) // default category 34 | } 35 | for _, cat := range channel.Categories { 36 | pd.AddCategory(cat, nil) 37 | } 38 | pd.IExplicit = fmt.Sprintf("%t", channel.Explicit) 39 | pd.Generator = generator 40 | pd.Copyright = channel.Copyright 41 | if channel.Copyright != "" { 42 | pd.Copyright = fmt.Sprintf("© 2024 %s", channel.Author) // XXX: year is hardcoded 43 | } 44 | if channel.Private { 45 | pd.IBlock = "yes" 46 | } 47 | 48 | // deprecated but used tags 49 | pd.ISubtitle = channel.Description 50 | 51 | // XXX: pd.IType = "eposodic" // eposodic or serial. eduncan911/podcast does not support this 52 | 53 | return &Feed{ 54 | Channel: channel, 55 | Podcast: pd, 56 | } 57 | } 58 | 59 | func podcastEnclosureType(mediaType MediaType) (podcast.EnclosureType, bool) { 60 | switch mediaType { 61 | case MP3: 62 | return podcast.MP3, true 63 | case M4A: 64 | return podcast.M4A, true 65 | default: 66 | return 0, false 67 | } 68 | } 69 | 70 | func (f *Feed) AddEpisode(ep *Episode, audioBaseURL *url.URL) (int, error) { 71 | epLink, err := url.JoinPath(f.Channel.Link.String(), episodeDir, ep.Slug) 72 | if err != nil { 73 | return 0, err 74 | } 75 | chapterBody := ep.ChaptersBody 76 | description := chapterBody + ep.Body 77 | if description == "" { 78 | description = ep.Subtitle 79 | } 80 | if description == "" { 81 | description = ep.Title 82 | } 83 | epLink += "/" 84 | item := &podcast.Item{ 85 | Title: ep.Title, 86 | Description: description, 87 | Link: epLink, 88 | GUID: epLink, 89 | IExplicit: fmt.Sprintf("%t", f.Channel.Explicit), 90 | // don't use `item.AddDuration(d int64)`. It converts duration to string like "53:12", 91 | // but just use seconds is recommended by Apple. 92 | IDuration: fmt.Sprintf("%d", ep.Audio().Duration), 93 | } 94 | pd := ep.PubDate() 95 | item.AddPubDate(&pd) 96 | if img := f.Channel.ImageURL(); img != "" { 97 | item.AddImage(img) 98 | } 99 | 100 | // deprecated but used tags 101 | item.ISubtitle = ep.Subtitle 102 | item.IAuthor = f.Channel.Author 103 | 104 | // XXX: item.IEpisodeType = "full" // full, trailer or bonus. 105 | // // eduncan911/podcast does not support this 106 | // XXX: item.Content = ep.HTML() // is not supported yet 107 | 108 | audioURL := audioBaseURL.JoinPath(ep.AudioFile) 109 | encType, ok := podcastEnclosureType(ep.Audio().mediaType) 110 | if !ok { 111 | return 0, fmt.Errorf("unsupported media type: %s", ep.AudioFile) 112 | } 113 | item.AddEnclosure(audioURL.String(), encType, ep.Audio().FileSize) 114 | 115 | return f.Podcast.AddItem(*item) 116 | } 117 | -------------------------------------------------------------------------------- /internal/cast/index.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "text/template" 9 | 10 | "github.com/Masterminds/sprig/v3" 11 | ) 12 | 13 | type Page struct { 14 | RawFrontmatter, RawBody string 15 | 16 | Body string 17 | } 18 | 19 | func LoadPage(mdPath string, cfg *Config, episodes []*Episode) (*Page, error) { 20 | if _, err := os.Stat(mdPath); err != nil { 21 | if os.IsNotExist(err) { 22 | return &Page{}, nil 23 | } 24 | return nil, err 25 | } 26 | bs, err := os.ReadFile(mdPath) 27 | if err != nil { 28 | return nil, err 29 | } 30 | content := strings.ReplaceAll(strings.TrimSpace(string(bs)), "\r\n", "\n") 31 | 32 | var page *Page 33 | if !strings.HasPrefix(content, "---\n") { 34 | page = &Page{RawBody: content} 35 | } else { 36 | frontmater, body, err := splitFrontMatterAndBody(content) 37 | if err != nil { 38 | return nil, err 39 | } 40 | page = &Page{ 41 | RawFrontmatter: frontmater, 42 | RawBody: body, 43 | } 44 | } 45 | if err := page.build(cfg, episodes); err != nil { 46 | return nil, err 47 | } 48 | return page, nil 49 | } 50 | 51 | func LoadIndex(rootDir string, cfg *Config, episodes []*Episode) (*Page, error) { 52 | idxMD := filepath.Join(rootDir, "index.md") 53 | return LoadPage(idxMD, cfg, episodes) 54 | } 55 | 56 | func (idx *Page) build(cfg *Config, episodes []*Episode) error { 57 | tmpl, err := template.New("index").Funcs(sprig.FuncMap()). 58 | Funcs(template.FuncMap{"html": htmlFunc}). 59 | Parse(idx.RawBody) 60 | if err != nil { 61 | return err 62 | } 63 | arg := struct { 64 | Channel *ChannelConfig 65 | Episodes []*Episode 66 | }{ 67 | Channel: cfg.Channel, 68 | Episodes: episodes, 69 | } 70 | var buf bytes.Buffer 71 | if err := tmpl.Execute(&buf, arg); err != nil { 72 | return err 73 | } 74 | 75 | md := NewMarkdown() 76 | var mdBuf bytes.Buffer 77 | if err := md.Convert(buf.Bytes(), &mdBuf); err != nil { 78 | return err 79 | } 80 | idx.Body = mdBuf.String() 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /internal/cast/markdown.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import ( 4 | "github.com/yuin/goldmark" 5 | "github.com/yuin/goldmark/extension" 6 | "github.com/yuin/goldmark/parser" 7 | "github.com/yuin/goldmark/renderer/html" 8 | ) 9 | 10 | func NewMarkdown() goldmark.Markdown { 11 | return goldmark.New( 12 | goldmark.WithExtensions( 13 | extension.GFM, 14 | extension.DefinitionList, 15 | extension.Footnote, 16 | extension.Typographer, 17 | extension.CJK, 18 | ), 19 | goldmark.WithParserOptions( 20 | parser.WithAutoHeadingID(), 21 | ), 22 | goldmark.WithRendererOptions( 23 | html.WithUnsafe(), 24 | ), 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /internal/cast/mediatype.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | type MediaType int 4 | 5 | //go:generate go run golang.org/x/tools/cmd/stringer -type=MediaType 6 | const ( 7 | MP3 MediaType = iota + 1 8 | M4A 9 | ) 10 | 11 | var mediaTypeExtMap = map[string]MediaType{ 12 | ".mp3": MP3, 13 | ".m4a": M4A, 14 | } 15 | 16 | func GetMediaTypeByExt(ext string) (MediaType, bool) { 17 | mt, ok := mediaTypeExtMap[ext] 18 | return mt, ok 19 | } 20 | 21 | func IsSupportedMediaExt(ext string) bool { 22 | _, ok := mediaTypeExtMap[ext] 23 | return ok 24 | } 25 | -------------------------------------------------------------------------------- /internal/cast/mediatype_string.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=MediaType"; DO NOT EDIT. 2 | 3 | package cast 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[MP3-1] 12 | _ = x[M4A-2] 13 | } 14 | 15 | const _MediaType_name = "MP3M4A" 16 | 17 | var _MediaType_index = [...]uint8{0, 3, 6} 18 | 19 | func (i MediaType) String() string { 20 | i -= 1 21 | if i < 0 || i >= MediaType(len(_MediaType_index)-1) { 22 | return "MediaType(" + strconv.FormatInt(int64(i+1), 10) + ")" 23 | } 24 | return _MediaType_name[_MediaType_index[i]:_MediaType_index[i+1]] 25 | } 26 | -------------------------------------------------------------------------------- /internal/cast/page.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | func splitFrontMatterAndBody(content string) (string, string, error) { 9 | content = strings.ReplaceAll(content, "\r\n", "\n") 10 | stuff := strings.SplitN(content, "---\n", 3) 11 | if strings.TrimSpace(stuff[0]) != "" { 12 | return "", "", errors.New("no front matter") 13 | } 14 | return strings.TrimSpace(stuff[1]), strings.TrimSpace(stuff[2]), nil 15 | } 16 | -------------------------------------------------------------------------------- /internal/cast/scaffold.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | //go:embed testdata/init testdata/init/template/_layout.tmpl 12 | var embedFS embed.FS 13 | 14 | func Scaffold(outDir string) error { 15 | root := "testdata/init" 16 | err := fs.WalkDir(embedFS, root, func(path string, d fs.DirEntry, err error) error { 17 | if err != nil { 18 | return err 19 | } 20 | dstPath, err := filepath.Rel(root, path) 21 | if err != nil { 22 | return err 23 | } 24 | outPath := filepath.Join(outDir, dstPath) 25 | if d.IsDir() { 26 | return os.MkdirAll(outPath, 0755) 27 | } 28 | data, err := embedFS.ReadFile(path) 29 | if err != nil { 30 | return err 31 | } 32 | log.Printf("Writing %q\n", outPath) 33 | return os.WriteFile(outPath, data, 0644) 34 | }) 35 | if err != nil { 36 | return err 37 | } 38 | log.Printf("✨ Initialized your brand new podcast project under %q directory\n", outDir) 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/cast/template.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import ( 4 | "html/template" 5 | "io" 6 | "path/filepath" 7 | 8 | "github.com/Masterminds/sprig/v3" 9 | ) 10 | 11 | const templateDir = "template" 12 | 13 | type castTemplate struct { 14 | *template.Template 15 | } 16 | 17 | // XXX: define argument types 18 | 19 | func loadTemplate(rootDir string) (*castTemplate, error) { 20 | base := filepath.Join(rootDir, templateDir) 21 | glob := filepath.Join(base, "*.tmpl") 22 | 23 | tmpl, err := template.ParseGlob(glob) 24 | if err != nil { 25 | return nil, err 26 | } 27 | return &castTemplate{tmpl}, nil 28 | } 29 | 30 | func htmlFunc(html string) template.HTML { 31 | return template.HTML(html) 32 | } 33 | 34 | func (ct *castTemplate) Execute(w io.Writer, layout, name string, data interface{}) error { 35 | return template.Must(template.Must( 36 | ct.Lookup(layout).Clone()). 37 | Funcs(sprig.FuncMap()). 38 | Funcs(template.FuncMap{"html": htmlFunc}). 39 | AddParseTree("content", ct.Lookup(name).Tree)). 40 | ExecuteTemplate(w, layout, data) 41 | } 42 | -------------------------------------------------------------------------------- /internal/cast/testdata/init/README.md: -------------------------------------------------------------------------------- 1 | # Your Podcast Site 2 | 3 | ## What to do next 4 | 5 | 1. Congiguration 6 | - adjust the `podbard.yam` file to fit your podcast site 7 | - locate `static/images/artwwork.jpg` if you like 8 | 2. Locate the audio files (MP3 or M4A) 9 | - put the audio files in the `audio/` directory (You should remove the sample files) 10 | 3. Locate the episode files (Markdown) 11 | - put the episode files in the `episode/` directory (You should remove the sample files) 12 | - Hint: You can use the `podbard episode ` subcommand to create a new episode file 13 | 4. Build the site 14 | - run `podbard build` to build the site to the `public/` directory 15 | 16 | See. for more details. 17 | -------------------------------------------------------------------------------- /internal/cast/testdata/init/audio/sample.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Songmu/podbard/7c911caa5253e989c409bfe5a87dbdca5bab93b9/internal/cast/testdata/init/audio/sample.mp3 -------------------------------------------------------------------------------- /internal/cast/testdata/init/episode/sample.md: -------------------------------------------------------------------------------- 1 | --- 2 | audio: sample.mp3 3 | date: 2024-09-01 4 | title: sample episode 5 | description: This is a sample episode. 6 | --- 7 | 8 | # Sample Episode 9 | 10 | This is a sample episode. 11 | -------------------------------------------------------------------------------- /internal/cast/testdata/init/index.md: -------------------------------------------------------------------------------- 1 | # {{.Channel.Title}} 2 | 3 | {{.Channel.Description}} 4 | 5 | ## RSS Feed 6 | 7 | 8 | ## Episodes 9 | {{range .Episodes -}} 10 | - [{{.Title}}]({{.URL.Path}}) ({{.PubDate.Format "2006-01-02 15:04"}}) 11 | {{end}} 12 | -------------------------------------------------------------------------------- /internal/cast/testdata/init/podbard.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Songmu/podbard/refs/heads/main/schema.yaml 2 | 3 | channel: 4 | link: https://podbard.example.com 5 | title: sample 6 | description: sample description 7 | language: ja-JP 8 | category: "Technology" 9 | author: your name 10 | email: your-email@example.com 11 | artwork: "images/artwork.jpg" # url or path 12 | 13 | timezone: Asia/Tokyo 14 | # audio_bucket_url: https://s3-ap-northeast-1.amazonaws.com/podbard.example.com/audio 15 | -------------------------------------------------------------------------------- /internal/cast/testdata/init/static/css/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #5a3d31; 3 | --secondary-color: #f4f4f4; 4 | --highlight-color: #e9d9c7; 5 | --background-color: #f5f5f0; 6 | --text-color: #333; 7 | --border-color: #c4c4b7; 8 | --light-border-color: #ccc; 9 | --block-background: #fff8e7; 10 | --blockquote-bg: #f5f5f0; 11 | --blockquote-border: 4px solid var(--primary-color); 12 | --padding: 1em 1.25em; 13 | } 14 | 15 | body { 16 | font-family: "Helvetica Neue", Arial, "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo, sans-serif; 17 | line-height: 1.7; 18 | color: var(--text-color); 19 | background-color: var(--background-color); 20 | margin: 0; 21 | padding: 0; 22 | 23 | a { 24 | color: var(--primary-color); 25 | text-decoration: none; 26 | 27 | &:hover { 28 | text-decoration: underline; 29 | } 30 | } 31 | 32 | header { 33 | background-color: var(--primary-color); 34 | color: var(--secondary-color); 35 | padding: var(--padding); 36 | 37 | h1 { 38 | margin: 0; 39 | font-size: 1.9em; 40 | font-weight: bold; 41 | a { 42 | color: var(--secondary-color); 43 | 44 | &:hover { 45 | text-decoration: none; 46 | } 47 | } 48 | } 49 | 50 | p { 51 | margin: 0.3em 0 0; 52 | font-size: 1em; 53 | color: #e0d6d1; 54 | } 55 | } 56 | 57 | main { 58 | max-width: 80em; 59 | padding: var(--padding); 60 | border: 1px solid var(--border-color); 61 | margin: auto; 62 | background-color: var(--block-background); 63 | 64 | h1 { font-size: 2.00em; margin-bottom: 0.5em; } 65 | h2 { font-size: 1.68em; margin-bottom: 0.5em; } 66 | h3 { font-size: 1.41em; margin-bottom: 0.5em; } 67 | h4 { font-size: 1.18em; margin-bottom: 0.5em; } 68 | h5, h6 { font-size: 1em; margin-bottom: 0.5em; } 69 | h6 { font-weight: normal; } 70 | p { font-size: 1em; margin-bottom: 0.7em; } 71 | 72 | input { 73 | border: 1px solid var(--border-color); 74 | padding: 0.5em; 75 | font-size: 1em; 76 | width: 100%; 77 | max-width: 40em; 78 | margin-bottom: 1em; 79 | background-color: #fff; 80 | } 81 | 82 | figure { margin: 1.5em 0; text-align: center; } 83 | audio { width: 100%; max-width: 600px; } 84 | 85 | code { 86 | background-color: #f4f4f4; 87 | border: 1px solid var(--light-border-color); 88 | padding: 0.2em 0.4em; 89 | border-radius: 3px; 90 | font-family: Consolas, Monaco, 'Courier New', monospace; 91 | font-size: 0.95em; 92 | } 93 | 94 | pre { 95 | background-color: #f9f9f9; 96 | border: 1px solid var(--light-border-color); 97 | padding: 1em; 98 | overflow-x: auto; 99 | border-radius: 5px; 100 | max-width: 100%; 101 | 102 | code { 103 | background: none; 104 | border: none; 105 | padding: 0; 106 | font-size: 1em; 107 | color: var(--text-color); 108 | } 109 | } 110 | 111 | blockquote { 112 | background-color: var(--blockquote-bg); 113 | border-left: var(--blockquote-border); 114 | margin: 0.7em 0; 115 | padding: 0.75em 1.5em; 116 | font-style: italic; 117 | color: var(--text-color); 118 | 119 | p { 120 | margin: 0; 121 | font-size: 1em; 122 | } 123 | } 124 | 125 | table { 126 | width: 100%; 127 | border-collapse: collapse; 128 | margin-bottom: 1.5em; 129 | background-color: var(--block-background); 130 | 131 | th, td { 132 | border: 1px solid var(--border-color); 133 | padding: 0.75em; 134 | text-align: left; 135 | font-size: 1em; 136 | color: var(--text-color); 137 | } 138 | 139 | th { background-color: var(--highlight-color); font-weight: bold; } 140 | td { background-color: #fff; } 141 | tr:nth-child(even) td { background-color: var(--background-color); } 142 | } 143 | 144 | dl { 145 | margin-bottom: 1.5em; 146 | 147 | dt { 148 | font-weight: bold; 149 | margin-top: 0.5em; 150 | color: #3b2f2f; 151 | } 152 | 153 | dd { 154 | margin-left: 1.5em; 155 | margin-bottom: 0.5em; 156 | font-size: 1em; 157 | color: var(--text-color); 158 | } 159 | } 160 | 161 | aside.post-meta { 162 | background-color: var(--highlight-color); 163 | padding: 0.7em; 164 | margin-top: 1.25em; 165 | border-right: 4px solid var(--primary-color); 166 | text-align: right; 167 | } 168 | 169 | nav.adjacent-episodes ul { 170 | list-style: none; 171 | padding: 0; 172 | margin: 1em 0; 173 | text-align: right; 174 | 175 | li { 176 | display: inline; 177 | padding: 0 0.7em; 178 | &:nth-child(n+2) { border-left: 1px solid var(--primary-color); } 179 | 180 | a[rel="prev"]::before { content: '\02190 '; } /* larr */ 181 | a[rel="next"]::after { content: ' \02192'; } /* rarr */ 182 | } 183 | } 184 | } 185 | 186 | footer { 187 | background-color: var(--primary-color); 188 | color: white; 189 | padding: var(--padding); 190 | text-align: right; 191 | margin-top: 2em; 192 | 193 | section h2 { 194 | font-size: 1.18em; 195 | padding: 0; 196 | margin: 0; 197 | } 198 | 199 | ul { 200 | list-style: none; 201 | padding: 0; 202 | margin: 0; 203 | li { display: inline; margin-right: 0.7em; } 204 | } 205 | 206 | a, ul li a { color: #e0d6d1; } 207 | small { display: block; margin-top: 0.7em; } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /internal/cast/testdata/init/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Songmu/podbard/7c911caa5253e989c409bfe5a87dbdca5bab93b9/internal/cast/testdata/init/static/favicon.ico -------------------------------------------------------------------------------- /internal/cast/testdata/init/static/images/artwork.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Songmu/podbard/7c911caa5253e989c409bfe5a87dbdca5bab93b9/internal/cast/testdata/init/static/images/artwork.jpg -------------------------------------------------------------------------------- /internal/cast/testdata/init/template/_layout.tmpl: -------------------------------------------------------------------------------- 1 | {{define "layout" -}} 2 | 3 | 4 | 5 | {{- if .Channel.Private -}} 6 | 7 | 8 | {{- end}} 9 | 10 | {{.Page.Title}} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 |

{{.Channel.Title}}

33 |

{{.Channel.Description}}

34 |
35 | 36 |
37 | {{- template "content" . -}} 38 |
39 | 40 |
41 |
42 |

Subscribe

43 |
    44 |
  • RSS
  • 45 |
46 |
47 |
© 2024 {{.Channel.Author}}
48 |
Generated by podbard
49 |
50 | 51 | 52 | 53 | {{- end}} 54 | -------------------------------------------------------------------------------- /internal/cast/testdata/init/template/episode.tmpl: -------------------------------------------------------------------------------- 1 | {{define "episode" -}} 2 | 3 |

{{.Episode.Title}}

4 |
5 | 6 |
7 | 8 | {{if ne .Episode.ChaptersBody "" -}} 9 | 13 | {{end}} 14 | 15 | {{.Body}} 16 | 17 | 22 | 23 | 37 | 38 | {{- end}} 39 | -------------------------------------------------------------------------------- /internal/cast/testdata/init/template/index.tmpl: -------------------------------------------------------------------------------- 1 | {{define "index" -}} 2 | {{.Body}} 3 | {{- end}} 4 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package podbard_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "testing" 7 | 8 | "github.com/Songmu/podbard" 9 | ) 10 | 11 | func TestRun(t *testing.T) { 12 | if err := run("init", ".songmu"); err != nil { 13 | t.Errorf("unexpected error while podbard init: %v", err) 14 | } 15 | 16 | if err := run("-C", "testdata/dev", "episode", "--save-meta", "1.mp3"); err != nil { 17 | t.Errorf("unexpected error while podbard episode: %v", err) 18 | } 19 | 20 | if err := run("-C", "testdata/dev", "build"); err != nil { 21 | t.Errorf("unexpected error while podbard build: %v", err) 22 | } 23 | } 24 | 25 | func run(argv ...string) error { 26 | return podbard.Run(context.Background(), argv, io.Discard, io.Discard) 27 | } 28 | -------------------------------------------------------------------------------- /podbard.go: -------------------------------------------------------------------------------- 1 | package podbard 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | const cmdName = "podbard" 13 | 14 | // Run the podbard 15 | func Run(ctx context.Context, argv []string, outw, errw io.Writer) error { 16 | log.SetOutput(errw) 17 | 18 | app := cli.NewApp() 19 | app.Usage = "A primitive podcast site generator" 20 | app.Writer = outw 21 | app.ErrWriter = errw 22 | app.Version = fmt.Sprintf("v%s (rev:%s)", version, revision) 23 | app.Flags = []cli.Flag{ 24 | &cli.StringFlag{ 25 | Name: "C", 26 | Value: ".", 27 | Usage: "change to directory", 28 | }, 29 | } 30 | app.Commands = []*cli.Command{ 31 | commandInit, 32 | commandEpisode, 33 | commandBuild, 34 | } 35 | return app.RunContext(ctx, append([]string{cmdName}, argv...)) 36 | } 37 | -------------------------------------------------------------------------------- /schema.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema 2 | 3 | "$schema": https://json-schema.org/draft/2020-12/schema 4 | title: Podbard Configuration Schema 5 | type: object 6 | required: 7 | - timezone 8 | - channel 9 | properties: 10 | timezone: 11 | type: string 12 | description: "timezone of the podcast" 13 | audio_bucket_url: 14 | type: string 15 | description: | 16 | Optinal setting to specify the URL of the audio bucket. 17 | This setting is used if the audio files are to be placed in a different location, 18 | such as S3 or R2. 19 | channel: 20 | type: object 21 | required: 22 | - link 23 | - title 24 | - description 25 | - language 26 | - category 27 | - author 28 | - email 29 | properties: 30 | link: 31 | type: string 32 | format: uri 33 | pattern: "^https?://" 34 | description: "URL of the podcast" 35 | title: 36 | type: string 37 | description: "Title of the podcast" 38 | description: 39 | type: string 40 | description: "Description of the podcast" 41 | language: 42 | type: string 43 | description: | 44 | BCP 47 language tag like ja-JP, zh-CN, en-US describing the language of the podcast 45 | examples: [ja-JP, zh-CN, en-US] 46 | category: 47 | type: [string, array] 48 | items: 49 | type: string 50 | description: "Category for Apple podcasts. Subcategories are currently not supported." 51 | author: 52 | type: string 53 | description: "Author of the podcast" 54 | email: 55 | type: string 56 | format: email 57 | description: "Email address of the podcast" 58 | artwork: 59 | type: string 60 | description: | 61 | Artwork for podcast site. Specify either the full URL or a path relative to the site URL. 62 | explicit: 63 | type: boolean 64 | description: "Explicit content or not. Default is false." 65 | default: false 66 | copyright: 67 | type: string 68 | description: | 69 | Optional setting item for copyright notice. If not set, "© 2024 $author" is used by default. 70 | private: 71 | type: boolean 72 | description: "Private podcast or not. Default is false." 73 | default: false 74 | -------------------------------------------------------------------------------- /scripts/check_mp3.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/bogem/id3v2/v2" 8 | "github.com/k0kubun/pp/v3" 9 | ) 10 | 11 | func main() { 12 | if err := checkMP3(os.Args[1:]); err != nil { 13 | fmt.Println(err) 14 | os.Exit(1) 15 | } 16 | } 17 | 18 | func checkMP3(argv []string) error { 19 | 20 | fname := argv[0] 21 | f, err := os.Open(fname) 22 | if err != nil { 23 | return err 24 | } 25 | defer f.Close() 26 | 27 | tag, err := id3v2.ParseReader(f, id3v2.Options{Parse: true}) 28 | if err != nil { 29 | return err 30 | } 31 | chapFrames := tag.GetFrames(tag.CommonID("CHAP")) 32 | for _, frame := range chapFrames { 33 | chapFrame, ok := frame.(id3v2.ChapterFrame) 34 | if ok { 35 | pp.Println(chapFrame) 36 | } 37 | } 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /scripts/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Songmu/podbard/scripts 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/bogem/id3v2/v2 v2.1.4 7 | github.com/goccy/go-yaml v1.12.0 8 | github.com/k0kubun/pp/v3 v3.2.0 9 | github.com/sashabaranov/go-openai v1.29.1 10 | github.com/writeas/go-strip-markdown/v2 v2.1.1 11 | ) 12 | 13 | require ( 14 | github.com/fatih/color v1.10.0 // indirect 15 | github.com/mattn/go-colorable v0.1.13 // indirect 16 | github.com/mattn/go-isatty v0.0.16 // indirect 17 | golang.org/x/sys v0.6.0 // indirect 18 | golang.org/x/text v0.3.8 // indirect 19 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /scripts/go.sum: -------------------------------------------------------------------------------- 1 | github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= 2 | github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= 3 | github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= 4 | github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= 5 | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= 6 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 7 | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= 8 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 9 | github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= 10 | github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= 11 | github.com/goccy/go-yaml v1.12.0 h1:/1WHjnMsI1dlIBQutrvSMGZRQufVO3asrHfTwfACoPM= 12 | github.com/goccy/go-yaml v1.12.0/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= 13 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 14 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 15 | github.com/k0kubun/pp/v3 v3.2.0 h1:h33hNTZ9nVFNP3u2Fsgz8JXiF5JINoZfFq4SvKJwNcs= 16 | github.com/k0kubun/pp/v3 v3.2.0/go.mod h1:ODtJQbQcIRfAD3N+theGCV1m/CBxweERz2dapdz1EwA= 17 | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= 18 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 19 | github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 20 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 21 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 22 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 23 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 24 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 25 | github.com/sashabaranov/go-openai v1.29.1 h1:AlB+vwpg1tibwr83OKXLsI4V1rnafVyTlw0BjR+6WUM= 26 | github.com/sashabaranov/go-openai v1.29.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= 27 | github.com/writeas/go-strip-markdown/v2 v2.1.1 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28= 28 | github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA= 29 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 30 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 31 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 32 | golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= 33 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 34 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 35 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 36 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 37 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 38 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 39 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 40 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 41 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 42 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 43 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 44 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 46 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 49 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 50 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 51 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 52 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 53 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 54 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 55 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 56 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 57 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 58 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 59 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 60 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 61 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 62 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 63 | -------------------------------------------------------------------------------- /scripts/rename.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | func main() { 12 | if err := rename(os.Args[1:]); err != nil { 13 | fmt.Println(err) 14 | os.Exit(1) 15 | } 16 | } 17 | 18 | func rename(argv []string) error { 19 | dir := argv[0] 20 | from := argv[1] 21 | to := argv[2] 22 | 23 | return walkAndRename(dir, from, to) 24 | } 25 | func walkAndRename(dir, from, to string) error { 26 | 27 | return filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error { 28 | if err != nil { 29 | return err 30 | } 31 | return renameFileOrDir(path, from, to, info) 32 | }) 33 | } 34 | 35 | func renameFileOrDir(path, from, to string, info fs.FileInfo) error { 36 | if strings.Contains(info.Name(), from) { 37 | newName := strings.ReplaceAll(info.Name(), from, to) 38 | newPath := filepath.Join(filepath.Dir(path), newName) 39 | err := os.Rename(path, newPath) 40 | if err != nil { 41 | return fmt.Errorf("failed to rename %s to %s: %w", path, newPath, err) 42 | } 43 | fmt.Printf("Renamed: %s -> %s\n", path, newPath) 44 | return renameContentsIfFile(newPath, from, to) 45 | } 46 | return renameContentsIfFile(path, from, to) 47 | } 48 | 49 | func renameContentsIfFile(path, from, to string) error { 50 | if strings.Contains(path, from) { 51 | // XXX 52 | path = strings.ReplaceAll(path, from, to) 53 | } 54 | fmt.Println(path) 55 | fi, err := os.Stat(path) 56 | if err != nil { 57 | return err 58 | } 59 | if fi.Name() == ".git" { 60 | return filepath.SkipDir 61 | } 62 | 63 | if !fi.IsDir() { 64 | content, err := os.ReadFile(path) 65 | if err != nil { 66 | return err 67 | } 68 | newContent := strings.ReplaceAll(string(content), from, to) 69 | if string(content) != newContent { 70 | err = os.WriteFile(path, []byte(newContent), 0644) 71 | if err != nil { 72 | return fmt.Errorf("failed to write to file %s: %w", path, err) 73 | } 74 | fmt.Printf("Replaced content in: %s\n", path) 75 | } 76 | } 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /scripts/text_to_speech.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/goccy/go-yaml" 15 | "github.com/sashabaranov/go-openai" 16 | stripmd "github.com/writeas/go-strip-markdown/v2" 17 | ) 18 | 19 | func main() { 20 | if err := Main(os.Args[1:]); err != nil { 21 | fmt.Println(err) 22 | os.Exit(1) 23 | } 24 | } 25 | 26 | func Main(argv []string) error { 27 | 28 | fs := flag.NewFlagSet( 29 | "text_to_speech.go", flag.ContinueOnError) 30 | 31 | fs.Usage = func() { 32 | fmt.Println("Usage: go run ./text_to_speech.go ") 33 | fs.PrintDefaults() 34 | } 35 | dryRun := fs.Bool("dry-run", false, "dry run") 36 | if err := fs.Parse(argv); err != nil { 37 | return err 38 | } 39 | 40 | if fs.NArg() < 1 { 41 | return errors.New("Usage: go run text_to_speech.go ") 42 | } 43 | mdFile := fs.Arg(0) 44 | 45 | cli := openai.NewClient(os.Getenv("OPENAI_API_KEY")) 46 | 47 | data, err := os.ReadFile(mdFile) 48 | if err != nil { 49 | return fmt.Errorf("Error reading file: %w", err) 50 | } 51 | fm, body, err := splitFrontMatterAndBody(string(data)) 52 | 53 | efm := &EpisodeFrontMatter{} 54 | if err := yaml.Unmarshal([]byte(fm), efm); err != nil { 55 | return fmt.Errorf("Error unmarshalling front matter:", err) 56 | } 57 | body = stripmd.Strip(body) 58 | 59 | body = efm.Title + "\n" + body 60 | 61 | if *dryRun { 62 | fmt.Println(body) 63 | return nil 64 | } 65 | 66 | audioFile := efm.AudioFile 67 | if audioFile != "" { 68 | if filepath.Ext(audioFile) != ".mp3" { 69 | return errors.New("audio file must have .mp3 extension") 70 | } 71 | } else { 72 | audioFile = strings.TrimSuffix(filepath.Base(mdFile), ".md") + ".mp3" 73 | } 74 | baseDir := filepath.Join(filepath.Dir(filepath.Dir(mdFile)), "audio") 75 | audioFile = filepath.Join(baseDir, audioFile) 76 | wavFile := strings.TrimSuffix(audioFile, ".mp3") + ".wav" 77 | 78 | ctx := context.Background() 79 | resp, err := cli.CreateSpeech(ctx, openai.CreateSpeechRequest{ 80 | Model: openai.TTSModel1, 81 | Voice: openai.VoiceEcho, 82 | ResponseFormat: openai.SpeechResponseFormatWav, 83 | Input: body, 84 | }) 85 | if err != nil { 86 | return fmt.Errorf("Error creating speech: %w", err) 87 | } 88 | defer resp.Close() 89 | 90 | f, err := os.Create(wavFile) 91 | if err != nil { 92 | return fmt.Errorf("Error creating audio file: %w", err) 93 | } 94 | defer f.Close() 95 | 96 | if _, err := io.Copy(f, resp); err != nil { 97 | return fmt.Errorf("Error writing audio file: %w", err) 98 | } 99 | 100 | com := exec.Command("ffmpeg", "-i", wavFile, "-ab", "32k", "-ac", "1", "-ar", "44100", audioFile) 101 | com.Stdin = os.Stdin 102 | com.Stdout = os.Stdout 103 | com.Stderr = os.Stderr 104 | 105 | if err := com.Run(); err != nil { 106 | return fmt.Errorf("Error converting audio file: %w", err) 107 | } 108 | if err := os.Remove(wavFile); err != nil { 109 | return fmt.Errorf("Error removing wav file: %w", err) 110 | } 111 | return nil 112 | } 113 | 114 | func splitFrontMatterAndBody(content string) (string, string, error) { 115 | content = strings.ReplaceAll(content, "\r\n", "\n") 116 | stuff := strings.SplitN(content, "---\n", 3) 117 | if strings.TrimSpace(stuff[0]) != "" { 118 | return "", "", errors.New("no front matter") 119 | } 120 | return strings.TrimSpace(stuff[1]), strings.TrimSpace(stuff[2]), nil 121 | } 122 | 123 | type EpisodeFrontMatter struct { 124 | AudioFile string `yaml:"audio"` 125 | Title string `yaml:"title"` 126 | Date string `yaml:"date"` 127 | Subtitle string `yaml:"subtitle"` 128 | } 129 | -------------------------------------------------------------------------------- /testdata/dev/audio/1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Songmu/podbard/7c911caa5253e989c409bfe5a87dbdca5bab93b9/testdata/dev/audio/1.mp3 -------------------------------------------------------------------------------- /testdata/dev/audio/2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Songmu/podbard/7c911caa5253e989c409bfe5a87dbdca5bab93b9/testdata/dev/audio/2.mp3 -------------------------------------------------------------------------------- /testdata/dev/audio/3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Songmu/podbard/7c911caa5253e989c409bfe5a87dbdca5bab93b9/testdata/dev/audio/3.mp3 -------------------------------------------------------------------------------- /testdata/dev/episode/1.md: -------------------------------------------------------------------------------- 1 | --- 2 | audio: 1.mp3 3 | date: 2024-09-01 4 | title: 1st Episode 5 | description: Bran new podcast 6 | --- 7 | 8 | ## Good morning! 9 | 10 | Hello, podbard! 11 | 12 | このエピソードは、新しいポッドキャストの始まりです。 13 | -------------------------------------------------------------------------------- /testdata/dev/episode/2.md: -------------------------------------------------------------------------------- 1 | --- 2 | audio: 2.mp3 3 | title: Episode 2 4 | date: 2024-09-02T22:24:35+09:00 5 | description: Have a good day! 6 | --- 7 | 8 | ## Have a good day! 9 | 10 | yay! 11 | -------------------------------------------------------------------------------- /testdata/dev/episode/3.md: -------------------------------------------------------------------------------- 1 | --- 2 | audio: 3.mp3 3 | title: Episode 3 4 | date: 2024-09-05T01:21:16+09:00 5 | description: Good bye! 6 | --- 7 | 8 | ## Good bye! 9 | 10 | bye bye 11 | -------------------------------------------------------------------------------- /testdata/dev/episode/4.md: -------------------------------------------------------------------------------- 1 | --- 2 | audio: 3.mp3 3 | title: CSSテスト 4 | date: 2024-09-06T01:21:16+09:00 5 | description: テストです。 6 | --- 7 | 8 | このページは、CSSのテストを行うための日本語サンプルページです。以下に、さまざまなHTML要素のサンプルを含めています。 9 | 10 | ## 見出し2 11 | 12 | これは二つ目の見出しです。 13 | 14 | ### 見出し3 15 | 16 | これは三つ目の見出しです。 17 | 18 | #### 見出し4 19 | 20 | これは四つ目の見出しです。 21 | 22 | ##### 見出し5 23 | 24 | これは五つ目の見出しです。 25 | 26 | ###### 見出し6 27 | 28 | これは六つ目の見出しです。 29 | 30 | --- 31 | 32 | ## 段落 33 | 34 | これは通常の段落です。CSSを適用する際のテキスト表示の確認に利用できます。 35 | 36 | これは二つ目の段落です。 **太字** や *斜体* などの装飾も確認できます。 37 | 38 | ## リスト 39 | 40 | - 最初の項目 41 | - 二番目の項目 42 | - 三番目の項目 43 | 44 | 1. 番号付きリストの一つ目 45 | 2. 二つ目 46 | 3. 三つ目 47 | 48 | ## 引用 49 | 50 | > これは引用です。スタイルが適切に表示されるか確認してください。 51 | 52 | 引用元 53 | 54 | ## テーブル 55 | 56 | | 名前 | 年齢 | 職業 | 57 | | --------- | ---- | --------- | 58 | | 佐藤太郎 | 30 | エンジニア | 59 | | 山田花子 | 25 | デザイナー | 60 | | 田中一郎 | 35 | マネージャー | 61 | 62 | ## リンク 63 | 64 | [これはリンクです](https://example.com)。 65 | 66 | ## コードブロック 67 | 68 | ### インラインコード 69 | 70 | 出力は`print("こんにちは世界")`のようにおこなえます。 71 | 72 | ### コードブロック 73 | 74 | ```go 75 | package main 76 | 77 | import "fmt" 78 | 79 | func main() { 80 | fmt.Println("こんにちは、世界") 81 | } 82 | ``` 83 | 84 | ## 画像 85 | 86 | ![代替テキスト](/images/artwork.jpg) 87 | 88 | ## 定義リスト 89 | 90 | index.md 91 | : indexページ 92 | 93 | podbard.yaml 94 | : Podbardtの設定ファイル 95 | 96 | audio/ 97 | : 各エピソードの音声ファイル配置ディレクトリ 98 | 99 | episode/ 100 | : 各エピソードのMarkdownファイル配置ディレクトリ 101 | 102 | template/ 103 | : テンプレートファイル配置ディレクトリ 104 | 105 | static/ 106 | : 静的ファイル配置ディレクトリ 107 | -------------------------------------------------------------------------------- /testdata/dev/index.md: -------------------------------------------------------------------------------- 1 | # {{.Channel.Title}} 2 | 3 | {{.Channel.Description}} 4 | 5 | ## RSS Feed 6 | 7 | 8 | ## Episodes 9 | {{range .Episodes -}} 10 | - [{{.Title}}]({{.URL.Path}}) ({{.PubDate.Format "2006-01-02 15:04"}}) 11 | {{end}} 12 | -------------------------------------------------------------------------------- /testdata/dev/podbard.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Songmu/podbard/refs/heads/main/schema.yaml 2 | channel: 3 | link: https://podbard.example.com 4 | title: sample 5 | description: sample description 6 | language: ja-JP 7 | category: "Technology" 8 | author: Songmu 9 | email: y.songmu@gmail.com 10 | artwork: "images/artwork.jpg" # url or path 11 | 12 | timezone: Asia/Tokyo 13 | audio_bucket_url: https://s3-ap-northeast-1.amazonaws.com/podbard.example.com/audio 14 | -------------------------------------------------------------------------------- /testdata/dev/static/css/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #5a3d31; 3 | --secondary-color: #f4f4f4; 4 | --highlight-color: #e9d9c7; 5 | --background-color: #f5f5f0; 6 | --text-color: #333; 7 | --border-color: #c4c4b7; 8 | --light-border-color: #ccc; 9 | --block-background: #fff8e7; 10 | --blockquote-bg: #f5f5f0; 11 | --blockquote-border: 4px solid var(--primary-color); 12 | --padding: 1em 1.25em; 13 | } 14 | 15 | body { 16 | font-family: "Helvetica Neue", Arial, "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo, sans-serif; 17 | line-height: 1.7; 18 | color: var(--text-color); 19 | background-color: var(--background-color); 20 | margin: 0; 21 | padding: 0; 22 | 23 | a { 24 | color: var(--primary-color); 25 | text-decoration: none; 26 | 27 | &:hover { 28 | text-decoration: underline; 29 | } 30 | } 31 | 32 | header { 33 | background-color: var(--primary-color); 34 | color: var(--secondary-color); 35 | padding: var(--padding); 36 | 37 | h1 { 38 | margin: 0; 39 | font-size: 1.9em; 40 | font-weight: bold; 41 | a { 42 | color: var(--secondary-color); 43 | 44 | &:hover { 45 | text-decoration: none; 46 | } 47 | } 48 | } 49 | 50 | p { 51 | margin: 0.3em 0 0; 52 | font-size: 1em; 53 | color: #e0d6d1; 54 | } 55 | } 56 | 57 | main { 58 | max-width: 80em; 59 | padding: var(--padding); 60 | border: 1px solid var(--border-color); 61 | margin: auto; 62 | background-color: var(--block-background); 63 | 64 | h1 { font-size: 2.00em; margin-bottom: 0.5em; } 65 | h2 { font-size: 1.68em; margin-bottom: 0.5em; } 66 | h3 { font-size: 1.41em; margin-bottom: 0.5em; } 67 | h4 { font-size: 1.18em; margin-bottom: 0.5em; } 68 | h5, h6 { font-size: 1em; margin-bottom: 0.5em; } 69 | h6 { font-weight: normal; } 70 | p { font-size: 1em; margin-bottom: 0.7em; } 71 | 72 | input { 73 | border: 1px solid var(--border-color); 74 | padding: 0.5em; 75 | font-size: 1em; 76 | width: 100%; 77 | max-width: 40em; 78 | margin-bottom: 1em; 79 | background-color: #fff; 80 | } 81 | 82 | figure { margin: 1.5em 0; text-align: center; } 83 | audio { width: 100%; max-width: 600px; } 84 | 85 | code { 86 | background-color: #f4f4f4; 87 | border: 1px solid var(--light-border-color); 88 | padding: 0.2em 0.4em; 89 | border-radius: 3px; 90 | font-family: Consolas, Monaco, 'Courier New', monospace; 91 | font-size: 0.95em; 92 | } 93 | 94 | pre { 95 | background-color: #f9f9f9; 96 | border: 1px solid var(--light-border-color); 97 | padding: 1em; 98 | overflow-x: auto; 99 | border-radius: 5px; 100 | max-width: 100%; 101 | 102 | code { 103 | background: none; 104 | border: none; 105 | padding: 0; 106 | font-size: 1em; 107 | color: var(--text-color); 108 | } 109 | } 110 | 111 | blockquote { 112 | background-color: var(--blockquote-bg); 113 | border-left: var(--blockquote-border); 114 | margin: 0.7em 0; 115 | padding: 0.75em 1.5em; 116 | font-style: italic; 117 | color: var(--text-color); 118 | 119 | p { 120 | margin: 0; 121 | font-size: 1em; 122 | } 123 | } 124 | 125 | table { 126 | width: 100%; 127 | border-collapse: collapse; 128 | margin-bottom: 1.5em; 129 | background-color: var(--block-background); 130 | 131 | th, td { 132 | border: 1px solid var(--border-color); 133 | padding: 0.75em; 134 | text-align: left; 135 | font-size: 1em; 136 | color: var(--text-color); 137 | } 138 | 139 | th { background-color: var(--highlight-color); font-weight: bold; } 140 | td { background-color: #fff; } 141 | tr:nth-child(even) td { background-color: var(--background-color); } 142 | } 143 | 144 | dl { 145 | margin-bottom: 1.5em; 146 | 147 | dt { 148 | font-weight: bold; 149 | margin-top: 0.5em; 150 | color: #3b2f2f; 151 | } 152 | 153 | dd { 154 | margin-left: 1.5em; 155 | margin-bottom: 0.5em; 156 | font-size: 1em; 157 | color: var(--text-color); 158 | } 159 | } 160 | 161 | aside.post-meta { 162 | background-color: var(--highlight-color); 163 | padding: 0.7em; 164 | margin-top: 1.25em; 165 | border-right: 4px solid var(--primary-color); 166 | text-align: right; 167 | } 168 | 169 | nav.adjacent-episodes ul { 170 | list-style: none; 171 | padding: 0; 172 | margin: 1em 0; 173 | text-align: right; 174 | 175 | li { 176 | display: inline; 177 | padding: 0 0.7em; 178 | &:nth-child(n+2) { border-left: 1px solid var(--primary-color); } 179 | 180 | a[rel="prev"]::before { content: '\02190 '; } /* larr */ 181 | a[rel="next"]::after { content: ' \02192'; } /* rarr */ 182 | } 183 | } 184 | } 185 | 186 | footer { 187 | background-color: var(--primary-color); 188 | color: white; 189 | padding: var(--padding); 190 | text-align: right; 191 | margin-top: 2em; 192 | 193 | section h2 { 194 | font-size: 1.18em; 195 | padding: 0; 196 | margin: 0; 197 | } 198 | 199 | ul { 200 | list-style: none; 201 | padding: 0; 202 | margin: 0; 203 | li { display: inline; margin-right: 0.7em; } 204 | } 205 | 206 | a, ul li a { color: #e0d6d1; } 207 | small { display: block; margin-top: 0.7em; } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /testdata/dev/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Songmu/podbard/7c911caa5253e989c409bfe5a87dbdca5bab93b9/testdata/dev/static/favicon.ico -------------------------------------------------------------------------------- /testdata/dev/static/images/artwork.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Songmu/podbard/7c911caa5253e989c409bfe5a87dbdca5bab93b9/testdata/dev/static/images/artwork.jpg -------------------------------------------------------------------------------- /testdata/dev/template/_layout.tmpl: -------------------------------------------------------------------------------- 1 | {{define "layout" -}} 2 | 3 | 4 | 5 | {{- if .Channel.Private -}} 6 | 7 | 8 | {{- end}} 9 | 10 | {{.Page.Title}} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 |

{{.Channel.Title}}

33 |

{{.Channel.Description}}

34 |
35 | 36 |
37 | {{- template "content" . -}} 38 |
39 | 40 |
41 |
42 |

Subscribe

43 |
    44 |
  • RSS
  • 45 |
46 |
47 |
© 2024 {{.Channel.Author}}
48 |
Generated by podbard
49 |
50 | 51 | 52 | 53 | {{- end}} 54 | -------------------------------------------------------------------------------- /testdata/dev/template/episode.tmpl: -------------------------------------------------------------------------------- 1 | {{define "episode" -}} 2 | 3 |

{{.Episode.Title}}

4 |
5 | 6 |
7 | 8 | {{if ne .Episode.ChaptersBody "" -}} 9 | 13 | {{end}} 14 | 15 | {{.Body}} 16 | 17 | 22 | 23 | 37 | 38 | {{- end}} 39 | -------------------------------------------------------------------------------- /testdata/dev/template/index.tmpl: -------------------------------------------------------------------------------- 1 | {{define "index" -}} 2 | {{.Body}} 3 | {{- end}} 4 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | package podbard 4 | 5 | import _ "golang.org/x/tools/cmd/stringer" 6 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package podbard 2 | 3 | const version = "0.0.16" 4 | 5 | var revision = "HEAD" 6 | --------------------------------------------------------------------------------