Compare commits

..

23 Commits

Author SHA1 Message Date
e0a323ff91 put link to home in "layout" 2026-03-12 23:11:12 +03:00
3f418669f3 add button to delete podcast & link to website 2026-03-12 22:42:22 +03:00
5409167a96 allow to mark episodes as completed 2026-03-12 14:53:44 +03:00
618c034b05 allow to see time left for playing episode 2026-03-12 14:11:43 +03:00
8e226e9ea1 update podcast page 2026-03-12 13:25:42 +03:00
2c84d3d818 sort episodes by pubdate 2026-03-12 13:20:25 +03:00
c7dc944264 show episode title & duration in player 2026-03-12 12:52:30 +03:00
286a01c4d6 delete old views ui 2026-03-12 12:42:47 +03:00
3d92096bb8 fix player time 2026-03-12 12:42:29 +03:00
da9f4bb0ec rewrite ui with vite+react 2026-03-12 11:57:43 +03:00
44c7126ac5 sort episodes from newest to oldest 2026-02-17 20:23:38 +03:00
e4c4cdce41 update justfile scripts for docker 2026-02-17 20:19:33 +03:00
20c5df67cd add missing import for time/tzdata 2026-02-17 20:12:27 +03:00
36fab5e2c0 add error check when loading time location 2026-02-17 20:10:30 +03:00
d193e9d7a2 add dev script in Justfile 2026-02-17 20:02:30 +03:00
3494ad6515 show date episode was added 2026-02-17 20:02:14 +03:00
a7f02c8dd7 check new episodes every hour instead of 5 seconds 2026-02-13 19:54:20 +03:00
9c7fa50fcd add podcast page 2026-02-13 17:14:02 +03:00
e4c22dbe47 add html page w/ list of podcasts 2026-02-13 17:04:32 +03:00
76b550d628 add api route to delete podcasts 2026-02-13 15:26:34 +03:00
7df08812bd append metadata when downloading episodes 2026-02-13 14:23:11 +03:00
39d8286eb9 download episode file before adding it to db 2026-02-13 13:13:44 +03:00
68ebffe9f4 add environment variables to docker run command 2026-02-13 13:11:56 +03:00
28 changed files with 3572 additions and 25 deletions

View File

@@ -1,5 +1,12 @@
build: dev:
docker build -t podcaster . cd web && pnpm build
PODCASTS_DIRPATH=./podcasts go run .
build tag="latest":
docker build -t git.zatch.ru/tsivinsky/podcaster:{{tag}} .
push tag="latest":
docker push git.zatch.ru/tsivinsky/podcaster:{{tag}}
run: run:
docker run -p 5000:5000 --name podcaster -v ./db:/db -v ./podcasts:/podcasts podcaster:latest docker run -p 5000:5000 --name podcaster -e PODCASTS_DIRPATH=/podcasts -e DB_PATH=/db/sqlite.db -v ./db:/db -v ./podcasts:/podcasts podcaster:latest

6
go.mod
View File

@@ -6,3 +6,9 @@ require (
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/mattn/go-sqlite3 v1.14.34 github.com/mattn/go-sqlite3 v1.14.34
) )
require (
github.com/tetratelabs/wazero v1.11.0 // indirect
go.senan.xyz/taglib v0.11.1 // indirect
golang.org/x/sys v0.41.0 // indirect
)

6
go.sum
View File

@@ -9,3 +9,9 @@ github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
go.senan.xyz/taglib v0.11.1 h1:S3mO5e3HRRG0Ehw1jLUodYbAJK8TtqdOoNgqkC0D3uU=
go.senan.xyz/taglib v0.11.1/go.mod h1:qyTl978MnGeZ/ny4d/t0ErLXxysA+39X4+SNSCk56Zs=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

227
main.go
View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"embed"
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
@@ -10,12 +11,33 @@ import (
"os" "os"
"path" "path"
"strconv" "strconv"
"strings"
"time" "time"
_ "time/tzdata"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
type OrderType = string
const (
OrderTypeAsc OrderType = "ASC"
OrderTypeDesc OrderType = "DESC"
)
func validateOrderType(orderType string) OrderType {
switch strings.ToLower(orderType) {
case "asc":
return OrderTypeAsc
case "desc":
return OrderTypeDesc
default:
return OrderTypeAsc
}
}
type Podcast struct { type Podcast struct {
ID int64 `json:"id" db:"id"` ID int64 `json:"id" db:"id"`
Name string `json:"name" db:"name"` Name string `json:"name" db:"name"`
@@ -23,6 +45,7 @@ type Podcast struct {
Feed string `json:"feed" db:"feed"` Feed string `json:"feed" db:"feed"`
Language string `json:"language" db:"language"` Language string `json:"language" db:"language"`
Link string `json:"link" db:"link"` Link string `json:"link" db:"link"`
Image string `json:"image" db:"image"`
CreatedAt time.Time `json:"createdAt" db:"created_at"` CreatedAt time.Time `json:"createdAt" db:"created_at"`
} }
@@ -33,6 +56,8 @@ type Episode struct {
Guid string `json:"guid" db:"guid"` Guid string `json:"guid" db:"guid"`
Url string `json:"url" db:"url"` Url string `json:"url" db:"url"`
PodcastId int64 `json:"podcastId" db:"podcast_id"` PodcastId int64 `json:"podcastId" db:"podcast_id"`
Number int `json:"number" db:"number"`
Listened bool `json:"listened" db:"listened"`
CreatedAt time.Time `json:"createdAt" db:"created_at"` CreatedAt time.Time `json:"createdAt" db:"created_at"`
} }
@@ -72,6 +97,7 @@ type RSSFeed struct {
Description string `xml:"description"` Description string `xml:"description"`
Language string `xml:"language"` Language string `xml:"language"`
Link string `xml:"link"` Link string `xml:"link"`
Image string `xml:"image>url"`
Items []RSSFeedItem `xml:"item"` Items []RSSFeedItem `xml:"item"`
} `xml:"channel"` } `xml:"channel"`
} }
@@ -125,13 +151,28 @@ func getPodcasts(db *sqlx.DB) ([]*Podcast, error) {
return podcasts, nil return podcasts, nil
} }
func getPodcastById(db *sqlx.DB, podcastId int64) (*Podcast, error) {
row := db.QueryRowx("SELECT * FROM podcasts WHERE id = ?", podcastId)
if row.Err() != nil {
return nil, fmt.Errorf("failed to query db: %v", row.Err())
}
podcast := new(Podcast)
if err := row.StructScan(podcast); err != nil {
return nil, fmt.Errorf("failed to scan struct podcast: %v", err)
}
return podcast, nil
}
func createPodcast(db *sqlx.DB, feed RSSFeed, feedUrl string) error { func createPodcast(db *sqlx.DB, feed RSSFeed, feedUrl string) error {
_, err := db.NamedExec("INSERT INTO podcasts (name, description, feed, language, link) VALUES (:name, :description, :feed, :language, :link)", map[string]any{ _, err := db.NamedExec("INSERT INTO podcasts (name, description, feed, language, link, image) VALUES (:name, :description, :feed, :language, :link, :image)", map[string]any{
"name": feed.Channel.Title, "name": feed.Channel.Title,
"description": feed.Channel.Description, "description": feed.Channel.Description,
"feed": feedUrl, "feed": feedUrl,
"language": feed.Channel.Language, "language": feed.Channel.Language,
"link": feed.Channel.Link, "link": feed.Channel.Link,
"image": feed.Channel.Image,
}) })
return err return err
} }
@@ -150,11 +191,12 @@ func getEpisodeByGuid(db *sqlx.DB, guid string) (*Episode, error) {
return episode, nil return episode, nil
} }
func addEpisode(db *sqlx.DB, episode RSSFeedItem, podcastId int64) error { func addEpisode(db *sqlx.DB, episode RSSFeedItem, episodeNumber int, podcastId int64) error {
_, err := db.NamedExec("INSERT INTO episodes (title, pubdate, guid, url, podcast_id) VALUES (:title, :pubdate, :guid, :url, :podcast_id)", map[string]any{ _, err := db.NamedExec("INSERT INTO episodes (title, pubdate, guid, url, number, podcast_id) VALUES (:title, :pubdate, :guid, :url, :number, :podcast_id)", map[string]any{
"title": episode.Title, "title": episode.Title,
"pubdate": episode.PubDate.Time, "pubdate": episode.PubDate.Time,
"url": episode.Enclosure.URL, "url": episode.Enclosure.URL,
"number": episodeNumber,
"guid": episode.Guid, "guid": episode.Guid,
"podcast_id": podcastId, "podcast_id": podcastId,
}) })
@@ -176,8 +218,23 @@ func downloadEpisodeAudioFile(url string) ([]byte, error) {
return data, nil return data, nil
} }
func getPodcastEpisodes(db *sqlx.DB, podcastId int64) ([]*Episode, error) { func getPodcastEpisodes(db *sqlx.DB, podcastId int64, orderBy string, orderType OrderType) ([]*Episode, error) {
rows, err := db.Queryx("SELECT * FROM episodes WHERE podcast_id = ?", podcastId) allowedColumns := map[string]bool{
"title": true,
"pubdate": true,
"number": true,
"created_at": true,
}
if !allowedColumns[orderBy] {
orderBy = "pubdate"
}
orderType = validateOrderType(orderType)
query := fmt.Sprintf("SELECT * FROM episodes WHERE podcast_id = :podcast_id ORDER BY %s %s", orderBy, orderType)
rows, err := db.NamedQuery(query, map[string]any{
"podcast_id": podcastId,
})
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query db: %v", err) return nil, fmt.Errorf("failed to query db: %v", err)
} }
@@ -194,6 +251,21 @@ func getPodcastEpisodes(db *sqlx.DB, podcastId int64) ([]*Episode, error) {
return episodes, nil return episodes, nil
} }
func deletePodcastById(db *sqlx.DB, podcastId int64) error {
if _, err := db.NamedExec("DELETE FROM episodes WHERE podcast_id = :podcastId; DELETE FROM podcasts WHERE id = :podcastId", map[string]any{
"podcastId": podcastId,
}); err != nil {
return fmt.Errorf("failed to delete podcasts and episodes: %v", err)
}
return nil
}
var (
//go:embed web/dist/*
webFS embed.FS
)
func main() { func main() {
podcastsDirPath := os.Getenv("PODCASTS_DIRPATH") podcastsDirPath := os.Getenv("PODCASTS_DIRPATH")
@@ -220,6 +292,7 @@ func main() {
feed varchar not null, feed varchar not null,
language varchar not null, language varchar not null,
link varchar not null, link varchar not null,
image varchar not null,
created_at datetime default current_timestamp created_at datetime default current_timestamp
); );
@@ -229,12 +302,15 @@ func main() {
pubdate datetime not null, pubdate datetime not null,
guid varchar not null, guid varchar not null,
url varchar not null, url varchar not null,
number integer not null,
created_at datetime default current_timestamp, created_at datetime default current_timestamp,
podcast_id integer not null, podcast_id integer not null,
FOREIGN KEY (podcast_id) REFERENCES podcasts(id) FOREIGN KEY (podcast_id) REFERENCES podcasts(id)
); );
`) `)
db.Exec(`ALTER TABLE episodes ADD COLUMN listened boolean default false;`)
go func(db *sqlx.DB) { go func(db *sqlx.DB) {
for { for {
podcasts, err := getPodcasts(db) podcasts, err := getPodcasts(db)
@@ -252,18 +328,22 @@ func main() {
newestEpisode := feed.Channel.Items[0] newestEpisode := feed.Channel.Items[0]
episode, err := getEpisodeByGuid(db, newestEpisode.Guid) episode, err := getEpisodeByGuid(db, newestEpisode.Guid)
if err != nil && episode == nil { if err != nil && episode == nil {
if err := addEpisode(db, newestEpisode, podcast.ID); err != nil {
log.Printf("failed to add new episode [%s]: %v\n", podcast.Name, err)
continue
}
data, err := downloadEpisodeAudioFile(newestEpisode.Enclosure.URL) data, err := downloadEpisodeAudioFile(newestEpisode.Enclosure.URL)
if err != nil { if err != nil {
log.Printf("failed to download newest episode audio file: %v\n", err) log.Printf("failed to download newest episode audio file: %v\n", err)
continue continue
} }
f, err := os.OpenFile(path.Join(podcastsDirPath, podcast.Name, newestEpisode.Title+".mp3"), os.O_CREATE|os.O_RDWR, 0644) episodeNumber := len(feed.Channel.Items) - 1
if err := addEpisode(db, newestEpisode, episodeNumber, podcast.ID); err != nil {
log.Printf("failed to add new episode [%s]: %v\n", podcast.Name, err)
continue
}
episodeFilePath := path.Join(podcastsDirPath, podcast.Name, fmt.Sprintf("Episode %d.mp3", episodeNumber))
f, err := os.OpenFile(episodeFilePath, os.O_CREATE|os.O_RDWR, 0644)
if err != nil { if err != nil {
log.Printf("failed to create file for newest episode: %v\n", err) log.Printf("failed to create file for newest episode: %v\n", err)
continue continue
@@ -274,17 +354,37 @@ func main() {
continue continue
} }
if err := appendPodcastMetadata(episodeFilePath, newestEpisode, episodeNumber, *podcast); err != nil {
log.Printf("failed to append episode metadata: %v\n", err)
continue
}
f.Close() f.Close()
} }
} }
time.Sleep(5 * time.Second) time.Sleep(1 * time.Hour)
} }
}(db) }(db)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /podcasts", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/" {
path = "/index.html"
}
path = "web/dist" + path
_, err := webFS.Open(path)
if err != nil {
path = "web/dist/index.html"
}
http.ServeFileFS(w, r, webFS, path)
})
mux.HandleFunc("GET /api/podcasts", func(w http.ResponseWriter, r *http.Request) {
podcasts, err := getPodcasts(db) podcasts, err := getPodcasts(db)
if err != nil { if err != nil {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
@@ -294,14 +394,21 @@ func main() {
sendJSON(w, podcasts, 200) sendJSON(w, podcasts, 200)
}) })
mux.HandleFunc("POST /podcasts", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("POST /api/podcasts", func(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
Feed string `json:"feed"` Feed string `json:"feed"`
} }
isFormData := r.Header.Get("Content-Type") == "application/x-www-form-urlencoded"
if isFormData {
body.Feed = r.FormValue("feed")
} else {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
} }
}
if body.Feed == "" { if body.Feed == "" {
http.Error(w, "invalid request, rss feed url is required", 400) http.Error(w, "invalid request, rss feed url is required", 400)
@@ -323,19 +430,39 @@ func main() {
log.Printf("failed to create directory for podcast %s: %v\n", feed.Channel.Title, err) log.Printf("failed to create directory for podcast %s: %v\n", feed.Channel.Title, err)
} }
if isFormData {
http.Redirect(w, r, "/", http.StatusFound)
} else {
sendJSON(w, struct { sendJSON(w, struct {
Ok bool `json:"ok"` Ok bool `json:"ok"`
}{true}, 201) }{true}, 201)
}
}) })
mux.HandleFunc("GET /podcasts/{id}/episodes", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /api/podcasts/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, err.Error(), 400)
return
}
podcast, err := getPodcastById(db, int64(id))
if err != nil {
http.Error(w, "podcast not found", 404)
return
}
sendJSON(w, podcast, 200)
})
mux.HandleFunc("GET /api/podcasts/{id}/episodes", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id")) id, err := strconv.Atoi(r.PathValue("id"))
if err != nil { if err != nil {
http.Error(w, err.Error(), 404) http.Error(w, err.Error(), 404)
return return
} }
episodes, err := getPodcastEpisodes(db, int64(id)) episodes, err := getPodcastEpisodes(db, int64(id), "pubdate", OrderTypeDesc)
if err != nil { if err != nil {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
@@ -344,6 +471,74 @@ func main() {
sendJSON(w, episodes, 200) sendJSON(w, episodes, 200)
}) })
mux.HandleFunc("DELETE /api/podcasts/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, err.Error(), 404)
return
}
podcast, err := getPodcastById(db, int64(id))
if err != nil {
http.Error(w, err.Error(), 404)
return
}
if err := os.RemoveAll(path.Join(podcastsDirPath, podcast.Name)); err != nil {
http.Error(w, err.Error(), 404)
return
}
if err := deletePodcastById(db, int64(id)); err != nil {
http.Error(w, err.Error(), 404)
return
}
sendJSON(w, struct {
Ok bool `json:"ok"`
}{true}, 200)
})
mux.HandleFunc("PATCH /api/episodes/{id}", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Listened *bool `json:"listened"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), 400)
return
}
fields := map[string]any{}
if body.Listened != nil {
fields["listened"] = &body.Listened
}
if len(fields) == 0 {
sendJSON(w, struct {
Ok bool `json:"ok"`
}{true}, 200)
return
}
fields["id"] = r.PathValue("id")
f := []string{}
for field := range fields {
f = append(f, fmt.Sprintf("%s = :%s", field, field))
}
q := fmt.Sprintf("UPDATE episodes SET %s WHERE id = :id", strings.Join(f, ", "))
_, err := db.NamedExec(q, fields)
if err != nil {
http.Error(w, err.Error(), 400)
return
}
sendJSON(w, struct {
Ok bool `json:"ok"`
}{true}, 200)
})
fmt.Println("starting http server on http://localhost:5000") fmt.Println("starting http server on http://localhost:5000")
if err := http.ListenAndServe(":5000", mux); err != nil { if err := http.ListenAndServe(":5000", mux); err != nil {
log.Fatalf("failed to start http server: %v\n", err) log.Fatalf("failed to start http server: %v\n", err)

51
metadata.go Normal file
View File

@@ -0,0 +1,51 @@
package main
import (
"fmt"
"io"
"net/http"
"strconv"
"go.senan.xyz/taglib"
)
func getPodcastCover(imageUrl string) ([]byte, error) {
resp, err := http.Get(imageUrl)
if err != nil {
return nil, fmt.Errorf("failed to fetch podcast cover image: %v", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read image data from response: %v", err)
}
return data, nil
}
func appendPodcastMetadata(filePath string, feedItem RSSFeedItem, episodeNumber int, podcast Podcast) error {
if err := taglib.WriteTags(filePath, map[string][]string{
taglib.Artist: {podcast.Name},
taglib.AlbumArtist: {podcast.Name},
taglib.Album: {podcast.Name},
taglib.TrackNumber: {strconv.Itoa(episodeNumber)},
taglib.Podcast: {podcast.Name},
taglib.PodcastURL: {podcast.Link},
taglib.PodcastDesc: {podcast.Description},
taglib.Title: {feedItem.Title},
}, 0); err != nil {
return fmt.Errorf("failed to append tags: %v", err)
}
imgData, err := getPodcastCover(podcast.Image)
if err != nil {
return fmt.Errorf("failed to fetch podcast cover: %v", err)
}
if err := taglib.WriteImage(filePath, imgData); err != nil {
return fmt.Errorf("failed to append cover image: %v", err)
}
return nil
}

24
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

23
web/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
]);

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

37
web/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"dayjs": "^1.11.19",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.2",
"react-router": "^7.13.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

2475
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

41
web/src/api/episodes.ts Normal file
View File

@@ -0,0 +1,41 @@
import { useMutation, useQuery } from "@tanstack/react-query";
export type EpisodeDetail = {
id: number;
title: string;
pubDate: string;
guid: string;
url: string;
podcastId: number;
number: number;
listened: boolean;
createdAt: string;
};
export const usePodcastEpisodesQuery = (
id: number | string | null | undefined,
) => {
return useQuery({
queryKey: ["podcasts", id, "episodes"],
enabled: typeof id !== "undefined" && id !== null,
queryFn: async () => {
const resp = await fetch(`/api/podcasts/${id}/episodes`);
return (await resp.json()) as EpisodeDetail[];
},
});
};
export const useUpdateEpisodeMutation = () => {
return useMutation({
mutationFn: async ({
id,
...data
}: Partial<EpisodeDetail> & { id: number }) => {
const resp = await fetch(`/api/episodes/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
return await resp.json();
},
});
};

60
web/src/api/podcasts.ts Normal file
View File

@@ -0,0 +1,60 @@
import { useMutation, useQuery } from "@tanstack/react-query";
export type PodcastDetail = {
id: number;
name: string;
description: string;
feed: string;
language: string;
link: string;
image: string;
createdAt: string;
};
export const usePodcastsQuery = () => {
return useQuery({
queryKey: ["podcasts"],
queryFn: async () => {
const resp = await fetch("/api/podcasts");
return (await resp.json()) as PodcastDetail[];
},
});
};
export const usePodcastQuery = (id: number | string | null | undefined) => {
return useQuery({
queryKey: ["podcasts", id],
enabled: typeof id !== "undefined" && id !== null,
queryFn: async () => {
const resp = await fetch(`/api/podcasts/${id}`);
return (await resp.json()) as PodcastDetail;
},
});
};
export type CreatePodcastData = {
feed: string;
};
export const useCreatePodcastMutation = () => {
return useMutation({
mutationFn: async (data: CreatePodcastData) => {
const resp = await fetch("/api/podcasts", {
method: "POST",
body: JSON.stringify(data),
});
return await resp.json();
},
});
};
export const useDeletePodcastMutation = () => {
return useMutation({
mutationFn: async ({ id }: { id: number }) => {
const resp = await fetch(`/api/podcasts/${id}`, {
method: "DELETE",
});
return await resp.json();
},
});
};

View File

@@ -0,0 +1,44 @@
import { useForm } from "react-hook-form";
import {
useCreatePodcastMutation,
type CreatePodcastData,
} from "../api/podcasts";
import { useQueryClient } from "@tanstack/react-query";
export const NewPodcastForm = () => {
const queryClient = useQueryClient();
const form = useForm<CreatePodcastData>({
defaultValues: {
feed: "",
},
});
const mutation = useCreatePodcastMutation();
const onSubmit = form.handleSubmit((data) => {
mutation.mutate(data, {
onSuccess() {
queryClient.invalidateQueries({ queryKey: ["podcasts"] });
form.reset();
},
});
});
return (
<form className="mt-3 flex gap-1" onSubmit={onSubmit}>
<input
type="text"
inputMode="url"
placeholder="rss feed"
className="w-full"
{...form.register("feed", {
required: true,
})}
/>
<button type="submit" className="whitespace-nowrap">
Add podcast
</button>
</form>
);
};

View File

@@ -0,0 +1,17 @@
export const FastForwardIcon = ({
size = 32,
color = "#000000",
}: {
size?: number;
color?: string;
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill={color}
viewBox="0 0 256 256"
>
<path d="M248.67,114.66,160.48,58.5A15.91,15.91,0,0,0,136,71.84v37.3L56.48,58.5A15.91,15.91,0,0,0,32,71.84V184.16A15.92,15.92,0,0,0,56.48,197.5L136,146.86v37.3a15.92,15.92,0,0,0,24.48,13.34l88.19-56.16a15.8,15.8,0,0,0,0-26.68ZM48,183.94V72.07L135.82,128Zm104,0V72.07L239.82,128Z"></path>
</svg>
);

17
web/src/icons/pause.tsx Normal file
View File

@@ -0,0 +1,17 @@
export const PauseIcon = ({
size = 32,
color = "#000000",
}: {
size?: number;
color?: string;
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill={color}
viewBox="0 0 256 256"
>
<path d="M200,32H160a16,16,0,0,0-16,16V208a16,16,0,0,0,16,16h40a16,16,0,0,0,16-16V48A16,16,0,0,0,200,32Zm0,176H160V48h40ZM96,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16H96a16,16,0,0,0,16-16V48A16,16,0,0,0,96,32Zm0,176H56V48H96Z"></path>
</svg>
);

17
web/src/icons/play.tsx Normal file
View File

@@ -0,0 +1,17 @@
export const PlayIcon = ({
size = 32,
color = "#000000",
}: {
size?: number;
color?: string;
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill={color}
viewBox="0 0 256 256"
>
<path d="M232.4,114.49,88.32,26.35a16,16,0,0,0-16.2-.3A15.86,15.86,0,0,0,64,39.87V216.13A15.94,15.94,0,0,0,80,232a16.07,16.07,0,0,0,8.36-2.35L232.4,141.51a15.81,15.81,0,0,0,0-27ZM80,215.94V40l143.83,88Z"></path>
</svg>
);

17
web/src/icons/rewind.tsx Normal file
View File

@@ -0,0 +1,17 @@
export const RewindIcon = ({
size = 32,
color = "#000000",
}: {
size?: number;
color?: string;
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill={color}
viewBox="0 0 256 256"
>
<path d="M223.77,58a16,16,0,0,0-16.25.53L128,109.14V71.84A15.91,15.91,0,0,0,103.52,58.5L15.33,114.66a15.8,15.8,0,0,0,0,26.68l88.19,56.16A15.91,15.91,0,0,0,128,184.16v-37.3l79.52,50.64A15.91,15.91,0,0,0,232,184.16V71.84A15.83,15.83,0,0,0,223.77,58ZM112,183.93,24.18,128,112,72.06Zm104,0L128.18,128,216,72.06Z"></path>
</svg>
);

1
web/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

40
web/src/main.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes } from "react-router";
import { Route } from "react-router";
import { HomePage } from "./pages/home";
import { PodcastPage } from "./pages/podcast";
import { PlayerProvider } from "./player/provider";
import { Player } from "./player/player";
import { Link } from "react-router";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<PlayerProvider>
<div className="max-w-[1440px] w-full mx-auto p-2 relative">
<BrowserRouter>
<Link to="/" className="w-fit">
<h1 className="text-3xl font-semibold">podcaster</h1>
</Link>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/podcasts/:id" element={<PodcastPage />} />
</Routes>
</BrowserRouter>
</div>
<Player />
</PlayerProvider>
</QueryClientProvider>
</StrictMode>,
);

25
web/src/pages/home.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { usePodcastsQuery } from "../api/podcasts";
import { NewPodcastForm } from "../components/NewPodcastForm";
export const HomePage = () => {
const { data: podcasts } = usePodcastsQuery();
return (
<div>
<NewPodcastForm />
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,250px)] gap-3 mt-5">
{podcasts?.map((podcast) => (
<a
key={podcast.id}
href={`/podcasts/${podcast.id}`}
className="block"
>
<img src={podcast.image} alt="" className="w-full aspect-square" />
<span>{podcast.name}</span>
</a>
))}
</div>
</div>
);
};

162
web/src/pages/podcast.tsx Normal file
View File

@@ -0,0 +1,162 @@
import { useParams } from "react-router";
import { useDeletePodcastMutation, usePodcastQuery } from "../api/podcasts";
import {
usePodcastEpisodesQuery,
useUpdateEpisodeMutation,
} from "../api/episodes";
import { usePlayerContext } from "../player/context";
import { PlayIcon } from "../icons/play";
import { PauseIcon } from "../icons/pause";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
export const PodcastPage = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { id } = useParams<{ id: string }>();
const { data: podcast } = usePodcastQuery(id);
const { data: episodes } = usePodcastEpisodesQuery(id);
const player = usePlayerContext();
const updateEpisodeMutation = useUpdateEpisodeMutation();
const deletePodcastMutation = useDeletePodcastMutation();
const handleDeletePodcast = () => {
if (
!confirm(
"Are you sure you want to delete this podcast with all episodes?",
)
)
return;
if (typeof id === "undefined") return;
deletePodcastMutation.mutate(
{
id: parseInt(id),
},
{
onSuccess() {
queryClient.invalidateQueries({ queryKey: ["podcasts"] });
navigate("/");
},
},
);
};
const handleMarkCompleted = (episodeId: number) => {
updateEpisodeMutation.mutate(
{
id: episodeId,
listened: true,
},
{
onSuccess() {
queryClient.invalidateQueries({
queryKey: ["podcasts", id, "episodes"],
});
},
},
);
};
const handleUnmarkCompleted = (episodeId: number) => {
updateEpisodeMutation.mutate(
{
id: episodeId,
listened: false,
},
{
onSuccess() {
queryClient.invalidateQueries({
queryKey: ["podcasts", id, "episodes"],
});
},
},
);
};
return (
<div>
<div className="mt-3 flex gap-2">
<img src={podcast?.image} alt="" className="w-[300px] aspect-square" />
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-semibold">{podcast?.name}</h2>
<p>{podcast?.description}</p>
<div className="mt-auto flex items-center gap-2">
<a
href={podcast?.link}
target="_blank"
rel="noopener noreferrer"
className="block bg-blue-500 text-white py-1 px-4 rounded"
>
Website
</a>
<button
className="bg-red-500 text-white py-1 px-4 rounded w-fit cursor-pointer"
disabled={deletePodcastMutation.isPending}
onClick={handleDeletePodcast}
>
Delete
</button>
</div>
</div>
</div>
<div className="mt-6 p-2.5 border-t flex flex-col gap-1">
{episodes?.map((episode) => (
<div key={episode.id} className="flex justify-between items-center">
<div className="flex items-center gap-2">
{player.status === "playing" &&
player.episode?.id === episode.id ? (
<button
onClick={() => {
player.setStatus("paused");
}}
>
<PauseIcon size={24} />
</button>
) : (
<button
onClick={() => {
player.setStatus("playing");
player.setEpisode(episode);
}}
>
<PlayIcon size={24} />
</button>
)}
<span>{episode.title}</span>
</div>
<div className="flex items-center gap-2">
{episode.listened ? (
<button
className="text-green-700"
disabled={
updateEpisodeMutation.isPending &&
updateEpisodeMutation.variables.id === episode.id
}
onClick={() => handleUnmarkCompleted(episode.id)}
>
Unmark completed
</button>
) : (
<button
disabled={
updateEpisodeMutation.isPending &&
updateEpisodeMutation.variables.id === episode.id
}
onClick={() => handleMarkCompleted(episode.id)}
>
Mark completed
</button>
)}
<span>{new Date(episode.createdAt).toLocaleString()}</span>
</div>
</div>
))}
</div>
</div>
);
};

21
web/src/player/context.ts Normal file
View File

@@ -0,0 +1,21 @@
import { createContext, useContext } from "react";
import type { EpisodeDetail } from "../api/episodes";
export type PlayerStatus = "stopped" | "playing" | "paused";
export type PlayerContext = {
status: PlayerStatus;
setStatus: (status: PlayerStatus) => void;
episode: EpisodeDetail | null;
setEpisode: (episode: EpisodeDetail | null) => void;
};
export const playerContext = createContext<PlayerContext | null>(null);
export const usePlayerContext = () => {
const ctx = useContext(playerContext);
if (!ctx) {
throw new Error("No PlayerProvider in component tree");
}
return ctx;
};

149
web/src/player/player.tsx Normal file
View File

@@ -0,0 +1,149 @@
import { useEffect, useRef, useState } from "react";
import { usePlayerContext } from "./context";
import { RewindIcon } from "../icons/rewind";
import { FastForwardIcon } from "../icons/fastforward";
import { PauseIcon } from "../icons/pause";
import { PlayIcon } from "../icons/play";
export const Player = () => {
const { status, episode, setStatus } = usePlayerContext();
const audioRef = useRef<HTMLAudioElement>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [showTimeLeft, setShowTimeLeft] = useState(false);
useEffect(() => {
if (!episode) return;
audioRef.current = new Audio(episode.url);
audioRef.current.addEventListener("timeupdate", (e) => {
const t = e.target as HTMLAudioElement;
setCurrentTime(t.currentTime);
});
audioRef.current.addEventListener("loadeddata", (e) => {
const t = e.target as HTMLAudioElement;
setDuration(t.duration);
});
}, [episode]);
useEffect(() => {
if (!audioRef.current) return;
if (status === "playing") {
audioRef.current.play();
} else {
audioRef.current.pause();
}
}, [status, audioRef]);
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowLeft":
if (audioRef.current) {
audioRef.current.currentTime -= 10;
}
break;
case "ArrowRight":
if (audioRef.current) {
audioRef.current.currentTime += 10;
}
break;
}
};
window.addEventListener("keydown", handleKeyPress);
return () => {
window.addEventListener("keydown", handleKeyPress);
};
}, []);
const progress = currentTime / duration;
const timeLeft = duration - currentTime;
if (status === "stopped") {
return null;
}
return (
<div className="fixed bottom-0 left-0 right-0 z-50">
<div className="max-w-[1440px] w-full mx-auto">
<div className="bg-white py-2 px-4 flex flex-col">
<div className="w-full flex items-center gap-4">
<div className="flex items-center gap-2">
{status === "playing" ? (
<button onClick={() => setStatus("paused")}>
<PauseIcon size={24} />
</button>
) : (
<button onClick={() => setStatus("playing")}>
<PlayIcon size={24} />
</button>
)}
<button
onClick={() => {
if (!audioRef.current) return;
audioRef.current.currentTime -= 10;
}}
>
<RewindIcon size={24} />
</button>
<button
onClick={() => {
if (!audioRef.current) return;
audioRef.current.currentTime += 10;
}}
>
<FastForwardIcon size={24} />
</button>
</div>
<div
className="w-full h-2 flex items-center rounded-lg bg-neutral-200 overflow-hidden hover:h-5 transition-[height] ease-linear"
onClick={(e) => {
if (!audioRef.current) return;
const rect = e.currentTarget.getBoundingClientRect();
const left = e.clientX - rect.x;
const percent = Math.floor((left / rect.width) * 100);
const newTime = (duration * percent) / 100;
audioRef.current.currentTime = newTime;
setCurrentTime(newTime);
}}
>
<div
style={{ width: `${progress * 100}%` }}
className="bg-red-500 h-full"
></div>
</div>
<button
className="min-w-[70px] text-right cursor-pointer"
onClick={() => setShowTimeLeft((prev) => !prev)}
>
{showTimeLeft ? "-" : ""}
{formatTime(showTimeLeft ? timeLeft : currentTime)}
</button>
</div>
<h3 className="text-base font-semibold mt-2">{episode?.title}</h3>
<p className="opacity-50 mt-1">{formatTime(duration)}</p>
</div>
</div>
</div>
);
};
const formatTime = (time: number): string => {
let hours = Math.floor(time / 3600);
let minutes = Math.floor((time % 3600) / 60);
let seconds = Math.floor(time % 60);
const h = String(hours).padStart(2, "0");
const m = String(minutes).padStart(2, "0");
const s = String(seconds).padStart(2, "0");
return [h, m, s].join(":");
};

View File

@@ -0,0 +1,23 @@
import { useState, type ReactNode } from "react";
import {
type PlayerContext,
type PlayerStatus,
playerContext,
} from "./context";
import type { EpisodeDetail } from "../api/episodes";
export const PlayerProvider = ({ children }: { children: ReactNode }) => {
const [status, setStatus] = useState<PlayerStatus>("stopped");
const [episode, setEpisode] = useState<EpisodeDetail | null>(null);
const value = {
status,
setStatus,
episode,
setEpisode,
} satisfies PlayerContext;
return (
<playerContext.Provider value={value}>{children}</playerContext.Provider>
);
};

28
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

15
web/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [
tailwindcss(),
react({
babel: {
plugins: [["babel-plugin-react-compiler"]],
},
}),
],
});