Skip to content

Commit

Permalink
Merge pull request #235 from kenellorando/rp1
Browse files Browse the repository at this point in the history
Cadence Stack Reverse Proxy (Redo)
  • Loading branch information
kenellorando authored Feb 26, 2023
2 parents dfce70c + 7d9fd8b commit db0be45
Show file tree
Hide file tree
Showing 14 changed files with 197 additions and 124 deletions.
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ build-cadence:
docker buildx build --push --platform linux/arm/v7,linux/amd64 --tag kenellorando/cadence:latest --tag kenellorando/cadence:$(VERSION) --file ./cadence/Dockerfile.multiarch ./cadence/

build-cadence_icecast2:
docker buildx build --push --platform linux/arm/v7,linux/amd64 --tag kenellorando/cadence_icecast2:latest --tag kenellorando/cadence_icecast2:$(VERSION) --file ./cadence_icecast2/Dockerfile ./cadence_icecast2/
docker buildx build --push --platform linux/arm/v7,linux/amd64 --tag kenellorando/cadence_icecast2:latest --tag kenellorando/cadence_icecast2:$(VERSION) --file ./stream/Dockerfile.icecast2 ./stream/

build-cadence_liquidsoap:
docker buildx build --push --platform linux/arm/v7,linux/amd64 --tag kenellorando/cadence_liquidsoap:latest --tag kenellorando/cadence_liquidsoap:$(VERSION) --file ./cadence_liquidsoap/Dockerfile ./cadence_liquidsoap/
docker buildx build --push --platform linux/arm/v7,linux/amd64 --tag kenellorando/cadence_liquidsoap:latest --tag kenellorando/cadence_liquidsoap:$(VERSION) --file ./stream/Dockerfile.liquidsoap ./stream/

build-all: build-cadence build-cadence_icecast2 build-cadence_liquidsoap
50 changes: 25 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# CadenceRadio

**Cadence** is an all-in-one web radio suite, allowing you to start a self-hosted internet radio website in minutes.
**Cadence** is an all-in-one web radio suite that you can use to start a self-hosted radio website in minutes.

The project ships with _Icecast_ and _Liquidsoap_ built-in, complemented by a _Cadence_ API server providing music search, song request, artwork, a UI, and real-time stream information.

Cadence ships all components mostly pre-configured with each each other so there is hardly any configuration required. Simply set a target directory containing your music files, set a few service passwords and hostnames, and you're done! The Cadence stack can be deployed in a single command.
Cadence ships all components mostly pre-configured with each each other so there is hardly any configuration required. Simply set a target directory containing your music files, set a few service passwords and hostnames, and deploy!

**Try the live demo! [cadenceradio.com](https://cadenceradio.com/)**
**[Try the live demo!](https://cadenceradio.com/)**

## 🖼️ Image Gallery
<details>
Expand All @@ -19,7 +19,7 @@ Cadence ships all components mostly pre-configured with each each other so there
<details>
<summary>Cadence Architecture</summary>

![cadence5 architecture](https://user-images.githubusercontent.com/17265041/185465196-66fc2249-e43a-46f7-a12f-dbde9aaf8172.png)
![cadence5.3 architecture](https://user-images.githubusercontent.com/17265041/220829527-411f76ca-884f-4bf4-8b44-3afeaca158fa.png)

</details>

Expand All @@ -29,38 +29,38 @@ Cadence ships all components mostly pre-configured with each each other so there
1. You must have Docker installed. If you are on a Linux server, install the [Compose plugin](https://docs.docker.com/compose/install/linux/).

### Installation
1. Edit `cadence/config/cadence.env`.
1. Set the `POSTGRES_PASSWORD` from `hackme` to a new password.
2. Set `CSERVER_MUSIC_DIR` to an absolute path of a directory on your system which contains music files (`.mp3`, `.flac`) to play. The target is not recursively searched. The default value is `/music/`.
3. Set `CSERVER_REQRATELIMIT` to an integer value that represents a song request cooldown period in seconds. Set this value to `0` to disable request rate limiting. The default value is `180`.
2. Edit `cadence_icecast2/config/cadence.xml`.
1. Fork or clone the repository.
2. Edit `config/cadence.env`
1. Change all instances of `hackme` to a new password.
2. Set the `<hostname>` value to a URL you expect your audience to connect to. Cadence uses this value to set the stream source in the UI. This may be a DNS name or a public or internal IP address. You can leave the default value `localhost` if your radio is meant to be accessible locally only.
3. Edit `cadence_liquidsoap/config/cadence.liq`:
2. Set `CSERVER_MUSIC_DIR` to an absolute path which contains music files (`.mp3`, `.flac`) for play. The target is not recursively searched. The default value is `/music/`.
3. Set `CSERVER_REQRATELIMIT` to an integer that sets the song request cooldown period in seconds. Set this value to `0` to disable rate limiting. The default value is `180`.
3. Edit `config/icecast.xml`
1. Change all instances of `hackme` to a new password.
2. If you changed the `CSERVER_MUSIC_DIR` value in step 1, change any instances of the default value `/music/` to match it here.
4. `docker compose up`
1. On older versions of Docker, use `docker-compose up` instead.
2. Set the `<hostname>` value to a URL you expect your audience to connect to. This value is what is set in the UI's stream source. This may be a DNS name or a public or private IP address. You can leave the default value `localhost` if your radio is meant to be accessible locally only.
4. Edit `config/liquidsoap.liq`
1. Change all instances of `hackme` to a new password.
2. If you changed `CSERVER_MUSIC_DIR` in step 1, change any instances of the default value `/music/` to match it here.
5. (_Optional_) Edit `config/nginx.conf`
1. For advanced users deploying Cadence to a server with DNS, Cadence ships with a reverse proxy which will forward requests based on domain-name to backend services. Simply configure the `server_name` values with your domain names.
6. `docker compose up`

### Accessing Services
Assuming no changes were made to port numbers or the hostnames during installation:

- The UI is accessible in a browser at `localhost:8080`
- API server requests may also be sent to the `localhost:8080` path. See the API Reference for more details.
- The audio stream is accessible at `localhost:8000/cadence1`.
- Assuming you kept the default values above, Cadence will become accessible in a browser at `localhost:8080`.
- If you optionally followed step 5 to make Cadence publicly accessible, open firewall port `80` and point DNS to your server.

## 👩‍💻 Developing

### Building the Stack
If you are changing code and need to rebuild exactly what you have, use the `--build` flag.
If you changed code or updated a container image, and need to rebuild exactly what you have, use the `--build` flag.

1. `docker compose down; docker compose up --build`
`docker compose down; docker compose up --build`

### Development Mode
Cadence provides special administrative control that may be useful for testing though an API that may be optionally enabled. As the name implies, don't enable development mode on a production server.
### Enable Development API
Cadence provides special administrative controls that may be useful for testing though an optionally enabled API. Don't enable development mode on a production server. See the API reference for more details.

1. Edit `cadence/config/cadence.env`.
1. Set `CSERVER_DEVMODE` from `0` (disabled) to `1` (enabled).
1. Edit `config/cadence.env`.
1. Set `CSERVER_DEVMODE` to `1` (enabled).

### API Reference
[Cadence's API Reference](https://github.com/kenellorando/cadence/wiki/API-Reference) provides usage details and complete request/response examples.
Interested in developing custom scripts or clients for your Cadence Radio? [Cadence's API Reference](https://github.com/kenellorando/cadence/wiki/API-Reference) provides usage details and complete request/response examples.
118 changes: 58 additions & 60 deletions cadence/server/api_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"net"
"net/http"
"strings"
"time"

"github.com/Jeffail/gabs"
Expand Down Expand Up @@ -39,6 +40,7 @@ type SongData struct {
// Takes a query string to search the database.
// Returns a slice of SongData of songs ordered by relevance.
func searchByQuery(query string) (queryResults []SongData, err error) {
query = strings.TrimSpace(query)
clog.Debug("searchByQuery", fmt.Sprintf("Searching database for query: '%v'", query))
selectWhereStatement := fmt.Sprintf("SELECT \"id\", \"artist\", \"title\",\"album\", \"genre\", \"year\" FROM %s ",
c.PostgresTableName) + "WHERE artist ILIKE $1 OR title ILIKE $2 ORDER BY LEAST(levenshtein($3, artist), levenshtein($4, title))"
Expand All @@ -64,6 +66,7 @@ func searchByQuery(query string) (queryResults []SongData, err error) {
// Returns a list of SongData whose one result is the first (best) match.
// This will not work if multiple songs share the exact same title and artist.
func searchByTitleArtist(title string, artist string) (queryResults []SongData, err error) {
title, artist = strings.TrimSpace(title), strings.TrimSpace(artist)
clog.Debug("searchByTitleArtist", fmt.Sprintf("Searching database for: '%s by %s", title, artist))
selectStatement := fmt.Sprintf("SELECT id,artist,title,album,genre,year FROM %s WHERE title LIKE $1 AND artist LIKE $2;",
c.PostgresTableName)
Expand Down Expand Up @@ -176,75 +179,70 @@ func filesystemMonitor() {
// Watches the Icecast status page and updates stream info for SSE.
func icecastMonitor() {
var prev = RadioInfo{}

// Resets now playing, stream URL, and listener global variables to defaults. Used when Icecast is unreachable.
icecastDataReset := func() {
now.Song.Title, now.Song.Artist, now.Host, now.Mountpoint = "-", "-", "-", "-"
now.Listeners = -1
}
checkIcecastStatus := func() {
resp, err := http.Get("http://" + c.IcecastAddress + c.IcecastPort + "/status-json.xsl")
if err != nil {
clog.Error("icecastMonitor", "Unable to stream data from the Icecast service.", err)
icecastDataReset()
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
clog.Debug("icecastMonitor", "Unable to connect to Icecast.")
icecastDataReset()
}
body, err := io.ReadAll(resp.Body)
if err != nil {
clog.Debug("icecastMonitor", "Connected to Icecast but unable to read response.")
icecastDataReset()
}
jsonParsed, err := gabs.ParseJSON([]byte(body))
if err != nil {
clog.Debug("icecastMonitor", "Connected to Icecast but unable to parse response.")
icecastDataReset()
}
if jsonParsed.Path("icestats.source.title").Data() == nil || jsonParsed.Path("icestats.source.artist").Data() == nil {
clog.Debug("icecastMonitor", "Connected to Icecast, but saw nothing playing.")
icecastDataReset()
}

go func() {
for {
time.Sleep(1 * time.Second)
resp, err := http.Get("http://" + c.IcecastAddress + c.IcecastPort + "/status-json.xsl")
defer resp.Body.Close()
if err != nil {
clog.Error("icecastMonitor", "Unable to stream data from the Icecast service.", err)
icecastDataReset()
continue
}
if resp.StatusCode != http.StatusOK {
clog.Debug("icecastMonitor", "Unable to connect to Icecast.")
icecastDataReset()
continue
}
body, err := io.ReadAll(resp.Body)
if err != nil {
clog.Debug("icecastMonitor", "Connected to Icecast but unable to read response.")
icecastDataReset()
continue
}
jsonParsed, err := gabs.ParseJSON([]byte(body))
if err != nil {
clog.Debug("icecastMonitor", "Connected to Icecast but unable to parse response.")
icecastDataReset()
continue
}
if jsonParsed.Path("icestats.source.title").Data() == nil || jsonParsed.Path("icestats.source.artist").Data() == nil {
clog.Debug("icecastMonitor", "Connected to Icecast, but saw nothing playing.")
icecastDataReset()
continue
}

now.Song.Artist = jsonParsed.Path("icestats.source.artist").Data().(string)
now.Song.Title = jsonParsed.Path("icestats.source.title").Data().(string)
now.Host = jsonParsed.Path("icestats.host").Data().(string)
now.Mountpoint = jsonParsed.Path("icestats.source.server_name").Data().(string)
now.Listeners = jsonParsed.Path("icestats.source.listeners").Data().(float64)
now.Bitrate = jsonParsed.Path("icestats.source.bitrate").Data().(float64)
now.Song.Artist = jsonParsed.Path("icestats.source.artist").Data().(string)
now.Song.Title = jsonParsed.Path("icestats.source.title").Data().(string)
now.Host = jsonParsed.Path("icestats.host").Data().(string)
now.Mountpoint = jsonParsed.Path("icestats.source.server_name").Data().(string)
now.Listeners = jsonParsed.Path("icestats.source.listeners").Data().(float64)
now.Bitrate = jsonParsed.Path("icestats.source.bitrate").Data().(float64)

if (prev.Song.Title != now.Song.Title) || (prev.Song.Artist != now.Song.Artist) {
clog.Info("icecastMonitor", fmt.Sprintf("Now Playing: %s by %s", now.Song.Title, now.Song.Artist))
radiodata_sse.SendEventMessage(now.Song.Title, "title", "")
radiodata_sse.SendEventMessage(now.Song.Artist, "artist", "")
if (prev.Song.Title != "") && (prev.Song.Artist != "") {
history = append(history, playRecord{Title: prev.Song.Title, Artist: prev.Song.Artist, Ended: time.Now()})
if len(history) > 10 {
history = history[1:]
}
radiodata_sse.SendEventMessage("update", "history", "")
if (prev.Song.Title != now.Song.Title) || (prev.Song.Artist != now.Song.Artist) {
clog.Info("icecastMonitor", fmt.Sprintf("Now Playing: %s by %s", now.Song.Title, now.Song.Artist))
radiodata_sse.SendEventMessage(now.Song.Title, "title", "")
radiodata_sse.SendEventMessage(now.Song.Artist, "artist", "")
if (prev.Song.Title != "") && (prev.Song.Artist != "") {
history = append(history, playRecord{Title: prev.Song.Title, Artist: prev.Song.Artist, Ended: time.Now()})
if len(history) > 10 {
history = history[1:]
}
radiodata_sse.SendEventMessage("update", "history", "")
}
if (prev.Host != now.Host) || (prev.Mountpoint != now.Mountpoint) {
clog.Info("icecastMonitor", fmt.Sprintf("Audio stream on: <%s/%s>", now.Host, now.Mountpoint))
radiodata_sse.SendEventMessage(fmt.Sprintf(now.Host, "/", now.Mountpoint), "listenurl", "")
}
if prev.Listeners != now.Listeners {
clog.Info("icecastMonitor", fmt.Sprintf("Listener count: <%v>", now.Listeners))
radiodata_sse.SendEventMessage(fmt.Sprint(now.Listeners), "listeners", "")
}
prev = now
resp.Body.Close()
}
if (prev.Host != now.Host) || (prev.Mountpoint != now.Mountpoint) {
clog.Info("icecastMonitor", fmt.Sprintf("Audio stream on: <%s/%s>", now.Host, now.Mountpoint))
radiodata_sse.SendEventMessage(fmt.Sprintf(now.Host, "/", now.Mountpoint), "listenurl", "")
}
if prev.Listeners != now.Listeners {
clog.Info("icecastMonitor", fmt.Sprintf("Listener count: <%v>", now.Listeners))
radiodata_sse.SendEventMessage(fmt.Sprint(now.Listeners), "listeners", "")
}
prev = now
}
go func() {
for {
time.Sleep(1 * time.Second)
checkIcecastStatus()
}
}()
}
Expand Down
40 changes: 23 additions & 17 deletions cadence/server/db_postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ func postgresInit() (err error) {
c.PostgresAddress, c.PostgresPort, c.PostgresUser, c.PostgresPassword, c.PostgresSSL)
dbp, err = sql.Open("postgres", dsn)
if err != nil {
clog.Error("postgresConfig", "Could not open connection to database.", err)
clog.Error("postgresInit", "Could not open connection to database.", err)
}
err = dbp.Ping()
if err != nil {
clog.Error("postgresConfig", "Could not successfully ping the metadata database.", err)
clog.Error("postgresInit", "Could not successfully ping the metadata database.", err)
}
// Enable fuzzystrmatch for levenshtein sorting.
// This enables the database to return results based on search similarity.
Expand All @@ -39,24 +39,19 @@ func postgresInit() (err error) {
if err != nil {
if err.(*pq.Error).Code == "42710" {
// 42710 also indicates an existing Postgres instance configured by another Cadence instance is still running.
clog.Debug("postgresInit", "fuzzystrmatch already enabled on metadata database.")
clog.Info("postgresInit", "fuzzystrmatch already enabled on metadata database.")
} else {
clog.Error("postgresInit", "Failed to enable fuzzystrmatch. Search may be degraded.", err)
return err
}
}
// We start an initial population now regardless if we know the database already exists
// in case there are no other running Cadence instances already running.
postgresPopulate()
if err != nil {
clog.Error("postgresConfig", "Failed to complete initial metadata population.", err)
}
return nil
}

func postgresPopulate() error {
dropDatabase := fmt.Sprintf("DROP DATABASE IF EXISTS %s", c.PostgresDBName)
createDatabase := fmt.Sprintf("CREATE DATABASE %s", c.PostgresDBName)
dropTable := fmt.Sprintf("DROP TABLE IF EXISTS %s", c.PostgresTableName)
createTable := fmt.Sprintf(`CREATE TABLE %s
(
id serial PRIMARY KEY,
Expand All @@ -72,24 +67,35 @@ func postgresPopulate() error {
)`, c.PostgresTableName)

// Drop the database and rebuild it to start fresh.
clog.Debug("postgresInit", fmt.Sprintf("Deleting existing databases named <%s>.", c.PostgresDBName))
clog.Debug("postgresPopulate", fmt.Sprintf("Deleting existing databases named <%s>...", c.PostgresDBName))
_, err := dbp.Exec(dropDatabase)
if err != nil {
clog.Error("postgresInit", "Failed to remove existing dbp. Skipping remaining autoconfig steps.", err)
clog.Error("postgresPopulate", "Failed to remove existing dbp. Skipping remaining autoconfig steps.", err)
return err
}
clog.Debug("postgresInit", fmt.Sprintf("Creating database <%s>.", c.PostgresDBName))
clog.Debug("postgresPopulate", fmt.Sprintf("Creating database <%s>...", c.PostgresDBName))
_, err = dbp.Exec(createDatabase)
if err != nil {
clog.Error("postgresInit", "Failed to create dbp. Skipping remaining autoconfig steps.", err)
clog.Error("postgresPopulate", "Failed to create database. Skipping remaining autoconfig steps.", err)
return err
}
clog.Debug("postgresInit", fmt.Sprintf("Reconnected. Building database schema for table <%s>...", c.PostgresTableName))
_, err = dbp.Exec(createTable)
clog.Debug("postgresPopulate", fmt.Sprintf("Dropping table <%s>...", c.PostgresTableName))
_, err = dbp.Exec(dropTable)
if err != nil {
clog.Error("postgresInit", "Failed to build database table!", err)
clog.Error("postgresPopulate", "Failed to drop table. Skipping remaining autoconfig steps.", err)
return err
}
clog.Debug("postgresPopulate", fmt.Sprintf("Creating table <%s>...", c.PostgresTableName))
_, err = dbp.Exec(createTable)
if err != nil {
if err.(*pq.Error).Code == "42P07" {
// 42P10 indicates an existing metadata table configured by another Cadence instance is still running.
clog.Info("postgresInit", "Metadata database already exists")
} else {
clog.Error("postgresPopulate", "Failed to build database table!", err)
return err
}
}
clog.Debug("dbPopulate", "Verifying music metadata directory is accessible...")
_, err = os.Stat(c.MusicDir)
if err != nil {
Expand All @@ -112,11 +118,11 @@ func postgresPopulate() error {
for _, ext := range extensions {
if strings.HasSuffix(path, ext) {
file, err := os.Open(path)
defer file.Close()
if err != nil {
clog.Error("dbPopulate", fmt.Sprintf("A problem occured opening <%s>.", path), err)
return err
}
defer file.Close()
tags, err := tag.ReadFrom(file)
if err != nil {
clog.Error("dbPopulate", fmt.Sprintf("A problem occured fetching tags from <%s>.", path), err)
Expand Down
22 changes: 20 additions & 2 deletions cadence/server/db_redis.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package main

import (
"context"
"net"
"net/http"
"time"

Expand All @@ -28,8 +29,12 @@ func redisInit() {

func rateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
_, err := dbr.RateLimit.Get(ctx, ip).Result()
ip, err := checkIP(r)
if err != nil {
w.WriteHeader(http.StatusInternalServerError) // 500 Internal Server Error
return
}
_, err = dbr.RateLimit.Get(ctx, ip).Result()
if err != nil {
if err == redis.Nil {
// redis.Nil means the IP is not in the database.
Expand All @@ -47,3 +52,16 @@ func rateLimit(next http.Handler) http.Handler {
}
})
}

func checkIP(r *http.Request) (ip string, err error) {
// We look at the remote address and check the IP.
// If for some reason no remote IP is there, we error to reject.
if r.RemoteAddr != "" {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil || ip == "" {
return "", err
}
return ip, nil
}
return "", err
}
Loading

0 comments on commit db0be45

Please sign in to comment.