Compare commits
23 Commits
b53ce13905
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e0a323ff91 | |||
| 3f418669f3 | |||
| 5409167a96 | |||
| 618c034b05 | |||
| 8e226e9ea1 | |||
| 2c84d3d818 | |||
| c7dc944264 | |||
| 286a01c4d6 | |||
| 3d92096bb8 | |||
| da9f4bb0ec | |||
| 44c7126ac5 | |||
| e4c4cdce41 | |||
| 20c5df67cd | |||
| 36fab5e2c0 | |||
| d193e9d7a2 | |||
| 3494ad6515 | |||
| a7f02c8dd7 | |||
| 9c7fa50fcd | |||
| e4c22dbe47 | |||
| 76b550d628 | |||
| 7df08812bd | |||
| 39d8286eb9 | |||
| 68ebffe9f4 |
13
Justfile
13
Justfile
@@ -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
6
go.mod
@@ -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
6
go.sum
@@ -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
227
main.go
@@ -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
51
metadata.go
Normal 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
24
web/.gitignore
vendored
Normal 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
23
web/eslint.config.js
Normal 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
13
web/index.html
Normal 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
37
web/package.json
Normal 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
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
41
web/src/api/episodes.ts
Normal 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
60
web/src/api/podcasts.ts
Normal 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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
44
web/src/components/NewPodcastForm.tsx
Normal file
44
web/src/components/NewPodcastForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
web/src/icons/fastforward.tsx
Normal file
17
web/src/icons/fastforward.tsx
Normal 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
17
web/src/icons/pause.tsx
Normal 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
17
web/src/icons/play.tsx
Normal 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
17
web/src/icons/rewind.tsx
Normal 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
1
web/src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
40
web/src/main.tsx
Normal file
40
web/src/main.tsx
Normal 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
25
web/src/pages/home.tsx
Normal 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
162
web/src/pages/podcast.tsx
Normal 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
21
web/src/player/context.ts
Normal 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
149
web/src/player/player.tsx
Normal 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(":");
|
||||||
|
};
|
||||||
23
web/src/player/provider.tsx
Normal file
23
web/src/player/provider.tsx
Normal 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
28
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal 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
15
web/vite.config.ts
Normal 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"]],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user