├── .dockerignore
├── .github
├── FUNDING.yml
└── workflows
│ └── docker-image.yml
├── .gitignore
├── Dockerfile
├── Dockerfile.multiarch
├── LICENSE
├── Makefile
├── README.md
├── cmd
└── main.go
├── docker-compose.yml
├── docs
├── images
│ ├── audio-pipeline.png
│ ├── logo.png
│ ├── playback-lifecycle.png
│ ├── screenshot01.png
│ ├── screenshot02.png
│ ├── screenshot03.png
│ └── tech-stack.png
├── installation.md
├── overview.md
└── roadmap.md
├── go.mod
├── go.sum
├── internal
├── config
│ └── config.go
├── events
│ ├── emitter.go
│ └── event.go
├── ffmpeg
│ ├── const.go
│ ├── ffmpeg.go
│ └── types.go
├── hls
│ ├── const.go
│ ├── playlist.go
│ ├── playlist_test.go
│ ├── segment.go
│ └── segment_test.go
├── http
│ ├── consts.go
│ ├── handlers.go
│ ├── messages.go
│ ├── middlewares.go
│ ├── parser.go
│ └── server.go
├── logger
│ └── logger.go
├── playback
│ ├── service.go
│ ├── state.go
│ └── types.go
├── playlist
│ ├── consts.go
│ ├── service.go
│ └── types.go
├── queue
│ ├── service.go
│ └── types.go
├── storage
│ ├── sqlite
│ │ ├── playback.go
│ │ ├── playlist.go
│ │ ├── queue.go
│ │ ├── sqlite.go
│ │ └── track.go
│ └── storage.go
├── tools
│ ├── fs
│ │ └── fs.go
│ ├── network
│ │ └── network.go
│ ├── sql
│ │ └── sql.go
│ └── ulid
│ │ └── ulid.go
└── track
│ ├── consts.go
│ ├── service.go
│ └── types.go
└── web
├── player
├── .gitignore
├── .prettierrc
├── README.md
├── index.html
├── package-lock.json
├── package.json
├── public
│ ├── favicon.svg
│ ├── icon128.png
│ ├── icon144.png
│ ├── icon152.png
│ ├── icon192.png
│ ├── icon256.png
│ ├── icon48.png
│ ├── icon512.png
│ ├── icon72.png
│ └── icon96.png
├── src
│ ├── App.tsx
│ ├── api
│ │ ├── index.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── index.css
│ ├── index.tsx
│ ├── page
│ │ ├── CurrentTrack.module.css
│ │ ├── CurrentTrack.tsx
│ │ ├── History.module.css
│ │ ├── History.tsx
│ │ ├── ListenersCounter.module.css
│ │ ├── ListenersCounter.tsx
│ │ ├── Page.module.css
│ │ ├── RadioButton.module.css
│ │ ├── RadioButton.tsx
│ │ └── index.tsx
│ ├── store
│ │ ├── events.ts
│ │ ├── history.ts
│ │ └── track.ts
│ ├── utils
│ │ └── date.ts
│ └── vite-env.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
└── studio
├── .gitignore
├── .prettierrc
├── README.md
├── index.html
├── package-lock.json
├── package.json
├── public
├── favicon.svg
├── icon128.png
├── icon144.png
├── icon152.png
├── icon192.png
├── icon256.png
├── icon48.png
├── icon512.png
├── icon72.png
└── icon96.png
├── src
├── App.tsx
├── api
│ ├── index.ts
│ ├── types.ts
│ └── utils.ts
├── components
│ ├── AudioPlayer.module.css
│ ├── AudioPlayer.tsx
│ ├── AuthGuard.module.css
│ ├── AuthGuard.tsx
│ └── EmptyLabel.tsx
├── hooks
│ └── useThemeBlackColor.ts
├── icons
│ ├── index.tsx
│ └── types.ts
├── index.css
├── main.tsx
├── notifications
│ └── index.ts
├── page
│ ├── DesktopPage.tsx
│ ├── MobileBar.tsx
│ ├── MobilePage.tsx
│ ├── Playback.tsx
│ ├── Playlists.tsx
│ ├── TracksLibrary.tsx
│ ├── TracksQueue.tsx
│ ├── index.tsx
│ └── styles.module.css
├── store
│ ├── events.ts
│ ├── playback.ts
│ ├── playlists.ts
│ ├── track-queue.ts
│ └── tracks.ts
├── types
│ └── index.ts
├── utils
│ ├── array.ts
│ ├── error.ts
│ └── time.ts
└── vite-env.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.dockerignore:
--------------------------------------------------------------------------------
1 | docs
2 | Makefile
3 | README.md
4 | Dockerfile.multiarch
5 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [cheatsnake]
2 | buy_me_a_coffee: yurace
3 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Build and Push Docker Image
2 |
3 | on:
4 | push:
5 | tags:
6 | - "*"
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout code
14 | uses: actions/checkout@v3
15 |
16 | - name: Set up Docker Buildx
17 | uses: docker/setup-buildx-action@v3
18 |
19 | - name: Log in to Docker Hub
20 | uses: docker/login-action@v3
21 | with:
22 | username: ${{ secrets.DOCKER_USERNAME }}
23 | password: ${{ secrets.DOCKER_PASSWORD }}
24 |
25 | - name: Build and push Docker image
26 | uses: docker/build-push-action@v5
27 | with:
28 | context: .
29 | file: Dockerfile.multiarch
30 | push: true
31 | platforms: linux/amd64,linux/arm64
32 | tags: |
33 | cheatsnake/airstation:${{ github.ref_name }}
34 | cheatsnake/airstation:latest
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | static
3 | *.db
4 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine AS player
2 | WORKDIR /app
3 | COPY ./web/player/package*.json ./
4 | RUN npm install
5 | COPY ./web/player .
6 | ARG AIRSTATION_PLAYER_TITLE
7 | ENV AIRSTATION_PLAYER_TITLE=$AIRSTATION_PLAYER_TITLE
8 | RUN npm run build
9 |
10 | FROM node:22-alpine AS studio
11 | WORKDIR /app
12 | COPY ./web/studio/package*.json ./
13 | RUN npm install
14 | COPY ./web/studio .
15 | RUN npm run build
16 |
17 | FROM golang:1.24-alpine AS server
18 | WORKDIR /app
19 | COPY go.mod go.sum ./
20 | RUN go mod download
21 | COPY cmd/ ./cmd/
22 | COPY internal/ ./internal/
23 | RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/bin/main ./cmd/main.go
24 |
25 | FROM alpine:latest
26 | WORKDIR /app
27 | RUN apk add --no-cache ffmpeg
28 | COPY --from=server /app/bin/main .
29 | COPY --from=player /app/dist ./web/player/dist
30 | COPY --from=studio /app/dist ./web/studio/dist
31 | EXPOSE 7331
32 | ENTRYPOINT ["./main"]
--------------------------------------------------------------------------------
/Dockerfile.multiarch:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine AS player
2 | WORKDIR /app
3 | COPY ./web/player/package*.json ./
4 | RUN npm install
5 | COPY ./web/player .
6 | RUN npm run build
7 |
8 | FROM node:22-alpine AS studio
9 | WORKDIR /app
10 | COPY ./web/studio/package*.json ./
11 | RUN npm install
12 | COPY ./web/studio .
13 | RUN npm run build
14 |
15 | FROM golang:1.24-alpine AS server
16 | ARG TARGETOS
17 | ARG TARGETARCH
18 | WORKDIR /app
19 | COPY go.mod go.sum ./
20 | RUN go mod download
21 | COPY cmd/ ./cmd/
22 | COPY internal/ ./internal/
23 | RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-s -w" -o /app/bin/main ./cmd/main.go
24 |
25 | FROM alpine:latest
26 | WORKDIR /app
27 | RUN apk add --no-cache ffmpeg
28 | COPY --from=server /app/bin/main .
29 | COPY --from=player /app/dist ./web/player/dist
30 | COPY --from=studio /app/dist ./web/studio/dist
31 | EXPOSE 7331
32 | ENTRYPOINT ["./main"]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 cheatsnake
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build
2 |
3 | test:
4 | @go test -cover -race ./... | grep -v '^?'
5 | fmt:
6 | go fmt ./...
7 | count-lines:
8 | @echo "total code lines:" && find . -name "*.go" -exec cat {} \; | wc -l
9 |
10 | build:
11 | @echo "⚙️ Installing web player dependencies..."
12 | @npm ci --prefix ./web/player
13 |
14 | @echo "⚙️ Installing web studio dependencies..."
15 | @npm ci --prefix ./web/studio
16 |
17 | @echo "🛠️ Building web player..."
18 | @npm run build --prefix ./web/player
19 |
20 | @echo "🛠️ Building web studio..."
21 | @npm run build --prefix ./web/studio
22 |
23 | @echo "🛠️ Building web server..."
24 | @go build ./cmd/main.go
25 |
26 | @echo "✅ Build completed successfully"
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Airstation
9 | Your own online radio station
10 |
11 | 🔍 Overview
12 | ⚙️ Installation
13 | 🗺️ Roadmap
14 | 🚨 Bug report
15 |
16 |
17 |
18 | Airstation is a self-hosted web app for streaming music over the Internet. It features a simple interface for uploading tracks and managing the playback queue, along with a minimalistic player for listeners. Under the hood, it streams music over HTTP using HLS, stores data in SQLite, and leverages FFmpeg for audio processing — all packaged in a compact Docker container for easy deployment.
19 |
20 |
21 |
22 |
23 |
24 |
25 | Made for fun
26 | `AIRSTATION_SECRET_KEY` - the secret key you need to log in to the station control panel `AIRSTATION_JWT_SIGN` - the key to sign the JWT session
33 |
34 | > Use [random string generator](https://it-tools.tech/token-generator?length=20) with a length of at least 10 characters for these variables!
35 |
36 | 3. Build a docker image and start a new container
37 |
38 | ```sh
39 | docker compose up -d
40 | ```
41 |
42 | And finally you can see:
43 |
44 | - Control panel on [http://localhost:7331/studio/](http://localhost:7331/studio/) (extra slash matters!)
45 | - Radio player on [http://localhost:7331](http://localhost:7331)
46 |
47 | To stop the container, just type:
48 |
49 | ```sh
50 | docker compose down
51 | ```
52 |
53 | ### Docker Compose
54 |
55 | You can get pre-built image from [Docker Hub](https://hub.docker.com/r/cheatsnake/airstation) and run it quickly with custom `docker-compose.yml` file as shown bellow:
56 |
57 | ```yml
58 | # docker-compose.yml
59 | services:
60 | airstation:
61 | image: cheatsnake/airstation:latest
62 | ports:
63 | - "7331:7331"
64 | volumes:
65 | - airstation-data:/app/storage
66 | - ./static:/app/static
67 | restart: unless-stopped
68 | environment:
69 | AIRSTATION_SECRET_KEY: ${AIRSTATION_SECRET_KEY:-PASTE_YOUR_OWN_KEY}
70 | AIRSTATION_JWT_SIGN: ${AIRSTATION_JWT_SIGN:-PASTE_RANDOM_STRING}
71 | healthcheck:
72 | test: ["CMD", "wget", "--spider", "-q", "http://localhost:7331/"]
73 | interval: 10s
74 | timeout: 5s
75 | retries: 3
76 | start_period: 10s
77 |
78 | volumes:
79 | airstation-data:
80 | ```
81 |
82 | > Don't forget to modify environment variables inside this file or via your own `.env` file in the same directory as the `docker-compose.yml`
83 |
84 | ## Build from source
85 |
86 | 1. Follow steps 1 and 2 from the previous section
87 |
88 | 2. Install dependencies
89 |
90 | ```sh
91 | npm ci --prefix ./web/player
92 | ```
93 |
94 | ```sh
95 | npm ci --prefix ./web/studio
96 | ```
97 |
98 | 3. Build web clients
99 |
100 | ```sh
101 | npm run build --prefix ./web/player
102 | ```
103 |
104 | ```sh
105 | npm run build --prefix ./web/studio
106 | ```
107 |
108 | 4. Build server
109 |
110 | ```sh
111 | go build ./cmd/main.go
112 | ```
113 |
114 | 5. Run app
115 |
116 | ```sh
117 | ./main
118 | ```
119 |
120 | > Make sure you have [FFmpeg](https://ffmpeg.org/) installed on your system.
121 |
122 | See the result on [http://localhost:7331](http://localhost:7331) and [http://localhost:7331/studio/](http://localhost:7331/studio/) (extra slash matters!)
123 |
124 | ## Development mode
125 |
126 | To run the application in development mode, start each part of the application using the commands below:
127 |
128 | ```sh
129 | npm run dev --prefix ./web/player
130 | ```
131 |
132 | ```sh
133 | npm run dev --prefix ./web/studio
134 | ```
135 |
136 | ```sh
137 | go run ./cmd/main.go
138 | ```
139 |
--------------------------------------------------------------------------------
/docs/overview.md:
--------------------------------------------------------------------------------
1 | # Overview
2 |
3 | Airstation allows you to organize your own internet radio station where your audio tracks will be played. The application was created with the purpose of being extremely simple and affordable way to organize your own radio. In fact, I don't even know if it makes sense to describe any user documentation, because all the applications can be presented in two screenshots.
4 |
5 | Logically, the frontend part of the application can be divided into 2 parts. The first is the control panel where the radio station is controlled. The second is a minimalistic radio player for listeners.
6 |
7 | The backend is organized simply. Track metadata and playback history are stored in an [SQLite](https://en.wikipedia.org/wiki/SQLite) database, while the audio files are saved in a static folder on the server. All tracks are processed using [FFmpeg](https://en.wikipedia.org/wiki/FFmpeg) to standardize their format and generate [HLS](https://en.wikipedia.org/wiki/HTTP_Live_Streaming) playlists for streaming.
8 |
9 |
10 |
11 | ## Main features
12 |
13 | In fact, only the most necessary functionality has been implemented at the current stage:
14 |
15 | - Permanent storage of tracks in the library
16 | - Ability to listen to added tracks
17 | - Ability to delete tracks from the library
18 | - Search for tracks in the library
19 | - Sort tracks by date added, name, duration.
20 | - Creating a track queue
21 | - Changing the current track queue
22 | - Cyclic queue mode
23 | - Possibility to randomly mix the queue
24 | - Possibility to temporarily stop the radio station
25 | - Playback history
26 | - Listener counter
27 | - Playlists mechanism
28 |
29 | ## Deep under the hood
30 |
31 | The following will describe in more detail how the application works. To better understand how streaming happens, it is worth considering the path an audio file takes before users hear it. Each track added to the station and played goes through several lifecycle stages:
32 |
33 |
34 |
35 | - First, as the station owner, you upload the track to the server. Typically, these are `mp3` or `aac` files with varying bitrates.
36 | - Next, all files are transcoded into a unified codec and bitrate, then stored permanently on the server.
37 | - When it's time to play the track, it is converted into an `m3u8` playlist — essentially, the audio file is split into small chunks for progressive streaming to the station's listeners.
38 |
39 | To ensure all listeners are at the same point in track playback, a special playback state object is used. While the station is running, the following lifecycle occurs:
40 |
41 |
42 |
43 | - The object selects the first track in your queue and generates a temporary HLS playlist by splitting the track into small chunks (typically 5 seconds each).
44 | - As soon as the track enters the playback state, playback time starts counting.
45 | - Based on the elapsed time, the corresponding chunks are selected from the playlist (usually at least 3 chunks to provide a buffer on the listener's side).
46 | - Each listener periodically requests the current chunks to maintain uninterrupted playback.
47 |
48 | ## Who is this app suitable for?
49 |
50 | Anyone can actually have their own radio station - we all listen to music. And it's really great to share your musical flavor with friends or your own audience. All you need is a [VPS server](https://en.wikipedia.org/wiki/Virtual_private_server) on which you can deploy your station. In fact, it costs pennies and you don't need to become a system administrator or Linux guru. One [YouTube video tutorial](https://www.youtube.com/results?search_query=how+to+vps) will open up a new world of [self-hosted solutions](https://github.com/awesome-selfhosted/awesome-selfhosted).
--------------------------------------------------------------------------------
/docs/roadmap.md:
--------------------------------------------------------------------------------
1 | # Roadmap
2 |
3 | Here you can find a list of future updates.
4 |
5 | ## 🚧 In Progress
6 |
7 | - [ ] Settings window for Player
8 | - [ ] Settings window for Studio
9 |
10 | ---
11 |
12 | ## 📝 Planned Features
13 |
14 | - [ ] Tags for tracks (as a grouping mechanism)
15 | - [ ] Ability to send voice messages recorded through the microphone
16 | - [ ] Scheduling mechanism for tracks/playlists (by [hjdx2009](https://github.com/cheatsnake/airstation/issues/7#issue-3059402373))
17 |
18 | ---
19 |
20 | ## 🌟 Long-Term Goals
21 |
22 | - [ ] Crossfade effect between tracks (by [rursache](https://github.com/cheatsnake/airstation/issues/5#issuecomment-2873728112))
23 | - [ ] Integration with online music services for downloading tracks directly to the library
24 | - [ ] Built-in tunneling feature (to share your local server with others over the Internet)
25 | - [ ] Integration with other music programs to synchronize track playback (by [swishkin](https://github.com/cheatsnake/airstation/issues/8#issue-3069650457))
26 | ---
27 |
28 | ## ✅ Done
29 |
30 | - [x] Saving playlists (ability to pre-create and select already created playlists for playback)
31 |
32 | ---
33 |
34 | ## 💡 Suggestions?
35 |
36 | Have a feature request or idea? Feel free to open an [issue](https://github.com/cheatsnake/airstation/issues).
37 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/cheatsnake/airstation
2 |
3 | go 1.24
4 |
5 | require github.com/oklog/ulid/v2 v2.1.1
6 |
7 | require github.com/rs/cors v1.11.1
8 |
9 | require (
10 | github.com/golang-jwt/jwt/v5 v5.2.2
11 | github.com/joho/godotenv v1.5.1
12 | modernc.org/sqlite v1.37.1
13 | )
14 |
15 | require (
16 | github.com/dustin/go-humanize v1.0.1 // indirect
17 | github.com/google/uuid v1.6.0 // indirect
18 | github.com/mattn/go-isatty v0.0.20 // indirect
19 | github.com/ncruces/go-strftime v0.1.9 // indirect
20 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
21 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
22 | golang.org/x/sys v0.33.0 // indirect
23 | modernc.org/libc v1.65.7 // indirect
24 | modernc.org/mathutil v1.7.1 // indirect
25 | modernc.org/memory v1.11.0 // indirect
26 | )
27 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
2 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
3 | github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
4 | github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
5 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
6 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
7 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
8 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
9 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
10 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
11 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
12 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
13 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
14 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
15 | github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
16 | github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
17 | github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
18 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
19 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
20 | github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
21 | github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
22 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
23 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
24 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
25 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
26 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
27 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
28 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
29 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
30 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
31 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
32 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
33 | modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
34 | modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
35 | modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
36 | modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
37 | modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
38 | modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
39 | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
40 | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
41 | modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
42 | modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
43 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
44 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
45 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
46 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
47 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
48 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
49 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
50 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
51 | modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
52 | modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
53 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
54 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
55 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
56 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
57 |
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "log"
5 | "os"
6 | "path/filepath"
7 | "strings"
8 |
9 | "github.com/joho/godotenv"
10 | )
11 |
12 | const minSecretLength = 10
13 |
14 | type Config struct {
15 | DBDir string
16 | DBFile string
17 | TracksDir string
18 | TmpDir string
19 | PlayerDir string
20 | StudioDir string
21 | HTTPPort string
22 | JWTSign string
23 | SecretKey string
24 | SecureCookie bool
25 | }
26 |
27 | func Load() *Config {
28 | _ = godotenv.Load() // For development
29 |
30 | return &Config{
31 | DBDir: getEnv("AIRSTATION_DB_DIR", filepath.Join("storage")),
32 | DBFile: getEnv("AIRSTATION_DB_FILE", "storage.db"),
33 | TracksDir: getEnv("AIRSTATION_TRACKS_DIR", filepath.Join("static", "tracks")),
34 | TmpDir: getEnv("AIRSTATION_TMP_DIR", filepath.Join("static", "tmp")),
35 | PlayerDir: getEnv("AIRSTATION_PLAYER_DIR", filepath.Join("web", "player", "dist")),
36 | StudioDir: getEnv("AIRSTATION_STUDIO_DIR", filepath.Join("web", "studio", "dist")),
37 | HTTPPort: getEnv("AIRSTATION_HTTP_PORT", "7331"),
38 | JWTSign: getSecret("AIRSTATION_JWT_SIGN"),
39 | SecretKey: getSecret("AIRSTATION_SECRET_KEY"),
40 | SecureCookie: getEnvBool("AIRSTATION_SECURE_COOKIE", false),
41 | }
42 | }
43 |
44 | func getEnv(key, defaultValue string) string {
45 | if value := os.Getenv(key); value != "" {
46 | return value
47 | }
48 | return defaultValue
49 | }
50 |
51 | func getEnvBool(key string, defaultValue bool) bool {
52 | val := os.Getenv(key)
53 | if val == "" {
54 | return defaultValue
55 | }
56 |
57 | val = strings.ToLower(val)
58 | return val == "1" || val == "true" || val == "yes" || val == "on"
59 | }
60 |
61 | func getSecret(key string) string {
62 | secretKey := os.Getenv(key)
63 |
64 | if secretKey == "" {
65 | log.Fatal(key + " environment variable is not set")
66 | }
67 |
68 | if len(secretKey) < minSecretLength {
69 | log.Fatal(key + " is too short")
70 | }
71 |
72 | return secretKey
73 | }
74 |
--------------------------------------------------------------------------------
/internal/events/emitter.go:
--------------------------------------------------------------------------------
1 | package events
2 |
3 | import (
4 | "sync"
5 | )
6 |
7 | // Emitter manages a set of subscribers and broadcasts events to them.
8 | type Emitter struct {
9 | subscribers sync.Map // A thread-safe map storing subscriber channels.
10 | }
11 |
12 | // NewEmitter creates and returns a new Emitter instance.
13 | //
14 | // Returns:
15 | // - A pointer to a new Emitter.
16 | func NewEmitter() *Emitter {
17 | return &Emitter{}
18 | }
19 |
20 | // RegisterEvent broadcasts an event with the specified name and data
21 | // to all currently subscribed channels.
22 | //
23 | // Parameters:
24 | // - name: The event name/type.
25 | // - data: The string payload of the event.
26 | func (ee *Emitter) RegisterEvent(name, data string) {
27 | event := NewEvent(name, data)
28 | ee.subscribers.Range(func(key, value any) bool {
29 | eventChan := key.(chan *Event)
30 | eventChan <- event
31 | return true
32 | })
33 | }
34 |
35 | // CountSubscribers returns the number of currently active subscribers.
36 | //
37 | // Returns:
38 | // - An integer count of subscriber channels.
39 | func (ee *Emitter) CountSubscribers() int {
40 | count := 0
41 | ee.subscribers.Range(func(key, value any) bool {
42 | count++
43 | return true
44 | })
45 | return count
46 | }
47 |
48 | // Subscribe adds a new subscriber channel to receive events.
49 | //
50 | // Parameters:
51 | // - eventChan: A channel to which events will be sent.
52 | func (ee *Emitter) Subscribe(eventChan chan *Event) {
53 | ee.subscribers.Store(eventChan, true)
54 | }
55 |
56 | // Unsubscribe removes a previously added subscriber channel.
57 | //
58 | // Parameters:
59 | // - eventChan: The channel to remove from the list of subscribers.
60 | func (ee *Emitter) Unsubscribe(eventChan chan *Event) {
61 | ee.subscribers.Delete(eventChan)
62 | }
63 |
--------------------------------------------------------------------------------
/internal/events/event.go:
--------------------------------------------------------------------------------
1 | // Package events provides a lightweight structure and utilities for creating
2 | // and formatting Server-Sent Events (SSE) to be sent over HTTP connections.
3 | package events
4 |
5 | import (
6 | "fmt"
7 | "strings"
8 | )
9 |
10 | // Event represents a Server-Sent Event (SSE) with a name and data payload.
11 | type Event struct {
12 | Name string // The name/type of the event (used as "event:" in SSE format).
13 | Data string // The data payload of the event (used as "data:" in SSE format).
14 | }
15 |
16 | // NewEvent creates a new Event instance with the given name and data.
17 | //
18 | // Parameters:
19 | // - name: The name/type of the event.
20 | // - data: The string data payload associated with the event.
21 | //
22 | // Returns:
23 | // - A pointer to the newly created Event.
24 | func NewEvent(name, data string) *Event {
25 | return &Event{
26 | Name: name,
27 | Data: data,
28 | }
29 | }
30 |
31 | // Stringify converts the Event into a string formatted for Server-Sent Events (SSE).
32 | // The format includes the "event" and "data" fields as per SSE specification,
33 | // followed by a double newline to indicate the end of the event.
34 | //
35 | // Returns:
36 | // - A string representation of the event in SSE format.
37 | func (e *Event) Stringify() string {
38 | var builder strings.Builder
39 |
40 | if e.Name != "" {
41 | builder.WriteString(fmt.Sprintf("event: %s\n", e.Name))
42 | }
43 |
44 | if e.Data != "" {
45 | builder.WriteString(fmt.Sprintf("data: %s\n", e.Data))
46 | }
47 |
48 | builder.WriteString("\n")
49 | return builder.String()
50 | }
51 |
--------------------------------------------------------------------------------
/internal/ffmpeg/const.go:
--------------------------------------------------------------------------------
1 | package ffmpeg
2 |
3 | // Constants defining the executable names.
4 | const (
5 | ffmpegBin = "ffmpeg"
6 | ffprobeBin = "ffprobe"
7 | )
8 |
--------------------------------------------------------------------------------
/internal/ffmpeg/types.go:
--------------------------------------------------------------------------------
1 | package ffmpeg
2 |
3 | // AudioMetadata holds metadata information about an audio file.
4 | type AudioMetadata struct {
5 | Name string // The name of the audio
6 | Duration float64 // The total duration of the audio file in seconds.
7 | BitRate int // The bit rate of the audio file in kbps (kilobits per second).
8 | CodecName string // The name of the codec used for encoding the audio.
9 | SampleRate int // The sample rate of the audio file in Hz (hertz).
10 | ChannelCount int // The number of audio channels (e.g., 1 for mono, 2 for stereo).
11 | }
12 |
13 | type rawAudioMetadata struct {
14 | Format struct {
15 | Duration string `json:"duration"`
16 | BitRate string `json:"bit_rate"`
17 | Tags struct {
18 | Title string `json:"title"`
19 | } `json:"tags"`
20 | } `json:"format"`
21 | Streams []struct {
22 | CodecName string `json:"codec_name"`
23 | SampleRate string `json:"sample_rate"`
24 | Channels int `json:"channels"`
25 | } `json:"streams"`
26 | }
27 |
--------------------------------------------------------------------------------
/internal/hls/const.go:
--------------------------------------------------------------------------------
1 | package hls
2 |
3 | const SegmentExtension = ".ts"
4 | const timeFormat = "2006-01-02T15:04:05.000Z"
5 |
6 | const (
7 | DefaultMaxSegmentDuration = 5
8 | DefaultLiveSegmentsAmount = 3
9 | )
10 |
--------------------------------------------------------------------------------
/internal/hls/playlist.go:
--------------------------------------------------------------------------------
1 | // Package hls provides functionality for handling HTTP Live Streaming (HLS) playlists and segments.
2 | package hls
3 |
4 | import (
5 | "math"
6 | "strconv"
7 | "time"
8 | )
9 |
10 | // Playlist represents an HLS playlist structure.
11 | type Playlist struct {
12 | LiveSegmentsAmount int // The number of live segments in the playlist.
13 | MaxSegmentDuration int // The maximum duration (in seconds) of a segment in the playlist.
14 |
15 | mediaSequence int64
16 | disconSequence int64
17 | lastDisconUpdate time.Time
18 | currentTrackSegments []*Segment
19 | nextTrackSegments []*Segment
20 | currentSegmentPath string
21 | }
22 |
23 | // NewPlaylist creates and returns a new Playlist instance with the provided current and next track segments.
24 | // It initializes the playlist with default values for live segments amount, max segment duration, media sequence,
25 | // discontinuity sequence, and last discontinuity update time.
26 | //
27 | // Parameters:
28 | // - cur: The list of segments for the current track.
29 | // - next: The list of segments for the next track.
30 | //
31 | // Returns:
32 | // - A pointer to the newly created Playlist instance.
33 | func NewPlaylist(cur, next []*Segment) *Playlist {
34 | return &Playlist{
35 | LiveSegmentsAmount: DefaultLiveSegmentsAmount,
36 | MaxSegmentDuration: DefaultMaxSegmentDuration,
37 |
38 | mediaSequence: 0,
39 | disconSequence: 0,
40 | lastDisconUpdate: time.Now(),
41 |
42 | currentTrackSegments: cur,
43 | nextTrackSegments: next,
44 |
45 | currentSegmentPath: "",
46 | }
47 | }
48 |
49 | // Generate constructs and returns the HLS playlist as a string based on the elapsed time.
50 | // It updates the discontinuity sequence, calculates the starting segment index, collects live segments,
51 | // and formats them into the HLS playlist format.
52 | //
53 | // Parameters:
54 | // - elapsedTime: The elapsed time in seconds used to determine the current segment index.
55 | //
56 | // Returns:
57 | // - A string representing the generated HLS playlist.
58 | func (p *Playlist) Generate(elapsedTime float64) string {
59 | offset := math.Mod(elapsedTime, float64(p.MaxSegmentDuration))
60 | liveSegments := p.currentSegments(elapsedTime)
61 | prevSegmentPath := p.currentSegmentPath
62 |
63 | if len(liveSegments) > 0 {
64 | p.currentSegmentPath = liveSegments[0].Path
65 | }
66 |
67 | p.UpdateDisconSequence(elapsedTime)
68 | if prevSegmentPath != p.currentSegmentPath {
69 | p.mediaSequence++
70 | }
71 |
72 | playlist := hlsHeader(p.MaxSegmentDuration, p.mediaSequence, p.disconSequence, offset)
73 | for _, seg := range liveSegments {
74 | playlist += hlsSegment(seg.Duration, seg.Path, seg.IsFirst)
75 | }
76 |
77 | return playlist
78 | }
79 |
80 | // Next updates the playlist by moving the next track segments to the current track segments
81 | // and assigning the provided segments as the new next track segments.
82 | //
83 | // Parameters:
84 | // - next: The new list of segments to be set as the next track segments.
85 | func (p *Playlist) Next(next []*Segment) {
86 | p.currentTrackSegments = p.nextTrackSegments
87 | p.nextTrackSegments = next
88 | }
89 |
90 | // ChangeNext replays segments for the next track.
91 | //
92 | // Parameters:
93 | // - next: The new list of segments to be set as the next track segments.
94 | func (p *Playlist) ChangeNext(next []*Segment) {
95 | p.nextTrackSegments = next
96 | }
97 |
98 | // AddSegments appends the provided segments to the next track segments list.
99 | //
100 | // Parameters:
101 | // - segments: The list of segments to append to the next track segments.
102 | func (p *Playlist) AddSegments(segments []*Segment) {
103 | p.nextTrackSegments = append(p.nextTrackSegments, segments...)
104 | }
105 |
106 | // SetMediaSequence set a new sequence number for mediaSequence.
107 | func (p *Playlist) SetMediaSequence(sequence int64) {
108 | p.mediaSequence = sequence
109 | }
110 |
111 | // UpdateDisconSequence updates the discontinuity sequence if a discontinuity is detected.
112 | //
113 | // Parameters:
114 | // - elapsedTime: The elapsed time in seconds used to calculate the current segment index.
115 | func (p *Playlist) UpdateDisconSequence(elapsedTime float64) {
116 | elapsedFromLastUpdate := time.Until(p.lastDisconUpdate).Seconds()
117 | if math.Abs(elapsedFromLastUpdate) < float64(p.MaxSegmentDuration) {
118 | return
119 | }
120 |
121 | index := p.calcCurrentSegmentIndex(elapsedTime)
122 |
123 | // if the current track segment is the second and it is not the very first track,
124 | // there was a discontinuty, so we increment the discontinuty counter
125 | if index == 1 && p.mediaSequence > 1 {
126 | p.disconSequence++
127 | p.lastDisconUpdate = time.Now()
128 | }
129 | }
130 |
131 | func (p *Playlist) FirstNextTrackSegment() *Segment {
132 | if len(p.nextTrackSegments) > 0 {
133 | return p.nextTrackSegments[0]
134 | }
135 |
136 | return nil
137 | }
138 |
139 | // currentSegments gathers enough segments from current and next tracks to meet liveSegmentsAmount
140 | func (p *Playlist) currentSegments(elapsedTime float64) []*Segment {
141 | startIndex := p.calcCurrentSegmentIndex(elapsedTime)
142 | liveSegments := make([]*Segment, 0, p.LiveSegmentsAmount)
143 |
144 | if startIndex < len(p.currentTrackSegments) {
145 | endIndex := startIndex + p.LiveSegmentsAmount
146 | if endIndex >= len(p.currentTrackSegments) {
147 | endIndex = len(p.currentTrackSegments)
148 | }
149 |
150 | liveSegments = append(liveSegments, p.currentTrackSegments[startIndex:endIndex]...)
151 | }
152 |
153 | if len(liveSegments) < p.LiveSegmentsAmount {
154 | required := p.LiveSegmentsAmount - len(liveSegments)
155 | liveSegments = append(liveSegments, p.nextTrackSegments[:min(len(p.nextTrackSegments), required)]...)
156 | }
157 |
158 | return liveSegments
159 | }
160 |
161 | func (p *Playlist) calcCurrentSegmentIndex(elapsedTime float64) int {
162 | return int(math.Floor(elapsedTime / float64(p.MaxSegmentDuration)))
163 | }
164 |
165 | // hlsHeader generates the header string for an HLS playlist with the specified target duration.
166 | func hlsHeader(dur int, mediaSeq, disconSeq int64, offset float64) string {
167 | currentTime := time.Now().UTC().Round(time.Millisecond).Format(timeFormat)
168 | return "#EXTM3U\n" +
169 | "#EXT-X-VERSION:6\n" +
170 | "#EXT-X-PROGRAM-DATE-TIME:" + currentTime + "\n" +
171 | "#EXT-X-TARGETDURATION:" + strconv.Itoa(dur) + "\n" +
172 | "#EXT-X-MEDIA-SEQUENCE:" + strconv.FormatInt(mediaSeq, 10) + "\n" +
173 | "#EXT-X-DISCONTINUITY-SEQUENCE:" + strconv.FormatInt(disconSeq, 10) + "\n" +
174 | "#EXT-X-START:TIME-OFFSET=" + strconv.FormatFloat(offset, 'f', 2, 64) + "\n"
175 | }
176 |
177 | // hlsSegment generates an HLS segment entry with the specified duration and path.
178 | func hlsSegment(dur float64, path string, isDiscon bool) string {
179 | disconTag := ""
180 |
181 | if isDiscon {
182 | disconTag = "#EXT-X-DISCONTINUITY\n"
183 | }
184 |
185 | duration := strconv.FormatFloat(dur, 'f', 2, 64)
186 | return disconTag +
187 | "#EXTINF:" + duration + ",\n" +
188 | path + "\n"
189 | }
190 |
--------------------------------------------------------------------------------
/internal/hls/segment.go:
--------------------------------------------------------------------------------
1 | package hls
2 |
3 | import (
4 | "math"
5 | "path/filepath"
6 | "strconv"
7 | )
8 |
9 | // Segment represents a single segment in an HLS playlist.
10 | type Segment struct {
11 | Duration float64 // The length of the segment in seconds.
12 | Path string // The file path or URL of the segment.
13 | IsFirst bool // A flag indicating whether this segment is the first segment in the track.
14 | }
15 |
16 | // NewSegment creates and returns a new Segment instance with the provided duration, path, and first segment flag.
17 | //
18 | // Parameters:
19 | // - duration: The duration of the segment in seconds.
20 | // - path: The file path or URL of the segment.
21 | // - isFirst: A boolean flag indicating whether this segment is the first segment in the track.
22 | //
23 | // Returns:
24 | // - A pointer to the newly created Segment instance.
25 | func NewSegment(duration float64, path string, isFirst bool) *Segment {
26 | return &Segment{
27 | Duration: duration,
28 | Path: path,
29 | IsFirst: isFirst,
30 | }
31 | }
32 |
33 | // GenerateSegments creates a list of Segment instances for a given track based on its duration and segment duration.
34 | // It divides the track into segments of the specified duration and generates metadata for each segment.
35 | //
36 | // Parameters:
37 | // - trackDuration: The total duration of the track in seconds.
38 | // - segmentDuration: The desired duration of each segment in seconds.
39 | // - trackID: The unique identifier for the track, used to generate segment names.
40 | // - outDir: The output directory where the segments will be stored.
41 | //
42 | // Returns:
43 | // - A slice of pointers to Segment instances representing the generated segments.
44 | func GenerateSegments(trackDuration float64, segmentDuration int, trackID, outDir string) []*Segment {
45 | if trackDuration <= 0 || segmentDuration <= 0 {
46 | return []*Segment{}
47 | }
48 |
49 | // Calculate total possible number of segments (rounded up)
50 | totalSegments := int(math.Round(trackDuration / float64(segmentDuration)))
51 | segments := make([]*Segment, 0, totalSegments)
52 |
53 | remaining := trackDuration
54 | index := 0
55 |
56 | // Generate segments until the entire track is covered
57 | for remaining > 0 {
58 | segName := trackID + strconv.Itoa(index) + SegmentExtension
59 | segPath := filepath.Join(outDir, segName)
60 |
61 | // Use the smaller of the remaining or full segment duration
62 | duration := math.Min(remaining, float64(segmentDuration))
63 | isFirst := index == 0
64 | segments = append(segments, NewSegment(duration, segPath, isFirst))
65 |
66 | remaining -= duration
67 | index++
68 | }
69 |
70 | return segments
71 | }
72 |
--------------------------------------------------------------------------------
/internal/hls/segment_test.go:
--------------------------------------------------------------------------------
1 | package hls
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestNewSegment(t *testing.T) {
8 | cases := []struct {
9 | name string
10 | duration float64
11 | path string
12 | isFirst bool
13 | expected *Segment
14 | }{
15 | {
16 | name: "Basic segment",
17 | duration: 5.5,
18 | path: "segment0.ts",
19 | isFirst: false,
20 | expected: &Segment{
21 | Duration: 5.5,
22 | Path: "segment0.ts",
23 | IsFirst: false,
24 | },
25 | },
26 | {
27 | name: "First segment",
28 | duration: 8.333,
29 | path: "segment1.ts",
30 | isFirst: true,
31 | expected: &Segment{
32 | Duration: 8.333,
33 | Path: "segment1.ts",
34 | IsFirst: true,
35 | },
36 | },
37 | {
38 | name: "Zero duration",
39 | duration: 0,
40 | path: "segment2.ts",
41 | isFirst: false,
42 | expected: &Segment{
43 | Duration: 0,
44 | Path: "segment2.ts",
45 | IsFirst: false,
46 | },
47 | },
48 | {
49 | name: "Empty path",
50 | duration: 10.0,
51 | path: "",
52 | isFirst: false,
53 | expected: &Segment{
54 | Duration: 10.0,
55 | Path: "",
56 | IsFirst: false,
57 | },
58 | },
59 | }
60 |
61 | for _, c := range cases {
62 | t.Run(c.name, func(t *testing.T) {
63 | got := NewSegment(c.duration, c.path, c.isFirst)
64 |
65 | if got.Duration != c.expected.Duration {
66 | t.Errorf("Duration = %f; want %f", got.Duration, c.expected.Duration)
67 | }
68 | if got.Path != c.expected.Path {
69 | t.Errorf("Path = %q; want %q", got.Path, c.expected.Path)
70 | }
71 | if got.IsFirst != c.expected.IsFirst {
72 | t.Errorf("IsFirst = %v; want %v", got.IsFirst, c.expected.IsFirst)
73 | }
74 | })
75 | }
76 | }
77 |
78 | func TestGenerateSegments(t *testing.T) {
79 | cases := []struct {
80 | name string
81 | trackDuration float64
82 | segmentDuration int
83 | trackID string
84 | outDir string
85 | expectedSegments []*Segment
86 | }{
87 | {
88 | name: "Basic case",
89 | trackDuration: 10.0,
90 | segmentDuration: 3,
91 | trackID: "track1",
92 | outDir: "/out",
93 | expectedSegments: []*Segment{
94 | {Duration: 3.0, Path: "/out/track10.ts", IsFirst: true},
95 | {Duration: 3.0, Path: "/out/track11.ts", IsFirst: false},
96 | {Duration: 3.0, Path: "/out/track12.ts", IsFirst: false},
97 | {Duration: 1.0, Path: "/out/track13.ts", IsFirst: false},
98 | },
99 | },
100 | {
101 | name: "Zero track duration",
102 | trackDuration: 0,
103 | segmentDuration: 3,
104 | trackID: "track2",
105 | outDir: "/out",
106 | expectedSegments: nil,
107 | },
108 | {
109 | name: "Zero segment duration",
110 | trackDuration: 10.0,
111 | segmentDuration: 0,
112 | trackID: "track3",
113 | outDir: "/out",
114 | expectedSegments: nil,
115 | },
116 | {
117 | name: "Exact division of track duration",
118 | trackDuration: 9.0,
119 | segmentDuration: 3,
120 | trackID: "track4",
121 | outDir: "/out",
122 | expectedSegments: []*Segment{
123 | {Duration: 3.0, Path: "/out/track40.ts", IsFirst: true},
124 | {Duration: 3.0, Path: "/out/track41.ts", IsFirst: false},
125 | {Duration: 3.0, Path: "/out/track42.ts", IsFirst: false},
126 | },
127 | },
128 | {
129 | name: "Large track duration",
130 | trackDuration: 25.0,
131 | segmentDuration: 10,
132 | trackID: "track5",
133 | outDir: "/out",
134 | expectedSegments: []*Segment{
135 | {Duration: 10.0, Path: "/out/track50.ts", IsFirst: true},
136 | {Duration: 10.0, Path: "/out/track51.ts", IsFirst: false},
137 | {Duration: 5.0, Path: "/out/track52.ts", IsFirst: false},
138 | },
139 | },
140 | }
141 |
142 | for _, c := range cases {
143 | t.Run(c.name, func(t *testing.T) {
144 | got := GenerateSegments(c.trackDuration, c.segmentDuration, c.trackID, c.outDir)
145 |
146 | if len(got) != len(c.expectedSegments) {
147 | t.Fatalf("Expected %d segments, got %d", len(c.expectedSegments), len(got))
148 | }
149 |
150 | for i, gotSegment := range got {
151 | if gotSegment.Duration != c.expectedSegments[i].Duration {
152 | t.Errorf("Segment %d: expected duration %f, got %f", i, c.expectedSegments[i].Duration, gotSegment.Duration)
153 | }
154 | if gotSegment.Path != c.expectedSegments[i].Path {
155 | t.Errorf("Segment %d: expected path %q, got %q", i, c.expectedSegments[i].Path, gotSegment.Path)
156 | }
157 | if gotSegment.IsFirst != c.expectedSegments[i].IsFirst {
158 | t.Errorf("Segment %d: expected IsFirst %v, got %v", i, c.expectedSegments[i].IsFirst, gotSegment.IsFirst)
159 | }
160 | }
161 | })
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/internal/http/consts.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | const (
4 | eventPlay = "play"
5 | eventPause = "pause"
6 | eventNewTrack = "new_track"
7 | eventLoadedTracks = "loaded_tracks"
8 | eventCountListeners = "count_listeners"
9 | )
10 |
--------------------------------------------------------------------------------
/internal/http/messages.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 | )
7 |
8 | type Message struct {
9 | Message string `json:"message"`
10 | }
11 |
12 | func jsonResponse(w http.ResponseWriter, data interface{}) {
13 | w.Header().Set("Content-Type", "application/json")
14 |
15 | if err := json.NewEncoder(w).Encode(data); err != nil {
16 | http.Error(w, "JSON encoding failed", http.StatusInternalServerError)
17 | }
18 | }
19 |
20 | func jsonMessage(w http.ResponseWriter, code int, body string) {
21 | msg := Message{Message: body}
22 |
23 | w.WriteHeader(code)
24 | jsonResponse(w, msg)
25 | }
26 |
27 | func jsonOK(w http.ResponseWriter, body string) {
28 | jsonMessage(w, http.StatusOK, body)
29 | }
30 |
31 | func jsonBadRequest(w http.ResponseWriter, body string) {
32 | jsonMessage(w, http.StatusBadRequest, body)
33 | }
34 |
35 | func jsonUnauthorized(w http.ResponseWriter, body string) {
36 | jsonMessage(w, http.StatusUnauthorized, body)
37 | }
38 |
39 | func jsonForbidden(w http.ResponseWriter, body string) {
40 | jsonMessage(w, http.StatusForbidden, body)
41 | }
42 |
43 | func jsonInternalError(w http.ResponseWriter, body string) {
44 | jsonMessage(w, http.StatusInternalServerError, body)
45 | }
46 |
--------------------------------------------------------------------------------
/internal/http/middlewares.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 |
7 | "github.com/golang-jwt/jwt/v5"
8 | )
9 |
10 | func (s *Server) jwtAuth(next http.Handler) http.Handler {
11 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
12 | cookie, err := r.Cookie("jwt")
13 | if err != nil {
14 | jsonUnauthorized(w, "Unauthorized, access denied.")
15 | return
16 | }
17 |
18 | token, err := jwt.Parse(cookie.Value, func(token *jwt.Token) (any, error) {
19 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
20 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
21 |
22 | }
23 | return []byte(s.config.JWTSign), nil
24 | })
25 |
26 | if err != nil || !token.Valid {
27 | jsonUnauthorized(w, "Invalid token.")
28 | return
29 | }
30 |
31 | next.ServeHTTP(w, r)
32 | })
33 | }
34 |
--------------------------------------------------------------------------------
/internal/http/parser.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 | "net/http"
8 | "net/url"
9 | "strconv"
10 | )
11 |
12 | func parseIntQuery(queries url.Values, key string, defaultValue int) int {
13 | queryValue := queries.Get(key)
14 | parsed, err := strconv.Atoi(queryValue)
15 | if err != nil {
16 | parsed = defaultValue
17 | }
18 |
19 | return parsed
20 | }
21 |
22 | func parseJSONBody[T any](r *http.Request) (*T, error) {
23 | rawBytes, err := io.ReadAll(r.Body)
24 | if err != nil {
25 | return nil, err
26 | }
27 | defer r.Body.Close()
28 |
29 | if len(rawBytes) == 0 {
30 | return nil, fmt.Errorf("request body is empty")
31 | }
32 |
33 | var jsonData T
34 | if err := json.Unmarshal(rawBytes, &jsonData); err != nil {
35 | return nil, err
36 | }
37 |
38 | return &jsonData, nil
39 | }
40 |
--------------------------------------------------------------------------------
/internal/http/server.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "log/slog"
5 | "mime"
6 | "net/http"
7 | "strconv"
8 | "time"
9 |
10 | "github.com/cheatsnake/airstation/internal/config"
11 | "github.com/cheatsnake/airstation/internal/events"
12 | "github.com/cheatsnake/airstation/internal/ffmpeg"
13 | "github.com/cheatsnake/airstation/internal/hls"
14 | "github.com/cheatsnake/airstation/internal/playback"
15 | "github.com/cheatsnake/airstation/internal/playlist"
16 | "github.com/cheatsnake/airstation/internal/queue"
17 | "github.com/cheatsnake/airstation/internal/storage"
18 | "github.com/cheatsnake/airstation/internal/track"
19 | "github.com/rs/cors"
20 | )
21 |
22 | type Server struct {
23 | playbackState *playback.State
24 | eventsEmitter *events.Emitter
25 | trackService *track.Service
26 | queueService *queue.Service
27 | playbackService *playback.Service
28 | playlistService *playlist.Service
29 | config *config.Config
30 | logger *slog.Logger
31 | router *http.ServeMux
32 | }
33 |
34 | func NewServer(store storage.Storage, conf *config.Config, logger *slog.Logger) *Server {
35 | ffmpegCLI := ffmpeg.NewCLI()
36 | ts := track.NewService(store, ffmpegCLI, logger.WithGroup("trackservice"))
37 | qs := queue.NewService(store)
38 | ps := playback.NewService(store)
39 | pls := playlist.NewService(store)
40 | state := playback.NewState(ts, qs, ps, conf.TmpDir, logger.WithGroup("playback"))
41 |
42 | return &Server{
43 | playbackState: state,
44 | eventsEmitter: events.NewEmitter(),
45 | trackService: ts,
46 | queueService: qs,
47 | playbackService: ps,
48 | playlistService: pls,
49 | config: conf,
50 | logger: logger.WithGroup("http"),
51 | router: http.NewServeMux(),
52 | }
53 | }
54 |
55 | func (s *Server) Run() {
56 | s.registerMP2TMimeType()
57 |
58 | // Public handlers
59 | s.router.HandleFunc("GET /stream", s.handleHLSPlaylist)
60 | s.router.HandleFunc("GET /api/v1/events", s.handleEvents)
61 | s.router.HandleFunc("POST /api/v1/login", s.handleLogin)
62 | s.router.Handle("GET /static/tmp/", s.handleStaticDirWithoutCache("/static/tmp", s.config.TmpDir))
63 | s.router.Handle("GET /api/v1/playback", http.HandlerFunc(s.handlePlaybackState))
64 | s.router.Handle("GET /api/v1/playback/history", http.HandlerFunc(s.handlePlaybackHistory))
65 |
66 | // Protected handlers
67 | s.router.Handle("POST /api/v1/tracks", s.jwtAuth(http.HandlerFunc(s.handleTracksUpload)))
68 | s.router.Handle("GET /api/v1/tracks", s.jwtAuth(http.HandlerFunc(s.handleTracks)))
69 | s.router.Handle("DELETE /api/v1/tracks", s.jwtAuth(http.HandlerFunc(s.handleDeleteTracks)))
70 | s.router.Handle("GET /api/v1/queue", s.jwtAuth(http.HandlerFunc(s.handleQueue)))
71 | s.router.Handle("POST /api/v1/queue", s.jwtAuth(http.HandlerFunc(s.handleAddToQueue)))
72 | s.router.Handle("PUT /api/v1/queue", s.jwtAuth(http.HandlerFunc(s.handleReorderQueue)))
73 | s.router.Handle("DELETE /api/v1/queue", s.jwtAuth(http.HandlerFunc(s.handleRemoveFromQueue)))
74 | s.router.Handle("POST /api/v1/playback/pause", s.jwtAuth(http.HandlerFunc(s.handlePausePlayback)))
75 | s.router.Handle("POST /api/v1/playback/play", s.jwtAuth(http.HandlerFunc(s.handlePlayPlayback)))
76 | s.router.Handle("POST /api/v1/playlist", s.jwtAuth(http.HandlerFunc(s.handleAddPlaylist)))
77 | s.router.Handle("GET /api/v1/playlists", s.jwtAuth(http.HandlerFunc(s.handlePlaylists)))
78 | s.router.Handle("GET /api/v1/playlist/{id}/", s.jwtAuth(http.HandlerFunc(s.handlePlaylist)))
79 | s.router.Handle("PUT /api/v1/playlist/{id}/", s.jwtAuth(http.HandlerFunc(s.handleEditPlaylist)))
80 | s.router.Handle("DELETE /api/v1/playlist/{id}/", s.jwtAuth(http.HandlerFunc(s.handleDeletePlaylist)))
81 | s.router.Handle("GET /static/tracks/", s.jwtAuth(s.handleStaticDir("/static/tracks", s.config.TracksDir)))
82 |
83 | s.router.Handle("GET /studio/", s.handleStaticDir("/studio/", s.config.StudioDir))
84 | s.router.Handle("GET /", s.handleStaticDir("/", s.config.PlayerDir))
85 |
86 | s.listenEvents()
87 |
88 | err := s.playbackState.Play()
89 | if err != nil {
90 | s.logger.Warn("Auto start playing failed: " + err.Error())
91 | }
92 |
93 | go s.playbackState.Run()
94 | go s.trackService.LoadTracksFromDisk(s.config.TracksDir)
95 | s.playbackService.DeleteOldPlaybackHistory()
96 |
97 | s.logger.Info("Server starts on http://localhost:" + s.config.HTTPPort)
98 | err = http.ListenAndServe(":"+s.config.HTTPPort, cors.Default().Handler(s.router))
99 | if err != nil {
100 | s.logger.Error("Listen and serve failed", slog.String("info", err.Error()))
101 | }
102 | }
103 |
104 | func (s *Server) registerMP2TMimeType() {
105 | err := mime.AddExtensionType(hls.SegmentExtension, "video/mp2t")
106 | if err != nil {
107 | s.logger.Error("MP2T mime type registration failed", slog.String("info", err.Error()))
108 | }
109 | }
110 |
111 | func (s *Server) listenEvents() {
112 | countConnectionTicker := time.Tick(5 * time.Second)
113 |
114 | // TODO: add context for gracefull shutdown
115 |
116 | go func() {
117 | for range countConnectionTicker {
118 | count := s.eventsEmitter.CountSubscribers()
119 | s.eventsEmitter.RegisterEvent(eventCountListeners, strconv.Itoa(count))
120 | }
121 | }()
122 |
123 | go func() {
124 | for {
125 | select {
126 | case <-s.playbackState.PlayNotify:
127 | s.eventsEmitter.RegisterEvent(eventPlay, s.playbackState.CurrentTrack.Name)
128 | case <-s.playbackState.PauseNotify:
129 | s.eventsEmitter.RegisterEvent(eventPause, " ")
130 | case trackName := <-s.playbackState.NewTrackNotify:
131 | s.eventsEmitter.RegisterEvent(eventNewTrack, trackName)
132 | case loadedTracks := <-s.trackService.LoadedTracksNotify:
133 | s.eventsEmitter.RegisterEvent(eventLoadedTracks, strconv.Itoa(loadedTracks))
134 | }
135 | }
136 | }()
137 | }
138 |
--------------------------------------------------------------------------------
/internal/logger/logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "context"
5 | "io"
6 | "log/slog"
7 | "os"
8 | "time"
9 | )
10 |
11 | type customHandler struct {
12 | output io.Writer
13 | opts *slog.HandlerOptions
14 | group string
15 | }
16 |
17 | func (h *customHandler) Enabled(ctx context.Context, level slog.Level) bool {
18 | if h.opts != nil && h.opts.Level != nil {
19 | return level >= h.opts.Level.Level()
20 | }
21 | return level >= slog.LevelInfo
22 | }
23 |
24 | func (h *customHandler) Handle(ctx context.Context, r slog.Record) error {
25 | logMeta := r.Time.Format(time.DateTime) + " " + r.Level.String() + " "
26 | if h.group != "" {
27 | logMeta += h.group + ": "
28 | }
29 |
30 | _, err := h.output.Write([]byte(logMeta + r.Message + " "))
31 | if err != nil {
32 | return err
33 | }
34 |
35 | r.Attrs(func(attr slog.Attr) bool {
36 | println(attr.Key)
37 | _, err := h.output.Write([]byte(attr.Value.String() + " "))
38 | return err == nil
39 | })
40 |
41 | _, err = h.output.Write([]byte("\n"))
42 | return err
43 | }
44 |
45 | func (h *customHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
46 | newHandler := &customHandler{output: h.output, opts: h.opts, group: h.group}
47 | r := slog.NewRecord(time.Now(), slog.LevelInfo, "", 0)
48 | for _, attr := range attrs {
49 | r.AddAttrs(attr)
50 | }
51 | return newHandler
52 | }
53 |
54 | func (h *customHandler) WithGroup(name string) slog.Handler {
55 | return &customHandler{output: h.output, opts: h.opts, group: name}
56 | }
57 |
58 | func newCustomHandler(w io.Writer, opts *slog.HandlerOptions) *customHandler {
59 | return &customHandler{output: w, opts: opts}
60 | }
61 |
62 | func New() *slog.Logger {
63 | handler := newCustomHandler(os.Stdout, &slog.HandlerOptions{
64 | Level: slog.LevelDebug,
65 | })
66 |
67 | logger := slog.New(handler)
68 | return logger
69 | }
70 |
--------------------------------------------------------------------------------
/internal/playback/service.go:
--------------------------------------------------------------------------------
1 | package playback
2 |
3 | import (
4 | "log/slog"
5 | "time"
6 | )
7 |
8 | type Service struct {
9 | store Store
10 | log *slog.Logger
11 | }
12 |
13 | func NewService(store Store) *Service {
14 | return &Service{
15 | store: store,
16 | }
17 | }
18 |
19 | // AddPlaybackHistory logs a playback event for a given track.
20 | //
21 | // Parameters:
22 | // - trackName: The name of the track that was played.
23 | func (s *Service) AddPlaybackHistory(trackName string) {
24 | err := s.store.AddPlaybackHistory(time.Now().Unix(), trackName)
25 | if err != nil {
26 | s.log.Error("Failed to add playback history: " + err.Error())
27 | }
28 | }
29 |
30 | // RecentPlaybackHistory retrieves the most recent playback history records.
31 | //
32 | // Parameters:
33 | // - limit: The maximum number of history entries to retrieve.
34 | //
35 | // Returns:
36 | // - A slice of PlaybackHistory pointers, or an error.
37 | func (s *Service) RecentPlaybackHistory(limit int) ([]*History, error) {
38 | history, err := s.store.RecentPlaybackHistory(limit)
39 | return history, err
40 | }
41 |
42 | // DeleteOldPlaybackHistory removes outdated playback history entries from the store.
43 | func (s *Service) DeleteOldPlaybackHistory() {
44 | _, err := s.store.DeleteOldPlaybackHistory()
45 | if err != nil {
46 | s.log.Warn("Failed to delete old playback history: " + err.Error())
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/internal/playback/state.go:
--------------------------------------------------------------------------------
1 | // Package playback manages audio playback state, track transitions, and HLS playlist generation.
2 | // It coordinates the timing and sequencing of audio tracks, maintaining synchronized state
3 | // for streaming playback, including current position, play/pause control, and playlist updates.
4 | // This package interacts with the track service to load tracks, generate segments, and handle
5 | // queue changes in a thread-safe manner.
6 | package playback
7 |
8 | import (
9 | "errors"
10 | "log/slog"
11 | "strings"
12 | "sync"
13 | "time"
14 |
15 | "github.com/cheatsnake/airstation/internal/hls"
16 | "github.com/cheatsnake/airstation/internal/queue"
17 | "github.com/cheatsnake/airstation/internal/track"
18 | )
19 |
20 | // State represents the current playback state of the application, including the currently playing track,
21 | // elapsed playback time, playlist management, and synchronization tools for safe concurrent access.
22 | type State struct {
23 | CurrentTrack *track.Track `json:"currentTrack"` // The currently playing track
24 | CurrentTrackElapsed float64 `json:"currentTrackElapsed"` // Seconds elapsed since the current track started playing
25 | IsPlaying bool `json:"isPlaying"` // Whether a track is currently playing
26 | UpdatedAt int64 `json:"updatedAt"` // Unix timestamp of the last state update
27 |
28 | NewTrackNotify chan string `json:"-"` // Channel to notify when a new track starts playing
29 | PlayNotify chan bool `json:"-"` // Channel to notify when playback starts
30 | PauseNotify chan bool `json:"-"` // Channel to notify when playback is paused
31 |
32 | PlaylistStr string `json:"-"` // Current HLS playlist as a string
33 | playlist *hls.Playlist // Internal representation of the HLS playlist
34 | playlistDir string // Directory where HLS playlist segments are stored
35 |
36 | refreshCount int64 // Number of state refresh cycles completed
37 | refreshInterval float64 // Time interval (in seconds) between state updates
38 |
39 | trackService *track.Service
40 | queueService *queue.Service
41 | playbackService *Service
42 |
43 | log *slog.Logger
44 | mutex sync.Mutex
45 | }
46 |
47 | // NewState creates and initializes a new playback State instance.
48 | func NewState(ts *track.Service, qs *queue.Service, ps *Service, tmpDir string, log *slog.Logger) *State {
49 | return &State{
50 | CurrentTrack: nil,
51 | CurrentTrackElapsed: 0,
52 | IsPlaying: false,
53 | UpdatedAt: time.Now().Unix(),
54 |
55 | NewTrackNotify: make(chan string),
56 | PlayNotify: make(chan bool),
57 | PauseNotify: make(chan bool),
58 |
59 | trackService: ts,
60 | queueService: qs,
61 | playbackService: ps,
62 |
63 | refreshCount: 0,
64 | playlistDir: tmpDir,
65 | refreshInterval: 1,
66 |
67 | log: log,
68 | }
69 | }
70 |
71 | // Run starts the state update loop which refreshes playback progress and switches tracks when needed.
72 | func (s *State) Run() {
73 | ticker := time.NewTicker(time.Duration(s.refreshInterval) * time.Second)
74 | defer ticker.Stop()
75 |
76 | for range ticker.C {
77 | if !s.IsPlaying {
78 | continue
79 | }
80 |
81 | s.mutex.Lock()
82 | s.CurrentTrackElapsed += s.refreshInterval
83 | s.refreshCount++
84 |
85 | if s.CurrentTrackElapsed >= s.CurrentTrack.Duration {
86 | err := s.loadNextTrack()
87 | if err != nil {
88 | s.log.Error(err.Error())
89 | }
90 |
91 | go s.queueService.CleanupHLSPlaylists(s.playlistDir)
92 | go s.playbackService.AddPlaybackHistory(s.CurrentTrack.Name)
93 | }
94 |
95 | s.PlaylistStr = s.playlist.Generate(s.CurrentTrackElapsed)
96 | s.UpdatedAt = time.Now().Unix()
97 | s.mutex.Unlock()
98 | }
99 | }
100 |
101 | // Play starts playback by loading the current and next tracks into the HLS playlist.
102 | func (s *State) Play() error {
103 | current, next, err := s.queueService.CurrentAndNextTrack()
104 | if err != nil {
105 | return err
106 | }
107 |
108 | if current == nil {
109 | return errors.New("playback queue is empty")
110 | }
111 |
112 | err = s.initHLSPlaylist(current, next)
113 | if err != nil {
114 | return err
115 | }
116 |
117 | s.mutex.Lock()
118 | s.CurrentTrack = current
119 | s.PlaylistStr = s.playlist.Generate(s.CurrentTrackElapsed)
120 | s.UpdatedAt = time.Now().Unix()
121 | s.IsPlaying = true
122 | s.mutex.Unlock()
123 |
124 | s.PlayNotify <- true
125 | go s.playbackService.AddPlaybackHistory(current.Name)
126 |
127 | return nil
128 | }
129 |
130 | // Pause stops playback, clears current playback state and playlist.
131 | func (s *State) Pause() {
132 | s.mutex.Lock()
133 | s.CurrentTrack = nil
134 | s.CurrentTrackElapsed = 0
135 | s.playlist = nil
136 | s.PlaylistStr = ""
137 | s.IsPlaying = false
138 | s.UpdatedAt = time.Now().Unix()
139 | s.mutex.Unlock()
140 |
141 | s.PauseNotify <- false
142 | }
143 |
144 | // Reload refreshes the current playlist based on updated queue state, used after queue changes.
145 | func (s *State) Reload() error {
146 | if !s.IsPlaying {
147 | return nil
148 | }
149 |
150 | current, next, err := s.queueService.CurrentAndNextTrack()
151 | if err != nil {
152 | return err
153 | }
154 |
155 | isCurrentTrackChanged := current != nil && s.CurrentTrack.ID != current.ID
156 | if isCurrentTrackChanged { // Restart if current track changed
157 | s.Pause()
158 | err = s.Play()
159 | if err != nil {
160 | return err
161 | }
162 | }
163 |
164 | segment := s.playlist.FirstNextTrackSegment()
165 | isNextTrackChanged := segment != nil && !strings.Contains(segment.Path, next.ID)
166 | if isNextTrackChanged { // Change segments for next track if it changed
167 | nextSeg, err := s.makeHLSSegments(next, s.playlistDir)
168 | if err != nil {
169 | return err
170 | }
171 | s.mutex.Lock()
172 | s.playlist.ChangeNext(nextSeg)
173 | s.mutex.Unlock()
174 | }
175 |
176 | return nil
177 | }
178 |
179 | // initHLSPlaylist prepares HLS segments for the current and next tracks, initializing a new playlist.
180 | func (s *State) initHLSPlaylist(current, next *track.Track) error {
181 | currentSeg, err := s.makeHLSSegments(current, s.playlistDir)
182 | if err != nil {
183 | return err
184 | }
185 |
186 | nextSeg, err := s.makeHLSSegments(next, s.playlistDir)
187 | if err != nil {
188 | return err
189 | }
190 |
191 | s.mutex.Lock()
192 | s.playlist = hls.NewPlaylist(currentSeg, nextSeg)
193 | s.UpdatedAt = time.Now().Unix()
194 | s.mutex.Unlock()
195 |
196 | return nil
197 | }
198 |
199 | // loadNextTrack advances the queue, resets elapsed time, and updates playlist with next segments.
200 | func (s *State) loadNextTrack() error {
201 | s.CurrentTrackElapsed = 0
202 | err := s.queueService.SpinQueue()
203 | if err != nil {
204 | return err
205 | }
206 |
207 | current, next, err := s.queueService.CurrentAndNextTrack()
208 | if err != nil {
209 | return err
210 | }
211 |
212 | s.CurrentTrack = current
213 | nextTrackSegments, err := s.makeHLSSegments(next, s.playlistDir)
214 | if err != nil {
215 | return err
216 | }
217 |
218 | s.NewTrackNotify <- current.Name
219 | s.playlist.Next(nextTrackSegments)
220 | return nil
221 | }
222 |
223 | // makeHLSSegments generates HLS segments for a given track.
224 | func (s *State) makeHLSSegments(track *track.Track, dir string) ([]*hls.Segment, error) {
225 | if track == nil {
226 | return []*hls.Segment{}, nil
227 | }
228 |
229 | err := s.trackService.MakeHLSPlaylist(track.Path, dir, track.ID, hls.DefaultMaxSegmentDuration)
230 | if err != nil {
231 | return nil, err
232 | }
233 |
234 | segments := hls.GenerateSegments(
235 | track.Duration,
236 | hls.DefaultMaxSegmentDuration,
237 | track.ID,
238 | dir,
239 | )
240 |
241 | return segments, nil
242 | }
243 |
--------------------------------------------------------------------------------
/internal/playback/types.go:
--------------------------------------------------------------------------------
1 | package playback
2 |
3 | type History struct {
4 | ID int `json:"id"`
5 | PlayedAt int64 `json:"playedAt"`
6 | TrackName string `json:"trackName"`
7 | }
8 |
9 | type Store interface {
10 | AddPlaybackHistory(playedAt int64, trackName string) error
11 | RecentPlaybackHistory(limit int) ([]*History, error)
12 | DeleteOldPlaybackHistory() (int64, error)
13 | }
14 |
--------------------------------------------------------------------------------
/internal/playlist/consts.go:
--------------------------------------------------------------------------------
1 | package playlist
2 |
3 | const (
4 | minNameLen = 3
5 | maxNameLen = 128
6 | maxDescrLen = 4096
7 | maxTracks = 100
8 | )
9 |
--------------------------------------------------------------------------------
/internal/playlist/service.go:
--------------------------------------------------------------------------------
1 | package playlist
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | )
7 |
8 | type Service struct {
9 | store Store
10 | }
11 |
12 | func NewService(store Store) *Service {
13 | return &Service{
14 | store: store,
15 | }
16 | }
17 |
18 | func (s *Service) AddPlaylist(name, description string, trackIDs []string) (*Playlist, error) {
19 | err := validateName(name)
20 | if err != nil {
21 | return nil, err
22 | }
23 |
24 | err = validateDescr(description)
25 | if err != nil {
26 | return nil, err
27 | }
28 |
29 | err = validateTracks(trackIDs)
30 | if err != nil {
31 | return nil, err
32 | }
33 |
34 | isExists, err := s.store.IsPlaylistExists(name)
35 | if err != nil {
36 | return nil, err
37 | }
38 | if isExists {
39 | return nil, fmt.Errorf("playlist with this name already exists")
40 | }
41 |
42 | pl, err := s.store.AddPlaylist(name, description, trackIDs)
43 | return pl, err
44 | }
45 |
46 | func (s *Service) Playlists() ([]*Playlist, error) {
47 | pls, err := s.store.Playlists()
48 | return pls, err
49 | }
50 |
51 | func (s *Service) Playlist(id string) (*Playlist, error) {
52 | pl, err := s.store.Playlist(id)
53 | return pl, err
54 | }
55 |
56 | func (s *Service) EditPlaylist(id, name, description string, trackIDs []string) error {
57 | err := validateName(name)
58 | if err != nil {
59 | return err
60 | }
61 |
62 | err = validateDescr(description)
63 | if err != nil {
64 | return err
65 | }
66 |
67 | err = validateTracks(trackIDs)
68 | if err != nil {
69 | return err
70 | }
71 |
72 | err = s.store.EditPlaylist(id, name, description, trackIDs)
73 | return err
74 | }
75 |
76 | func (s *Service) DeletePlaylist(id string) error {
77 | err := s.store.DeletePlaylist(id)
78 | return err
79 | }
80 |
81 | func validateName(name string) error {
82 | if len(name) < minNameLen {
83 | return fmt.Errorf("name must be at least %d characters", minNameLen)
84 | }
85 | if len(name) > maxNameLen {
86 | return fmt.Errorf("name must be at most %d characters", maxNameLen)
87 | }
88 | return nil
89 | }
90 |
91 | func validateDescr(descr string) error {
92 | if len(descr) > maxDescrLen {
93 | return fmt.Errorf("description must be at most %d characters", maxDescrLen)
94 | }
95 | return nil
96 | }
97 |
98 | func validateTracks(trackIDs []string) error {
99 | if len(trackIDs) > maxTracks {
100 | return fmt.Errorf("playlist cannot have more than %d tracks", maxTracks)
101 | }
102 |
103 | seen := make(map[string]struct{}, len(trackIDs))
104 | for _, id := range trackIDs {
105 | if id == "" {
106 | return errors.New("track ID cannot be empty")
107 | }
108 | if _, exists := seen[id]; exists {
109 | return fmt.Errorf("duplicate track ID found: %s", id)
110 | }
111 | seen[id] = struct{}{}
112 | }
113 | return nil
114 | }
115 |
--------------------------------------------------------------------------------
/internal/playlist/types.go:
--------------------------------------------------------------------------------
1 | package playlist
2 |
3 | import "github.com/cheatsnake/airstation/internal/track"
4 |
5 | type Playlist struct {
6 | ID string `json:"id"`
7 | Name string `json:"name"`
8 | Description string `json:"description"`
9 | Tracks []*track.Track `json:"tracks"`
10 | TrackCount int `json:"trackCount"`
11 | }
12 |
13 | type Store interface {
14 | AddPlaylist(name, description string, trackIDs []string) (*Playlist, error)
15 | Playlists() ([]*Playlist, error)
16 | Playlist(id string) (*Playlist, error)
17 | IsPlaylistExists(name string) (bool, error)
18 | EditPlaylist(id, name, description string, trackIDs []string) error
19 | DeletePlaylist(id string) error
20 | }
21 |
--------------------------------------------------------------------------------
/internal/queue/service.go:
--------------------------------------------------------------------------------
1 | package queue
2 |
3 | import (
4 | "path"
5 | "strings"
6 | "time"
7 |
8 | "github.com/cheatsnake/airstation/internal/hls"
9 | "github.com/cheatsnake/airstation/internal/tools/fs"
10 | "github.com/cheatsnake/airstation/internal/track"
11 | )
12 |
13 | type Service struct {
14 | store Store
15 | }
16 |
17 | func NewService(store Store) *Service {
18 | return &Service{
19 | store: store,
20 | }
21 | }
22 |
23 | // Queue retrieves the current playback queue.
24 | //
25 | // Returns:
26 | // - A slice of Track pointers or an error.
27 | func (s *Service) Queue() ([]*track.Track, error) {
28 | q, err := s.store.Queue()
29 | return q, err
30 | }
31 |
32 | // AddToQueue adds one or more tracks to the playback queue.
33 | //
34 | // Parameters:
35 | // - tracks: A slice of Track pointers to add.
36 | //
37 | // Returns:
38 | // - An error if the operation fails.
39 | func (s *Service) AddToQueue(tracks []*track.Track) error {
40 | err := s.store.AddToQueue(tracks)
41 | return err
42 | }
43 |
44 | // ReorderQueue updates the order of tracks in the playback queue.
45 | //
46 | // Parameters:
47 | // - ids: A slice of strings contains track IDs.
48 | //
49 | // Returns:
50 | // - An error if reordering fails.
51 | func (s *Service) ReorderQueue(ids []string) error {
52 | err := s.store.ReorderQueue(ids)
53 | return err
54 | }
55 |
56 | // RemoveFromQueue removes specific tracks from the playback queue.
57 | //
58 | // Parameters:
59 | // - ids: A slice of strings contains track IDs.
60 | //
61 | // Returns:
62 | // - An error if removal fails.
63 | func (s *Service) RemoveFromQueue(ids []string) error {
64 | err := s.store.RemoveFromQueue(ids)
65 | return err
66 | }
67 |
68 | // SpinQueue rotates the playback queue, moving the current track to the end.
69 | //
70 | // Returns:
71 | // - An error if the operation fails.
72 | func (s *Service) SpinQueue() error {
73 | err := s.store.SpinQueue()
74 | return err
75 | }
76 |
77 | // CurrentAndNextTrack retrieves the currently playing track and the next track in the queue.
78 | //
79 | // Returns:
80 | // - Pointers to the current and next tracks, and an error if retrieval fails.
81 | func (s *Service) CurrentAndNextTrack() (*track.Track, *track.Track, error) {
82 | current, next, err := s.store.CurrentAndNextTrack()
83 | return current, next, err
84 | }
85 |
86 | // CleanupHLSPlaylists removes old HLS playlist files that are no longer needed.
87 | //
88 | // Parameters:
89 | // - dirPath: Directory containing the HLS playlist files.
90 | //
91 | // Returns:
92 | // - An error if file cleanup fails.
93 | func (s *Service) CleanupHLSPlaylists(dirPath string) error {
94 | // waiting for all the listeners to listen to the last segments of ended track
95 | time.Sleep(hls.DefaultMaxSegmentDuration * 2 * time.Second)
96 | current, next, err := s.store.CurrentAndNextTrack()
97 | if err != nil {
98 | return err
99 | }
100 |
101 | utilized := []string{current.ID, next.ID}
102 | tmpFiles, err := fs.ListFilesFromDir(dirPath, "")
103 | if err != nil {
104 | return err
105 | }
106 |
107 | for _, tmpFile := range tmpFiles {
108 | keep := false
109 | for _, prefix := range utilized {
110 | if strings.HasPrefix(tmpFile, prefix) {
111 | keep = true
112 | break
113 | }
114 | }
115 | if !keep {
116 | fs.DeleteFile(path.Join(dirPath, tmpFile))
117 | }
118 | }
119 |
120 | return nil
121 | }
122 |
--------------------------------------------------------------------------------
/internal/queue/types.go:
--------------------------------------------------------------------------------
1 | package queue
2 |
3 | import "github.com/cheatsnake/airstation/internal/track"
4 |
5 | type Store interface {
6 | Queue() ([]*track.Track, error)
7 | AddToQueue(tracks []*track.Track) error
8 | RemoveFromQueue(trackIDs []string) error
9 | ReorderQueue(trackIDs []string) error
10 | SpinQueue() error
11 | CurrentAndNextTrack() (*track.Track, *track.Track, error)
12 | }
13 |
--------------------------------------------------------------------------------
/internal/storage/sqlite/playback.go:
--------------------------------------------------------------------------------
1 | package sqlite
2 |
3 | import (
4 | "database/sql"
5 | "fmt"
6 | "sync"
7 |
8 | "github.com/cheatsnake/airstation/internal/playback"
9 | )
10 |
11 | type PlaybackStore struct {
12 | db *sql.DB
13 | mutex *sync.Mutex
14 | }
15 |
16 | func NewPlaybackStore(db *sql.DB, mutex *sync.Mutex) PlaybackStore {
17 | return PlaybackStore{
18 | db: db,
19 | mutex: mutex,
20 | }
21 | }
22 |
23 | func (ps *PlaybackStore) AddPlaybackHistory(playedAt int64, trackName string) error {
24 | ps.mutex.Lock()
25 | defer ps.mutex.Unlock()
26 |
27 | query := `INSERT INTO playback_history (played_at, track_name) VALUES (?, ?)`
28 |
29 | _, err := ps.db.Exec(query, playedAt, trackName)
30 | if err != nil {
31 | return fmt.Errorf("failed to insert playback entry: %v", err)
32 | }
33 |
34 | return nil
35 | }
36 |
37 | func (ps *PlaybackStore) RecentPlaybackHistory(limit int) ([]*playback.History, error) {
38 | ps.mutex.Lock()
39 | defer ps.mutex.Unlock()
40 |
41 | query := `
42 | SELECT id, played_at, track_name
43 | FROM playback_history
44 | ORDER BY played_at DESC`
45 |
46 | query += fmt.Sprintf(" LIMIT %d", limit)
47 |
48 | rows, err := ps.db.Query(query)
49 | if err != nil {
50 | return nil, err
51 | }
52 | defer rows.Close()
53 |
54 | var history []*playback.History
55 | for rows.Next() {
56 | var item playback.History
57 | if err := rows.Scan(&item.ID, &item.PlayedAt, &item.TrackName); err != nil {
58 | return nil, err
59 | }
60 | history = append(history, &item)
61 | }
62 | return history, nil
63 | }
64 |
65 | func (ps *PlaybackStore) DeleteOldPlaybackHistory() (int64, error) {
66 | ps.mutex.Lock()
67 | defer ps.mutex.Unlock()
68 |
69 | query := `
70 | DELETE FROM playback_history
71 | WHERE played_at < (strftime('%s', 'now') - 30 * 24 * 60 * 60)`
72 |
73 | result, err := ps.db.Exec(query)
74 | if err != nil {
75 | return 0, fmt.Errorf("failed to delete old entries: %v", err)
76 | }
77 |
78 | rowsAffected, err := result.RowsAffected()
79 | if err != nil {
80 | return 0, fmt.Errorf("failed to get rows affected: %v", err)
81 | }
82 |
83 | return rowsAffected, nil
84 | }
85 |
--------------------------------------------------------------------------------
/internal/storage/sqlite/playlist.go:
--------------------------------------------------------------------------------
1 | package sqlite
2 |
3 | import (
4 | "database/sql"
5 | "fmt"
6 | "sync"
7 |
8 | "github.com/cheatsnake/airstation/internal/playlist"
9 | "github.com/cheatsnake/airstation/internal/tools/ulid"
10 | "github.com/cheatsnake/airstation/internal/track"
11 | )
12 |
13 | type PlaylistStore struct {
14 | db *sql.DB
15 | mutex *sync.Mutex
16 | }
17 |
18 | func NewPlaylistStore(db *sql.DB, mutex *sync.Mutex) PlaylistStore {
19 | return PlaylistStore{
20 | db: db,
21 | mutex: mutex,
22 | }
23 | }
24 |
25 | // AddPlaylist inserts a new playlist and associates tracks
26 | func (ps *PlaylistStore) AddPlaylist(name, description string, trackIDs []string) (*playlist.Playlist, error) {
27 | id := ulid.New()
28 |
29 | tx, err := ps.db.Begin()
30 | if err != nil {
31 | return nil, err
32 | }
33 |
34 | defer tx.Rollback()
35 |
36 | _, err = tx.Exec(`INSERT INTO playlist (id, name, description) VALUES (?, ?, ?)`, id, name, description)
37 | if err != nil {
38 | return nil, err
39 | }
40 |
41 | for _, trackID := range trackIDs {
42 | _, err = tx.Exec(`INSERT OR IGNORE INTO playlist_track (playlist_id, track_id) VALUES (?, ?)`, id, trackID)
43 | if err != nil {
44 | return nil, err
45 | }
46 | }
47 |
48 | if err := tx.Commit(); err != nil {
49 | return nil, err
50 | }
51 |
52 | return ps.Playlist(id)
53 | }
54 |
55 | // Playlists returns all playlists without tracks
56 | func (ps *PlaylistStore) Playlists() ([]*playlist.Playlist, error) {
57 | query := `
58 | SELECT p.id, p.name, p.description, COUNT(pt.track_id) as track_count
59 | FROM playlist p
60 | LEFT JOIN playlist_track pt ON p.id = pt.playlist_id
61 | GROUP BY p.id
62 | `
63 |
64 | rows, err := ps.db.Query(query)
65 | if err != nil {
66 | return nil, err
67 | }
68 | defer rows.Close()
69 |
70 | playlists := make([]*playlist.Playlist, 0)
71 |
72 | for rows.Next() {
73 | var p playlist.Playlist
74 | if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.TrackCount); err != nil {
75 | return nil, err
76 | }
77 |
78 | p.Tracks = []*track.Track{}
79 | playlists = append(playlists, &p)
80 | }
81 |
82 | return playlists, nil
83 | }
84 |
85 | // Playlist returns a playlist with all its tracks
86 | func (ps *PlaylistStore) Playlist(id string) (*playlist.Playlist, error) {
87 | p := playlist.Playlist{Tracks: make([]*track.Track, 0)}
88 |
89 | err := ps.db.QueryRow(`SELECT id, name, description FROM playlist WHERE id = ?`, id).
90 | Scan(&p.ID, &p.Name, &p.Description)
91 | if err != nil {
92 | return nil, err
93 | }
94 |
95 | rows, err := ps.db.Query(`
96 | SELECT t.id, t.name, t.path, t.bitRate, t.duration
97 | FROM playlist_track pt
98 | JOIN tracks t ON pt.track_id = t.id
99 | WHERE pt.playlist_id = ?
100 | ORDER BY pt.position
101 | `, id)
102 | if err != nil {
103 | return nil, fmt.Errorf("failed to query playlist tracks: %w", err)
104 | }
105 |
106 | defer rows.Close()
107 |
108 | for rows.Next() {
109 | var t track.Track
110 | if err := rows.Scan(&t.ID, &t.Name, &t.Path, &t.BitRate, &t.Duration); err != nil {
111 | return nil, fmt.Errorf("failed to scan track: %w", err)
112 | }
113 | p.Tracks = append(p.Tracks, &t)
114 | }
115 |
116 | p.TrackCount = len(p.Tracks)
117 |
118 | return &p, nil
119 | }
120 |
121 | // IsPlaylistExists checks if playlist with provided name exists
122 | func (ps *PlaylistStore) IsPlaylistExists(name string) (bool, error) {
123 | var exists bool
124 |
125 | err := ps.db.QueryRow(`
126 | SELECT EXISTS(
127 | SELECT 1 FROM playlist WHERE name = ?
128 | )
129 | `, name).Scan(&exists)
130 |
131 | if err != nil {
132 | return false, err
133 | }
134 |
135 | return exists, nil
136 | }
137 |
138 | // EditPlaylist updates playlist and its tracks
139 | func (ps *PlaylistStore) EditPlaylist(id, name, description string, trackIDs []string) error {
140 | tx, err := ps.db.Begin()
141 | if err != nil {
142 | return err
143 | }
144 |
145 | defer tx.Rollback()
146 |
147 | _, err = tx.Exec(`UPDATE playlist SET name = ?, description = ? WHERE id = ?`, name, description, id)
148 | if err != nil {
149 | return err
150 | }
151 |
152 | _, err = tx.Exec(`DELETE FROM playlist_track WHERE playlist_id = ?`, id)
153 | if err != nil {
154 | return err
155 | }
156 |
157 | for position, trackID := range trackIDs {
158 | _, err = tx.Exec(
159 | `INSERT OR IGNORE INTO playlist_track (playlist_id, track_id, position) VALUES (?, ?, ?)`,
160 | id, trackID, position,
161 | )
162 | if err != nil {
163 | return err
164 | }
165 | }
166 |
167 | return tx.Commit()
168 | }
169 |
170 | // DeletePlaylist deletes playlist and its track associations
171 | func (ps *PlaylistStore) DeletePlaylist(id string) error {
172 | tx, err := ps.db.Begin()
173 | if err != nil {
174 | return err
175 | }
176 |
177 | defer tx.Rollback()
178 |
179 | _, err = tx.Exec(`DELETE FROM playlist_track WHERE playlist_id = ?`, id)
180 | if err != nil {
181 | return err
182 | }
183 |
184 | _, err = tx.Exec(`DELETE FROM playlist WHERE id = ?`, id)
185 | if err != nil {
186 | return err
187 | }
188 |
189 | return tx.Commit()
190 | }
191 |
--------------------------------------------------------------------------------
/internal/storage/sqlite/queue.go:
--------------------------------------------------------------------------------
1 | package sqlite
2 |
3 | import (
4 | "database/sql"
5 | "errors"
6 | "fmt"
7 | "sync"
8 |
9 | "github.com/cheatsnake/airstation/internal/track"
10 | )
11 |
12 | type QueueStore struct {
13 | db *sql.DB
14 | mutex *sync.Mutex
15 | }
16 |
17 | func NewQueueStore(db *sql.DB, mutex *sync.Mutex) QueueStore {
18 | return QueueStore{
19 | db: db,
20 | mutex: mutex,
21 | }
22 | }
23 |
24 | func (qs *QueueStore) Queue() ([]*track.Track, error) {
25 | qs.mutex.Lock()
26 | defer qs.mutex.Unlock()
27 |
28 | tracks := make([]*track.Track, 0, 10)
29 |
30 | query := `
31 | SELECT t.id, t.name, t.path, t.duration, t.bitRate
32 | FROM tracks t
33 | JOIN queue q ON t.id = q.track_id
34 | ORDER BY q.id ASC`
35 | rows, err := qs.db.Query(query)
36 | if err != nil {
37 | return tracks, fmt.Errorf("failed to query tracks in queue: %w", err)
38 | }
39 | defer rows.Close()
40 |
41 | for rows.Next() {
42 | var track track.Track
43 | err := rows.Scan(&track.ID, &track.Name, &track.Path, &track.Duration, &track.BitRate)
44 | if err != nil {
45 | return tracks, fmt.Errorf("failed to scan track: %w", err)
46 | }
47 | tracks = append(tracks, &track)
48 | }
49 |
50 | if err = rows.Err(); err != nil {
51 | return tracks, fmt.Errorf("error iterating over rows: %w", err)
52 | }
53 |
54 | return tracks, nil
55 | }
56 |
57 | func (qs *QueueStore) AddToQueue(tracks []*track.Track) error {
58 | qs.mutex.Lock()
59 | defer qs.mutex.Unlock()
60 |
61 | query := `
62 | INSERT INTO queue (track_id)
63 | VALUES (?)
64 | ON CONFLICT (track_id) DO NOTHING
65 | `
66 |
67 | for _, track := range tracks {
68 | _, err := qs.db.Exec(query, track.ID)
69 | if err != nil {
70 | return fmt.Errorf("failed to add track to queue: %w", err)
71 | }
72 | }
73 |
74 | return nil
75 | }
76 |
77 | func (qs *QueueStore) RemoveFromQueue(trackIDs []string) error {
78 | qs.mutex.Lock()
79 | defer qs.mutex.Unlock()
80 |
81 | query := `DELETE FROM queue WHERE track_id = ?`
82 | for _, id := range trackIDs {
83 | _, err := qs.db.Exec(query, id)
84 | if err != nil {
85 | return fmt.Errorf("failed to remove track from queue: %w", err)
86 | }
87 | }
88 |
89 | return nil
90 | }
91 |
92 | func (qs *QueueStore) ReorderQueue(trackIDs []string) error {
93 | qs.mutex.Lock()
94 | defer qs.mutex.Unlock()
95 |
96 | _, err := qs.db.Exec(`DELETE FROM queue`)
97 | if err != nil {
98 | return fmt.Errorf("failed to clear queue: %w", err)
99 | }
100 |
101 | query := `INSERT INTO queue (track_id) VALUES (?)`
102 | for _, id := range trackIDs {
103 | _, err := qs.db.Exec(query, id)
104 | if err != nil {
105 | return fmt.Errorf("failed to reorder queue: %w", err)
106 | }
107 | }
108 |
109 | return nil
110 | }
111 |
112 | func (qs *QueueStore) CurrentAndNextTrack() (*track.Track, *track.Track, error) {
113 | qs.mutex.Lock()
114 | defer qs.mutex.Unlock()
115 |
116 | query := `
117 | SELECT t.id, t.name, t.path, t.duration, t.bitRate
118 | FROM tracks t
119 | JOIN queue q ON t.id = q.track_id
120 | ORDER BY q.id ASC
121 | LIMIT 2`
122 | rows, err := qs.db.Query(query)
123 | if err != nil {
124 | return nil, nil, fmt.Errorf("failed to query first and second tracks: %w", err)
125 | }
126 | defer rows.Close()
127 |
128 | var firstTrack, secondTrack track.Track
129 | count := 0
130 |
131 | for rows.Next() {
132 | if count == 0 {
133 | err := rows.Scan(&firstTrack.ID, &firstTrack.Name, &firstTrack.Path, &firstTrack.Duration, &firstTrack.BitRate)
134 | if err != nil {
135 | return nil, nil, fmt.Errorf("failed to scan first track: %w", err)
136 | }
137 | } else if count == 1 {
138 | err := rows.Scan(&secondTrack.ID, &secondTrack.Name, &secondTrack.Path, &secondTrack.Duration, &secondTrack.BitRate)
139 | if err != nil {
140 | return nil, nil, fmt.Errorf("failed to scan second track: %w", err)
141 | }
142 | }
143 | count++
144 | }
145 |
146 | if err = rows.Err(); err != nil {
147 | return nil, nil, fmt.Errorf("error iterating over rows: %w", err)
148 | }
149 |
150 | if count == 0 {
151 | return nil, nil, nil
152 | } else if count == 1 {
153 | return &firstTrack, &firstTrack, nil
154 | }
155 |
156 | return &firstTrack, &secondTrack, nil
157 | }
158 |
159 | func (qs *QueueStore) SpinQueue() error {
160 | qs.mutex.Lock()
161 | defer qs.mutex.Unlock()
162 |
163 | tx, err := qs.db.Begin()
164 | if err != nil {
165 | return fmt.Errorf("failed to begin transaction: %w", err)
166 | }
167 | defer tx.Rollback()
168 |
169 | var firstTrackID string
170 | var firstTrackQueueID int
171 |
172 | query := `SELECT id, track_id FROM queue ORDER BY id ASC LIMIT 1`
173 | err = tx.QueryRow(query).Scan(&firstTrackQueueID, &firstTrackID)
174 | if err != nil {
175 | if errors.Is(err, sql.ErrNoRows) {
176 | return nil // Queue is empty
177 | }
178 | return fmt.Errorf("failed to get first track: %w", err)
179 | }
180 |
181 | var maxID int
182 |
183 | err = tx.QueryRow(`SELECT MAX(id) FROM queue`).Scan(&maxID)
184 | if err != nil {
185 | return fmt.Errorf("failed to get max ID: %w", err)
186 | }
187 |
188 | query = `UPDATE queue SET id = ? WHERE id = ?`
189 | _, err = tx.Exec(query, maxID+1, firstTrackQueueID)
190 | if err != nil {
191 | return fmt.Errorf("failed to update first track ID: %w", err)
192 | }
193 |
194 | err = tx.Commit()
195 | if err != nil {
196 | return fmt.Errorf("failed to commit transaction: %w", err)
197 | }
198 |
199 | return nil
200 | }
201 |
--------------------------------------------------------------------------------
/internal/storage/sqlite/sqlite.go:
--------------------------------------------------------------------------------
1 | package sqlite
2 |
3 | import (
4 | "database/sql"
5 | "fmt"
6 | "log/slog"
7 | "sync"
8 |
9 | _ "modernc.org/sqlite"
10 | )
11 |
12 | type Instance struct {
13 | TrackStore
14 | QueueStore
15 | PlaybackStore
16 | PlaylistStore
17 |
18 | db *sql.DB
19 | log *slog.Logger
20 | mutex sync.Mutex
21 | }
22 |
23 | func New(dbPath string, log *slog.Logger) (*Instance, error) {
24 | db, err := sql.Open("sqlite", dbPath)
25 | if err != nil {
26 | return nil, fmt.Errorf("failed to open database: %w", err)
27 | }
28 |
29 | db.SetMaxOpenConns(1)
30 | log.Info("Sqlite database connected.")
31 |
32 | err = createTables(db)
33 | if err != nil {
34 | return nil, err
35 | }
36 |
37 | err = createIndexes(db)
38 | if err != nil {
39 | return nil, err
40 | }
41 |
42 | instance := &Instance{
43 | db: db,
44 | log: log,
45 | }
46 |
47 | instance.TrackStore = NewTrackStore(db, &instance.mutex)
48 | instance.QueueStore = NewQueueStore(db, &instance.mutex)
49 | instance.PlaybackStore = NewPlaybackStore(db, &instance.mutex)
50 | instance.PlaylistStore = NewPlaylistStore(db, &instance.mutex)
51 |
52 | return instance, nil
53 | }
54 |
55 | func (ins *Instance) Close() error {
56 | ins.mutex.Lock()
57 | defer ins.mutex.Unlock()
58 | return ins.db.Close()
59 | }
60 |
61 | func createTables(db *sql.DB) error {
62 | tracksTable := `
63 | CREATE TABLE IF NOT EXISTS tracks (
64 | id TEXT PRIMARY KEY,
65 | name TEXT NOT NULL UNIQUE,
66 | path TEXT NOT NULL,
67 | duration REAL NOT NULL,
68 | bitRate INTEGER NOT NULL
69 | );`
70 |
71 | queueTable := `
72 | CREATE TABLE IF NOT EXISTS queue (
73 | id INTEGER PRIMARY KEY AUTOINCREMENT,
74 | track_id TEXT NOT NULL UNIQUE,
75 | FOREIGN KEY (track_id) REFERENCES tracks (id)
76 | );`
77 |
78 | playbackHistoryTable := `
79 | CREATE TABLE IF NOT EXISTS playback_history (
80 | id INTEGER PRIMARY KEY AUTOINCREMENT,
81 | played_at INTEGER NOT NULL,
82 | track_name TEXT NOT NULL
83 | );`
84 |
85 | playlistTable := `
86 | CREATE TABLE IF NOT EXISTS playlist (
87 | id TEXT PRIMARY KEY,
88 | name TEXT NOT NULL UNIQUE,
89 | description TEXT
90 | );`
91 |
92 | playlistTrackTable := `
93 | CREATE TABLE IF NOT EXISTS playlist_track (
94 | playlist_id TEXT NOT NULL,
95 | track_id TEXT NOT NULL,
96 | position INTEGER NOT NULL,
97 | FOREIGN KEY (playlist_id) REFERENCES playlist (id) ON DELETE CASCADE,
98 | FOREIGN KEY (track_id) REFERENCES tracks (id),
99 | PRIMARY KEY (playlist_id, position),
100 | UNIQUE (playlist_id, track_id)
101 | );`
102 |
103 | _, err := db.Exec(tracksTable)
104 | if err != nil {
105 | return fmt.Errorf("failed to create tracks table: %w", err)
106 | }
107 |
108 | _, err = db.Exec(queueTable)
109 | if err != nil {
110 | return fmt.Errorf("failed to create queue table: %w", err)
111 | }
112 |
113 | _, err = db.Exec(playbackHistoryTable)
114 | if err != nil {
115 | return fmt.Errorf("failed to create table for playback history: %w", err)
116 | }
117 |
118 | _, err = db.Exec(playlistTable)
119 | if err != nil {
120 | return fmt.Errorf("failed to create playlist table: %w", err)
121 | }
122 |
123 | _, err = db.Exec(playlistTrackTable)
124 | if err != nil {
125 | return fmt.Errorf("failed to create playlist track table: %w", err)
126 | }
127 |
128 | return nil
129 | }
130 |
131 | func createIndexes(db *sql.DB) error {
132 | indexQuery := `CREATE INDEX IF NOT EXISTS idx_tracks_name ON tracks (name COLLATE NOCASE);`
133 | playedAtIndexQuery := `CREATE INDEX IF NOT EXISTS idx_playback_history_played_at ON playback_history(played_at);`
134 | playlistTrackIndexQuery := `CREATE INDEX IF NOT EXISTS idx_playlist_track_ids ON playlist_track (playlist_id, track_id);`
135 |
136 | _, err := db.Exec(indexQuery)
137 | if err != nil {
138 | return fmt.Errorf("failed to create index on tracks.name: %w", err)
139 | }
140 |
141 | _, err = db.Exec(playedAtIndexQuery)
142 | if err != nil {
143 | return fmt.Errorf("failed to create index on playback_history.played_at: %w", err)
144 | }
145 |
146 | _, err = db.Exec(playlistTrackIndexQuery)
147 | if err != nil {
148 | return fmt.Errorf("failed to create index for playlist_track: %w", err)
149 | }
150 |
151 | return nil
152 | }
153 |
--------------------------------------------------------------------------------
/internal/storage/sqlite/track.go:
--------------------------------------------------------------------------------
1 | package sqlite
2 |
3 | import (
4 | "database/sql"
5 | "errors"
6 | "fmt"
7 | "strings"
8 | "sync"
9 |
10 | sqltool "github.com/cheatsnake/airstation/internal/tools/sql"
11 | "github.com/cheatsnake/airstation/internal/tools/ulid"
12 | "github.com/cheatsnake/airstation/internal/track"
13 | )
14 |
15 | type TrackStore struct {
16 | db *sql.DB
17 | mutex *sync.Mutex
18 | }
19 |
20 | func NewTrackStore(db *sql.DB, mutex *sync.Mutex) TrackStore {
21 | return TrackStore{
22 | db: db,
23 | mutex: mutex,
24 | }
25 | }
26 |
27 | func (ts *TrackStore) Tracks(page, limit int, search, sortBy, sortOrder string) ([]*track.Track, int, error) {
28 | ts.mutex.Lock()
29 | defer ts.mutex.Unlock()
30 |
31 | countQuery := "SELECT COUNT(*) FROM tracks"
32 | if search != "" {
33 | countQuery += " WHERE LOWER(name) LIKE LOWER(?)"
34 | }
35 |
36 | var total int
37 | var err error
38 | tracks := make([]*track.Track, 0, limit)
39 |
40 | if search != "" {
41 | err = ts.db.QueryRow(countQuery, "%"+search+"%").Scan(&total)
42 | } else {
43 | err = ts.db.QueryRow(countQuery).Scan(&total)
44 | }
45 | if err != nil {
46 | return tracks, 0, fmt.Errorf("failed to get total track count: %w", err)
47 | }
48 |
49 | query := "SELECT id, name, path, duration, bitRate FROM tracks"
50 | if search != "" {
51 | query += " WHERE name LIKE ?"
52 | }
53 | query += fmt.Sprintf(" ORDER BY %s %s LIMIT ? OFFSET ?", sortBy, sortOrder)
54 |
55 | var rows *sql.Rows
56 | offset := (page - 1) * limit
57 | if search != "" {
58 | rows, err = ts.db.Query(query, "%"+strings.ToLower(search)+"%", limit, offset)
59 | } else {
60 | rows, err = ts.db.Query(query, limit, offset)
61 | }
62 | if err != nil {
63 | return tracks, 0, fmt.Errorf("failed to query tracks: %w", err)
64 | }
65 | defer rows.Close()
66 |
67 | for rows.Next() {
68 | var track track.Track
69 | err := rows.Scan(&track.ID, &track.Name, &track.Path, &track.Duration, &track.BitRate)
70 | if err != nil {
71 | return tracks, 0, fmt.Errorf("failed to scan track: %w", err)
72 | }
73 | tracks = append(tracks, &track)
74 | }
75 |
76 | if err = rows.Err(); err != nil {
77 | return tracks, 0, fmt.Errorf("error iterating over rows: %w", err)
78 | }
79 |
80 | return tracks, total, nil
81 | }
82 |
83 | func (ts *TrackStore) AddTrack(name, path string, duration float64, bitRate int) (*track.Track, error) {
84 | ts.mutex.Lock()
85 | defer ts.mutex.Unlock()
86 |
87 | id := ulid.New()
88 | track := &track.Track{
89 | ID: id,
90 | Name: name,
91 | Path: path,
92 | Duration: duration,
93 | BitRate: bitRate,
94 | }
95 |
96 | query := `INSERT INTO tracks (id, name, path, duration, bitRate) VALUES (?, ?, ?, ?, ?)`
97 | _, err := ts.db.Exec(query, track.ID, track.Name, track.Path, track.Duration, track.BitRate)
98 | if err != nil {
99 | return nil, fmt.Errorf("failed to insert track: %w", err)
100 | }
101 |
102 | return track, nil
103 | }
104 |
105 | func (ts *TrackStore) DeleteTracks(IDs []string) error {
106 | ts.mutex.Lock()
107 | defer ts.mutex.Unlock()
108 |
109 | query := `DELETE FROM tracks WHERE id = ?`
110 | for _, id := range IDs {
111 | _, err := ts.db.Exec(query, id)
112 | if err != nil {
113 | return fmt.Errorf("failed to delete track with ID %s: %w", id, err)
114 | }
115 | }
116 |
117 | return nil
118 | }
119 |
120 | func (ts *TrackStore) EditTrack(track *track.Track) (*track.Track, error) {
121 | ts.mutex.Lock()
122 | defer ts.mutex.Unlock()
123 |
124 | query := `
125 | UPDATE tracks
126 | SET name = ?,
127 | path = ?,
128 | duration = ?,
129 | bitRate = ?
130 | WHERE id = ?`
131 | _, err := ts.db.Exec(query, track.Name, track.Path, track.Duration, track.BitRate, track.ID)
132 | if err != nil {
133 | return nil, fmt.Errorf("failed to update track: %w", err)
134 | }
135 |
136 | return track, nil
137 | }
138 |
139 | func (ts *TrackStore) TrackByID(ID string) (*track.Track, error) {
140 | ts.mutex.Lock()
141 | defer ts.mutex.Unlock()
142 |
143 | query := `SELECT id, name, path, duration, bitRate FROM tracks WHERE id = ?`
144 | row := ts.db.QueryRow(query, ID)
145 |
146 | var track track.Track
147 | err := row.Scan(&track.ID, &track.Name, &track.Path, &track.Duration, &track.BitRate)
148 | if err != nil {
149 | if errors.Is(err, sql.ErrNoRows) {
150 | return nil, fmt.Errorf("track with ID %s not found", ID)
151 | }
152 | return nil, fmt.Errorf("failed to scan track: %w", err)
153 | }
154 |
155 | return &track, nil
156 | }
157 |
158 | func (ts *TrackStore) TracksByIDs(IDs []string) ([]*track.Track, error) {
159 | ts.mutex.Lock()
160 | defer ts.mutex.Unlock()
161 |
162 | tracks := make([]*track.Track, 0, len(IDs))
163 |
164 | whereClause := sqltool.BuildInClause("id", len(IDs))
165 | query := fmt.Sprintf("SELECT id, name, path, duration, bitRate FROM tracks WHERE %s", whereClause)
166 | args := make([]interface{}, len(IDs))
167 | for i, id := range IDs {
168 | args[i] = id
169 | }
170 |
171 | rows, err := ts.db.Query(query, args...)
172 | if err != nil {
173 | return tracks, fmt.Errorf("failed to query tracks: %w", err)
174 | }
175 | defer rows.Close()
176 |
177 | for rows.Next() {
178 | var track track.Track
179 | err := rows.Scan(&track.ID, &track.Name, &track.Path, &track.Duration, &track.BitRate)
180 | if err != nil {
181 | return tracks, fmt.Errorf("failed to scan track: %w", err)
182 | }
183 | tracks = append(tracks, &track)
184 | }
185 |
186 | if err = rows.Err(); err != nil {
187 | return tracks, fmt.Errorf("error iterating over rows: %w", err)
188 | }
189 |
190 | return tracks, nil
191 | }
192 |
--------------------------------------------------------------------------------
/internal/storage/storage.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "github.com/cheatsnake/airstation/internal/playback"
5 | "github.com/cheatsnake/airstation/internal/playlist"
6 | "github.com/cheatsnake/airstation/internal/queue"
7 | "github.com/cheatsnake/airstation/internal/track"
8 | )
9 |
10 | type Storage interface {
11 | track.Store
12 | queue.Store
13 | playback.Store
14 | playlist.Store
15 |
16 | Close() error
17 | }
18 |
--------------------------------------------------------------------------------
/internal/tools/fs/fs.go:
--------------------------------------------------------------------------------
1 | package fs
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "path/filepath"
7 | "strings"
8 | )
9 |
10 | func MustDir(dirPath string) {
11 | err := os.MkdirAll(dirPath, 0755)
12 | if err != nil {
13 | panic(err)
14 | }
15 | }
16 |
17 | func FileExists(filePath string) error {
18 | _, err := os.Stat(filePath)
19 | if os.IsNotExist(err) {
20 | return fmt.Errorf("file does not exist: %v", filePath)
21 | }
22 |
23 | if err != nil {
24 | return fmt.Errorf("retrieving file info failed: %v", err)
25 | }
26 |
27 | return nil
28 | }
29 |
30 | func DeleteFile(filePath string) error {
31 | err := os.Remove(filePath)
32 | if err != nil {
33 | return fmt.Errorf("deleting file failed: %v", err)
34 | }
35 |
36 | return nil
37 | }
38 |
39 | func DeleteDirIfExists(path string) error {
40 | _, err := os.Stat(path)
41 | if os.IsNotExist(err) {
42 | return nil
43 | } else if err != nil {
44 | return fmt.Errorf("error checking directory: %v", err)
45 | }
46 |
47 | err = os.RemoveAll(path)
48 | if err != nil {
49 | return fmt.Errorf("failed to delete directory: %v", err)
50 | }
51 |
52 | return nil
53 | }
54 |
55 | func RenameFile(oldPath, newPath string) error {
56 | err := os.Rename(oldPath, newPath)
57 | if err != nil {
58 | return fmt.Errorf("renaming file failed: %v", err)
59 | }
60 |
61 | return nil
62 | }
63 |
64 | func ListFilesFromDir(dirPath, fileExt string) ([]string, error) {
65 | var filenames []string
66 |
67 | fileInfo, err := os.Stat(dirPath)
68 | if err != nil {
69 | return nil, fmt.Errorf("failed to access directory: %v", err)
70 | }
71 | if !fileInfo.IsDir() {
72 | return nil, fmt.Errorf("path is not a directory: %s", dirPath)
73 | }
74 |
75 | err = filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
76 | if err != nil {
77 | return err
78 | }
79 |
80 | if !info.IsDir() {
81 | ext := strings.ToLower(filepath.Ext(path))
82 | if strings.Contains(ext, fileExt) {
83 | relPath, err := filepath.Rel(dirPath, path)
84 | if err != nil {
85 | return err
86 | }
87 | filenames = append(filenames, relPath)
88 | }
89 | }
90 | return nil
91 | })
92 |
93 | return filenames, err
94 | }
95 |
--------------------------------------------------------------------------------
/internal/tools/network/network.go:
--------------------------------------------------------------------------------
1 | package network
2 |
3 | import (
4 | "net"
5 | "strings"
6 | )
7 |
8 | func IsLocalhost(host string) bool {
9 | hostWithoutPort := strings.Split(host, ":")[0]
10 |
11 | if hostWithoutPort == "localhost" || hostWithoutPort == "127.0.0.1" || hostWithoutPort == "::1" {
12 | return true
13 | }
14 |
15 | ip := net.ParseIP(hostWithoutPort)
16 | if ip != nil {
17 | privateIPBlocks := []net.IPNet{
18 | {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)},
19 | {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)},
20 | {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)},
21 | }
22 |
23 | for _, block := range privateIPBlocks {
24 | if block.Contains(ip) {
25 | return true
26 | }
27 | }
28 | }
29 |
30 | return false
31 | }
32 |
--------------------------------------------------------------------------------
/internal/tools/sql/sql.go:
--------------------------------------------------------------------------------
1 | package sql
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | )
7 |
8 | func BuildInClause(column string, num int) string {
9 | if num == 0 {
10 | return fmt.Sprintf("%s IN ()", column) // Edge case: Empty IN clause (should be avoided in real queries)
11 | }
12 |
13 | placeholders := strings.Repeat("?,", num)
14 | placeholders = placeholders[:len(placeholders)-1] // Remove trailing comma
15 | return fmt.Sprintf("%s IN (%s)", column, placeholders)
16 | }
17 |
--------------------------------------------------------------------------------
/internal/tools/ulid/ulid.go:
--------------------------------------------------------------------------------
1 | package ulid
2 |
3 | import (
4 | "crypto/rand"
5 | "fmt"
6 | "strings"
7 | "sync"
8 | "time"
9 |
10 | "github.com/oklog/ulid/v2"
11 | )
12 |
13 | const Length = ulid.EncodedSize
14 |
15 | var (
16 | once sync.Once
17 | instance *generator
18 | )
19 |
20 | type generator struct {
21 | timestamp uint64
22 | entropy *ulid.MonotonicEntropy
23 | }
24 |
25 | func New() string {
26 | u := initGenerator()
27 |
28 | return strings.ToLower(ulid.MustNew(u.timestamp, u.entropy).String())
29 | }
30 |
31 | func Verify(s string) error {
32 | _, err := ulid.Parse(s)
33 | if err != nil {
34 | return fmt.Errorf("id %s is not allowed, must satisfy the ulid format", s)
35 | }
36 | return nil
37 | }
38 |
39 | func initGenerator() *generator {
40 | once.Do(func() {
41 | instance = &generator{
42 | timestamp: ulid.Timestamp(time.Now()),
43 | entropy: ulid.Monotonic(rand.Reader, 0),
44 | }
45 | })
46 |
47 | return instance
48 | }
49 |
--------------------------------------------------------------------------------
/internal/track/consts.go:
--------------------------------------------------------------------------------
1 | package track
2 |
3 | import "github.com/cheatsnake/airstation/internal/hls"
4 |
5 | const (
6 | minAllowedTrackDuration = hls.DefaultMaxSegmentDuration * hls.DefaultLiveSegmentsAmount
7 | maxAllowedTrackDuration = 6000 // 100 min (just an adequate barrier)
8 | defaultAudioBitRate = 192 // best balance between quallity and size
9 | )
10 |
11 | const (
12 | m4aExtension = "m4a"
13 | mp3Extension = "mp3"
14 | aacExtension = "aac"
15 | wavExtension = "wav"
16 | )
17 |
--------------------------------------------------------------------------------
/internal/track/types.go:
--------------------------------------------------------------------------------
1 | package track
2 |
3 | // Track represents an audio track with its associated metadata.
4 | type Track struct {
5 | ID string `json:"id"` // A unique identifier for the track, typically generated using ULID.
6 | Name string `json:"name"` // The name of the audio track.
7 | Path string `json:"path"` // The file path of the audio track.
8 | Duration float64 `json:"duration"` // The duration of the audio track in seconds.
9 | BitRate int `json:"bitRate"` // The bit rate of the audio track in kilobits per second (kbps).
10 | }
11 |
12 | type Store interface {
13 | Tracks(page, limit int, search, sortBy, sortOrder string) ([]*Track, int, error)
14 | TrackByID(ID string) (*Track, error)
15 | TracksByIDs(IDs []string) ([]*Track, error)
16 | AddTrack(name, path string, duration float64, bitRate int) (*Track, error)
17 | DeleteTracks(IDs []string) error
18 | EditTrack(track *Track) (*Track, error)
19 | }
20 |
21 | // Page represents a paginated response containing a list of audio tracks.
22 | type Page struct {
23 | Tracks []*Track `json:"tracks"` // A slice of Track pointers returned for the current page.
24 | Page int `json:"page"` // The current page number in the pagination result.
25 | Limit int `json:"limit"` // The maximum number of tracks per page.
26 | Total int `json:"total"` // The total number of tracks matching the query.
27 | }
28 |
29 | type BodyWithIDs struct {
30 | IDs []string `json:"ids"`
31 | }
32 |
--------------------------------------------------------------------------------
/web/player/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | dev-dist
14 | *.local
15 |
16 | # Editor directories and files
17 | .vscode/*
18 | !.vscode/extensions.json
19 | .idea
20 | .DS_Store
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 |
--------------------------------------------------------------------------------
/web/player/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "all",
3 | "tabWidth": 4,
4 | "semi": true,
5 | "singleQuote": false,
6 | "printWidth": 120
7 | }
--------------------------------------------------------------------------------
/web/player/README.md:
--------------------------------------------------------------------------------
1 | # Airstation Player
--------------------------------------------------------------------------------
/web/player/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | %AIRSTATION_PLAYER_TITLE%
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/web/player/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "player",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "hls.js": "^1.6.5",
13 | "solid-js": "^1.9.7"
14 | },
15 | "devDependencies": {
16 | "@types/node": "^22.15.29",
17 | "typescript": "^5.8.3",
18 | "vite": "^6.3.5",
19 | "vite-plugin-pwa": "^1.0.0",
20 | "vite-plugin-solid": "^2.11.6"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/web/player/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/web/player/public/icon128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/player/public/icon128.png
--------------------------------------------------------------------------------
/web/player/public/icon144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/player/public/icon144.png
--------------------------------------------------------------------------------
/web/player/public/icon152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/player/public/icon152.png
--------------------------------------------------------------------------------
/web/player/public/icon192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/player/public/icon192.png
--------------------------------------------------------------------------------
/web/player/public/icon256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/player/public/icon256.png
--------------------------------------------------------------------------------
/web/player/public/icon48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/player/public/icon48.png
--------------------------------------------------------------------------------
/web/player/public/icon512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/player/public/icon512.png
--------------------------------------------------------------------------------
/web/player/public/icon72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/player/public/icon72.png
--------------------------------------------------------------------------------
/web/player/public/icon96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/player/public/icon96.png
--------------------------------------------------------------------------------
/web/player/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Page } from "./page";
2 |
3 | const App = () => {
4 | return ;
5 | };
6 |
7 | export default App;
8 |
--------------------------------------------------------------------------------
/web/player/src/api/index.ts:
--------------------------------------------------------------------------------
1 | import { PlaybackHistory, PlaybackState, ResponseErr } from "./types";
2 | import { queryParams } from "./utils";
3 |
4 | export const API_HOST = "";
5 | export const API_PREFIX = "/api/v1";
6 |
7 | class AirstationAPI {
8 | private host: string;
9 | private prefix: string;
10 | private url: () => string;
11 |
12 | constructor(host: string, prefix: string) {
13 | this.host = host;
14 | this.prefix = prefix;
15 | this.url = () => `${this.host + this.prefix}`;
16 | }
17 |
18 | async getPlayback() {
19 | const url = `${this.url()}/playback`;
20 | return await this.makeRequest(url);
21 | }
22 |
23 | async getPlaybackHistory(limit?: number) {
24 | let url = `${this.url()}/playback/history`;
25 | if (limit) url += `?${queryParams({ limit })}`;
26 | return await this.makeRequest(url);
27 | }
28 |
29 | private async makeRequest(url: string, params: RequestInit = {}): Promise {
30 | const resp = await fetch(url, params);
31 | if (!resp.ok) {
32 | const body: ResponseErr = await resp.json();
33 | throw new Error(body.message);
34 | }
35 |
36 | return resp.json();
37 | }
38 | }
39 |
40 | export const airstationAPI = new AirstationAPI(API_HOST, API_PREFIX);
41 |
--------------------------------------------------------------------------------
/web/player/src/api/types.ts:
--------------------------------------------------------------------------------
1 | export interface Track {
2 | id: string;
3 | name: string;
4 | path: string;
5 | duration: number;
6 | bitRate: number;
7 | }
8 |
9 | export interface PlaybackState {
10 | currentTrack: Track | null;
11 | currentTrackElapsed: number;
12 | isPlaying: boolean;
13 | }
14 |
15 | export interface PlaybackHistory {
16 | id: number;
17 | playedAt: number;
18 | trackName: string;
19 | }
20 |
21 | export interface ResponseErr {
22 | message: string;
23 | }
24 |
25 | export interface ResponseOK {
26 | message: string;
27 | }
28 |
--------------------------------------------------------------------------------
/web/player/src/api/utils.ts:
--------------------------------------------------------------------------------
1 | export const jsonRequestParams = (method: string, body: Record) => {
2 | return {
3 | method,
4 | headers: { "Content-Type": "application/json" },
5 | body: JSON.stringify(body),
6 | };
7 | };
8 |
9 | export const queryParams = (params: Record) => {
10 | removeEmptyFields(params);
11 | return new URLSearchParams(params).toString();
12 | };
13 |
14 | const removeEmptyFields = (obj: Record) => {
15 | for (const key in obj) {
16 | if (obj.hasOwnProperty(key) && [undefined, null, ""].includes(obj[key])) {
17 | delete obj[key];
18 | }
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/web/player/src/index.css:
--------------------------------------------------------------------------------
1 | * {
2 | font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
3 | margin: 0;
4 | padding: 0;
5 | box-sizing: border-box;
6 | }
7 |
8 | ::-webkit-scrollbar {
9 | width: 8px;
10 | }
11 |
12 | ::-webkit-scrollbar-track {
13 | background: #252c32;
14 | }
15 |
16 | ::-webkit-scrollbar-thumb {
17 | background: #313942;
18 | }
19 |
20 | ::-webkit-scrollbar-thumb:hover {
21 | background: #313942;
22 | }
23 |
24 | body {
25 | background: #485563;
26 | background: -webkit-linear-gradient(to top, #29323c, #485563);
27 | background: linear-gradient(to top, #29323c, #485563);
28 | overflow: hidden;
29 | }
30 |
31 | .empty_icon {
32 | width: 24px;
33 | height: 24px;
34 | }
35 |
--------------------------------------------------------------------------------
/web/player/src/index.tsx:
--------------------------------------------------------------------------------
1 | /* @refresh reload */
2 | import { render } from 'solid-js/web'
3 | import './index.css'
4 | import App from './App.tsx'
5 |
6 | const root = document.getElementById('root')
7 |
8 | render(() => , root!)
9 |
--------------------------------------------------------------------------------
/web/player/src/page/CurrentTrack.module.css:
--------------------------------------------------------------------------------
1 | .box {
2 | width: 100%;
3 | }
4 |
5 | .label,
6 | .offline_label {
7 | display: flex;
8 | justify-content: center;
9 | align-items: center;
10 | gap: 0.5rem;
11 | color: #fff;
12 | font-size: 0.9rem;
13 | cursor: pointer;
14 | padding: 1rem 0.5rem;
15 | }
16 |
17 | .offline_label {
18 | user-select: none;
19 | cursor: default;
20 | color: #a8a8a8;
21 | }
22 |
23 | .offline_label_icon {
24 | width: 10px;
25 | height: 10px;
26 | border-radius: 50%;
27 | background: rgb(240, 78, 78);
28 | }
29 |
--------------------------------------------------------------------------------
/web/player/src/page/CurrentTrack.tsx:
--------------------------------------------------------------------------------
1 | import { onMount, Show } from "solid-js";
2 | import { airstationAPI } from "../api";
3 | import styles from "./CurrentTrack.module.css";
4 | import { addEventListener, EVENTS } from "../store/events";
5 | import { setTrackStore, trackStore } from "../store/track";
6 | import { addHistory } from "../store/history";
7 | import { getUnixTime } from "../utils/date";
8 |
9 | export const CurrentTrack = () => {
10 | onMount(async () => {
11 | try {
12 | const cs = await airstationAPI.getPlayback();
13 | if (cs.isPlaying && cs.currentTrack) setTrackStore("trackName", cs.currentTrack.name);
14 | } catch (error) {
15 | console.log(error);
16 | }
17 |
18 | addEventListener(EVENTS.newTrack, (e: MessageEvent) => {
19 | const unixTime = getUnixTime();
20 | setTrackStore("trackName", e.data);
21 | addHistory({ id: unixTime, playedAt: unixTime, trackName: e.data });
22 | });
23 | });
24 |
25 | const copyToClipboard = async () => {
26 | try {
27 | await navigator.clipboard.writeText(trackStore.trackName);
28 | } catch (error) {
29 | console.log(error);
30 | }
31 | };
32 |
33 | return (
34 |
35 |
0} fallback={ }>
36 |
37 | {trackStore.trackName}
38 |
39 |
40 |
41 | );
42 | };
43 |
44 | const OfflineLabel = () => {
45 | return (
46 |
47 |
48 |
Stream offline
49 |
50 | );
51 | };
52 |
--------------------------------------------------------------------------------
/web/player/src/page/History.module.css:
--------------------------------------------------------------------------------
1 | .menu {
2 | position: absolute;
3 | top: 0;
4 | left: -500px;
5 | width: 100%;
6 | max-width: 500px;
7 | height: 100vh;
8 | background-color: #1d2227;
9 | color: white;
10 | padding: 0.5rem;
11 | transition: left 0.3s ease;
12 | overflow-y: auto;
13 | z-index: 10;
14 | }
15 |
16 | .menu_mobile {
17 | background-color: #1d2227;
18 | }
19 |
20 | .menu_desktop {
21 | background-color: #1d222780;
22 | }
23 |
24 | .menu_header {
25 | display: flex;
26 | justify-content: space-between;
27 | align-items: center;
28 | }
29 |
30 | .menu_title {
31 | font-size: 1rem;
32 | user-select: none;
33 | }
34 |
35 | .menu_icon {
36 | background-image: url('data:image/svg+xml;utf8,');
37 | width: 24px;
38 | height: 24px;
39 | cursor: pointer;
40 | }
41 |
42 | .close_icon {
43 | background-image: url('data:image/svg+xml;utf8, ');
44 | width: 18px;
45 | height: 18px;
46 | cursor: pointer;
47 | }
48 |
49 | .open {
50 | left: 0;
51 | }
52 |
53 | .history {
54 | display: flex;
55 | flex-direction: column;
56 | gap: 1rem;
57 | }
58 |
59 | .history_item {
60 | font-size: 0.9rem;
61 | cursor: pointer;
62 | }
63 |
64 | .history_timestamp {
65 | color: #b7b7b7;
66 | }
67 |
68 | .load_more_btn {
69 | background: transparent;
70 | border: none;
71 | cursor: pointer;
72 | color: white;
73 | opacity: 0.7;
74 | }
75 |
76 | .load_more_btn:hover {
77 | opacity: 1;
78 | }
79 |
--------------------------------------------------------------------------------
/web/player/src/page/History.tsx:
--------------------------------------------------------------------------------
1 | import { Accessor, Component, createSignal, onMount } from "solid-js";
2 | import { airstationAPI } from "../api";
3 | import styles from "./History.module.css";
4 | import { formatDateToTimeFirst } from "../utils/date";
5 | import { history, setHistory } from "../store/history";
6 |
7 | export const History = () => {
8 | const [isOpen, setIsOpen] = createSignal(false);
9 | const open = () => setIsOpen(true);
10 | const close = () => setIsOpen(false);
11 |
12 | return (
13 | <>
14 |
20 |
21 | >
22 | );
23 | };
24 |
25 | const DESKTOP_WIDTH = 1100;
26 | const MAX_HISTORY_LIMIT = 500;
27 |
28 | const Menu: Component<{ isOpen: Accessor; close: () => void }> = ({ isOpen, close }) => {
29 | const [hideLoadMore, setHideLoadMore] = createSignal(false);
30 | const loadHistory = async (limit?: number) => {
31 | try {
32 | const h = await airstationAPI.getPlaybackHistory(limit);
33 | setHistory(h);
34 | } catch (error) {
35 | console.log(error);
36 | }
37 | };
38 |
39 | const loadMoreHistory = () => {
40 | loadHistory(MAX_HISTORY_LIMIT);
41 | setHideLoadMore(true);
42 | };
43 |
44 | const copyToClipboard = async (text: string) => {
45 | try {
46 | await navigator.clipboard.writeText(text);
47 | } catch (error) {
48 | console.log(error);
49 | }
50 | };
51 |
52 | onMount(() => {
53 | loadHistory();
54 | });
55 |
56 | return (
57 |
80 | );
81 | };
82 |
--------------------------------------------------------------------------------
/web/player/src/page/ListenersCounter.module.css:
--------------------------------------------------------------------------------
1 | .counter {
2 | color: #a8a8a8;
3 | font-size: 0.9rem;
4 | }
5 |
6 | .box {
7 | display: flex;
8 | gap: 0.3rem;
9 | align-items: center;
10 | justify-content: center;
11 | padding: 0.15rem 0.75rem;
12 | border-radius: 0.25rem;
13 | }
14 |
15 | .icon {
16 | background-image: url('data:image/svg+xml;utf8, ');
17 | width: 16px;
18 | height: 16px;
19 | }
20 |
21 | .number {
22 | user-select: none;
23 | line-height: 0rem;
24 | font-weight: 700;
25 | font-size: 1rem;
26 | }
27 |
--------------------------------------------------------------------------------
/web/player/src/page/ListenersCounter.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal, onMount } from "solid-js";
2 | import { addEventListener, EVENTS } from "../store/events";
3 | import styles from "./ListenersCounter.module.css";
4 |
5 | export const ListenersCounter = () => {
6 | const [count, setCount] = createSignal(0);
7 |
8 | onMount(() => {
9 | addEventListener(EVENTS.countListeners, (e: MessageEvent) => {
10 | setCount(+e.data);
11 | });
12 | });
13 |
14 | return (
15 |
16 |
17 |
18 |
{!count() ? "" : count()}
19 |
20 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/web/player/src/page/Page.module.css:
--------------------------------------------------------------------------------
1 | .page {
2 | display: flex;
3 | flex-direction: column;
4 | height: 100vh;
5 | }
6 |
7 | .header {
8 | display: flex;
9 | align-items: center;
10 | justify-content: space-between;
11 | padding: 0.5rem;
12 | }
13 |
--------------------------------------------------------------------------------
/web/player/src/page/RadioButton.module.css:
--------------------------------------------------------------------------------
1 | video {
2 | display: none;
3 | }
4 |
5 | .container {
6 | flex: 1;
7 | }
8 |
9 | .box {
10 | width: 100%;
11 | height: 100%;
12 | display: flex;
13 | justify-content: center;
14 | align-items: center;
15 | }
16 | .pause_icon {
17 | margin: 0px auto 0;
18 | width: 74px;
19 | height: 74px;
20 | box-sizing: border-box;
21 | border-color: transparent transparent transparent rgb(255, 255, 255);
22 | background-color: white;
23 | border-radius: 0.2rem;
24 | transition: transform 0.05s ease-out, background-color 0.3s, box-shadow 0.3s;
25 | display: flex;
26 | align-items: center;
27 | justify-content: center;
28 | overflow: hidden;
29 | }
30 |
31 | .play_icon {
32 | width: 74px;
33 | height: 74px;
34 | box-sizing: border-box;
35 | border-style: solid;
36 | border-width: 36px 0px 36px 74px;
37 | border-color: transparent transparent transparent rgb(255, 255, 255);
38 | transition: all 200ms ease-in-out;
39 | border-radius: 0.2rem;
40 | }
41 |
42 | .pause_icon,
43 | .play_icon {
44 | cursor: pointer;
45 | user-select: none;
46 | }
47 |
--------------------------------------------------------------------------------
/web/player/src/page/RadioButton.tsx:
--------------------------------------------------------------------------------
1 | import HLS from "hls.js";
2 | import styles from "./RadioButton.module.css";
3 | import { setTrackStore, trackStore } from "../store/track";
4 | import { Component, onCleanup, onMount } from "solid-js";
5 | import { addEventListener, EVENTS } from "../store/events";
6 | import { getUnixTime } from "../utils/date";
7 | import { addHistory } from "../store/history";
8 |
9 | const STREAM_SOURCE = "/stream";
10 |
11 | export const RadioButton = () => {
12 | let videoRef: HTMLAudioElement | undefined;
13 | let hls: HLS | undefined;
14 |
15 | const initStream = () => {
16 | if (!trackStore.isPlay && HLS.isSupported()) {
17 | hls = new HLS();
18 | hls.loadSource(STREAM_SOURCE);
19 | hls.attachMedia(videoRef as unknown as HTMLMediaElement);
20 | }
21 | };
22 |
23 | const handlePlay = () => {
24 | initStream();
25 | if (!trackStore.trackName) return;
26 | setTrackStore("isPlay", true);
27 | };
28 |
29 | const handlePause = () => {
30 | setTrackStore("isPlay", false);
31 | hls?.destroy();
32 | };
33 |
34 | onMount(() => {
35 | addEventListener(EVENTS.pause, (_e: MessageEvent) => {
36 | setTrackStore("trackName", "");
37 | (() => videoRef?.pause())();
38 | });
39 |
40 | addEventListener(EVENTS.play, (e: MessageEvent) => {
41 | const unixTime = getUnixTime();
42 | setTrackStore("trackName", e.data);
43 | addHistory({ id: unixTime, playedAt: unixTime, trackName: e.data });
44 |
45 | if (trackStore.isPlay) (() => videoRef?.pause())();
46 | (() => videoRef?.play())();
47 | });
48 |
49 | document.body.addEventListener("keydown", (event) => {
50 | if (event.key === " ") {
51 | event.preventDefault();
52 | trackStore.isPlay ? videoRef?.pause() : videoRef?.play();
53 | }
54 | });
55 | });
56 |
57 | return (
58 |
59 |
60 |
61 | {trackStore.isPlay ? (
62 |
videoRef?.pause()} media={videoRef} />
63 | ) : (
64 | videoRef?.play()}>
65 | )}
66 |
67 |
68 | );
69 | };
70 |
71 | let audioSource: MediaElementAudioSourceNode | null = null;
72 | let audioContext: AudioContext | null = null;
73 |
74 | const AnimatedPauseButton: Component<{ pause: () => void; media?: HTMLAudioElement }> = (props) => {
75 | let pauseIconRef: HTMLDivElement | undefined;
76 | let analyser: AnalyserNode | null = null;
77 | let dataArray: Uint8Array | null = null;
78 | let animationId: number | null = null;
79 | let gainNode: GainNode | null = null;
80 | let currentHue = 0;
81 |
82 | onMount(async () => {
83 | if (!pauseIconRef || !props.media) return;
84 | await initAudio();
85 | draw();
86 | });
87 |
88 | onCleanup(async () => {
89 | if (animationId !== null) {
90 | cancelAnimationFrame(animationId);
91 | animationId = null;
92 | }
93 |
94 | if (gainNode) {
95 | gainNode.disconnect();
96 | gainNode = null;
97 | }
98 |
99 | if (analyser) {
100 | analyser.disconnect();
101 | analyser = null;
102 | }
103 |
104 | dataArray = null;
105 |
106 | if (pauseIconRef) {
107 | pauseIconRef.style.transform = "scale(1)";
108 | pauseIconRef.style.backgroundColor = "white";
109 | pauseIconRef.style.boxShadow = "none";
110 | }
111 | });
112 |
113 | const initAudio = async () => {
114 | try {
115 | if (!props.media) return;
116 | if (!audioContext) audioContext = new window.AudioContext();
117 |
118 | analyser = audioContext.createAnalyser();
119 | analyser.fftSize = 256;
120 | gainNode = audioContext.createGain();
121 | gainNode.gain.value = 1;
122 |
123 | if (!audioSource) audioSource = audioContext.createMediaElementSource(props.media);
124 | audioSource.connect(gainNode);
125 | gainNode.connect(analyser);
126 | analyser.connect(audioContext.destination);
127 |
128 | const bufferLength = analyser.frequencyBinCount;
129 | dataArray = new Uint8Array(bufferLength);
130 | } catch (err) {
131 | console.error("Error initializing audio:", err);
132 | }
133 | };
134 |
135 | const draw = () => {
136 | if (!pauseIconRef || !analyser || !dataArray) return;
137 |
138 | animationId = requestAnimationFrame(draw);
139 | analyser.getByteFrequencyData(dataArray);
140 |
141 | let bass = 0;
142 | let treble = 0;
143 | const bassEnd = Math.floor(dataArray.length * 0.3);
144 | const trebleStart = Math.floor(dataArray.length * 0.6);
145 |
146 | for (let i = 0; i < dataArray.length; i++) {
147 | if (i < bassEnd) bass += dataArray[i];
148 | else if (i > trebleStart) treble += dataArray[i];
149 | }
150 |
151 | bass /= bassEnd;
152 | treble /= dataArray.length - trebleStart;
153 |
154 | const scale = 1 + bass / 300;
155 | const jump = (bass / 300) * 20;
156 |
157 | pauseIconRef.style.transform = `translateY(${-jump}px) scale(${scale})`;
158 |
159 | const bassImpact = bass / 255;
160 | const trebleImpact = treble / 255;
161 |
162 | currentHue += (Math.random() - 0.5) * bassImpact * 120;
163 | currentHue += trebleImpact * 2;
164 | currentHue = (currentHue + 360) % 360;
165 |
166 | const color = `hsl(${currentHue}, 50%, 60%)`;
167 | pauseIconRef.style.backgroundColor = color;
168 |
169 | const glowIntensity = bass / 2 + 20;
170 | pauseIconRef.style.boxShadow = `0 0 ${glowIntensity}px ${color}`;
171 | };
172 |
173 | return
;
174 | };
175 |
--------------------------------------------------------------------------------
/web/player/src/page/index.tsx:
--------------------------------------------------------------------------------
1 | import { onMount, onCleanup } from "solid-js";
2 | import { CurrentTrack } from "./CurrentTrack";
3 | import { ListenersCounter } from "./ListenersCounter";
4 | import { RadioButton } from "./RadioButton";
5 | import { closeEventSource, initEventSource } from "../store/events";
6 | import { History } from "./History";
7 | import styles from "./Page.module.css";
8 |
9 | export const Page = () => {
10 | onMount(() => {
11 | initEventSource();
12 | });
13 |
14 | onCleanup(() => {
15 | closeEventSource();
16 | });
17 |
18 | return (
19 |
20 |
25 |
26 |
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/web/player/src/store/events.ts:
--------------------------------------------------------------------------------
1 | import { createStore } from "solid-js/store";
2 | import { API_HOST, API_PREFIX } from "../api";
3 |
4 | export const EVENT_SOURCE_URL = API_HOST + API_PREFIX + "/events";
5 | export const EVENTS = {
6 | newTrack: "new_track",
7 | countListeners: "count_listeners",
8 | pause: "pause",
9 | play: "play",
10 | };
11 |
12 | const [eventSourceStore, setEventSourceStore] = createStore<{ eventSource: EventSource | null }>({
13 | eventSource: null,
14 | });
15 |
16 | export const initEventSource = () => {
17 | if (eventSourceStore.eventSource) eventSourceStore.eventSource.close();
18 |
19 | const es = new EventSource(EVENT_SOURCE_URL);
20 | setEventSourceStore("eventSource", es);
21 | };
22 |
23 | export const addEventListener = (event: string, listener: (event: MessageEvent) => void) => {
24 | if (eventSourceStore.eventSource) {
25 | eventSourceStore.eventSource.addEventListener(event, listener);
26 | }
27 | };
28 |
29 | export const closeEventSource = () => {
30 | if (eventSourceStore.eventSource) {
31 | eventSourceStore.eventSource.close();
32 | setEventSourceStore("eventSource", null);
33 | }
34 | };
35 |
--------------------------------------------------------------------------------
/web/player/src/store/history.ts:
--------------------------------------------------------------------------------
1 | import { createSignal } from "solid-js";
2 | import { PlaybackHistory } from "../api/types";
3 |
4 | export const [history, setHistory] = createSignal([]);
5 | export const addHistory = (h: PlaybackHistory) => {
6 | setHistory([h, ...history()]);
7 | };
8 |
--------------------------------------------------------------------------------
/web/player/src/store/track.ts:
--------------------------------------------------------------------------------
1 | import { createStore } from "solid-js/store";
2 |
3 | export const [trackStore, setTrackStore] = createStore({
4 | trackName: "",
5 | isPlay: false,
6 | });
7 |
--------------------------------------------------------------------------------
/web/player/src/utils/date.ts:
--------------------------------------------------------------------------------
1 | export const formatDateToTimeFirst = (date: Date) => {
2 | const timeParts = new Intl.DateTimeFormat(undefined, {
3 | hour: "2-digit",
4 | minute: "2-digit",
5 | second: "2-digit",
6 | hour12: false,
7 | }).formatToParts(date);
8 |
9 | const dateParts = new Intl.DateTimeFormat(undefined, {
10 | day: "2-digit",
11 | month: "2-digit",
12 | year: "numeric",
13 | }).formatToParts(date);
14 |
15 | const time = timeParts.map((p) => p.value).join("");
16 | const dateStr = dateParts.map((p) => p.value).join("");
17 |
18 | return `${time} ${dateStr}`;
19 | };
20 |
21 | export const getUnixTime = (): number => Math.floor(Date.now() / 1000);
22 |
--------------------------------------------------------------------------------
/web/player/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/web/player/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "module": "ESNext",
7 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "preserve",
17 | "jsxImportSource": "solid-js",
18 |
19 | /* Linting */
20 | "strict": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "noFallthroughCasesInSwitch": true,
24 | "noUncheckedSideEffectImports": true
25 | },
26 | "include": ["src"]
27 | }
28 |
--------------------------------------------------------------------------------
/web/player/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/web/player/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/web/player/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, loadEnv } from "vite";
2 | import solid from "vite-plugin-solid";
3 | import { VitePWA } from "vite-plugin-pwa";
4 | import path from "path";
5 |
6 | export default defineConfig(({ mode }) => {
7 | const globalEnv = loadEnv(mode, path.join(process.cwd(), "..", ".."), "");
8 | const localEnv = loadEnv(mode, process.cwd(), "");
9 | const appTitle = globalEnv.AIRSTATION_PLAYER_TITLE || localEnv.AIRSTATION_PLAYER_TITLE || "Radio";
10 | return {
11 | plugins: [
12 | solid(),
13 | VitePWA({
14 | scope: "/",
15 | registerType: "autoUpdate",
16 | workbox: {
17 | cleanupOutdatedCaches: true,
18 | navigateFallback: "/index.html",
19 | navigateFallbackDenylist: [/^\/studio\//],
20 | },
21 | devOptions: {
22 | enabled: true,
23 | },
24 | manifest: {
25 | scope: "/",
26 | start_url: "/",
27 | lang: "en",
28 | name: "Radio",
29 | short_name: "Radio",
30 | icons: [
31 | {
32 | src: "icon48.png",
33 | sizes: "48x48",
34 | type: "image/png",
35 | purpose: "maskable any",
36 | },
37 | {
38 | src: "icon72.png",
39 | sizes: "72x72",
40 | type: "image/png",
41 | purpose: "maskable any",
42 | },
43 | {
44 | src: "icon96.png",
45 | sizes: "96x96",
46 | type: "image/png",
47 | purpose: "maskable any",
48 | },
49 | {
50 | src: "icon128.png",
51 | sizes: "128x128",
52 | type: "image/png",
53 | purpose: "maskable any",
54 | },
55 | {
56 | src: "icon144.png",
57 | sizes: "144x144",
58 | type: "image/png",
59 | purpose: "maskable any",
60 | },
61 | {
62 | src: "icon152.png",
63 | sizes: "152x152",
64 | type: "image/png",
65 | purpose: "maskable any",
66 | },
67 | {
68 | src: "icon192.png",
69 | sizes: "192x192",
70 | type: "image/png",
71 | purpose: "maskable any",
72 | },
73 | {
74 | src: "icon256.png",
75 | sizes: "256x256",
76 | type: "image/png",
77 | purpose: "maskable any",
78 | },
79 | {
80 | src: "icon512.png",
81 | sizes: "512x512",
82 | type: "image/png",
83 | purpose: "maskable any",
84 | },
85 | ],
86 | },
87 | }),
88 | ],
89 | server: {
90 | proxy: {
91 | "/api": { target: "http://localhost:7331", changeOrigin: true },
92 | "/stream": { target: "http://localhost:7331", changeOrigin: true },
93 | "/static": { target: "http://localhost:7331", changeOrigin: true },
94 | },
95 | },
96 | envPrefix: "AIRSTATION_PLAYER_",
97 | define: {
98 | "import.meta.env.AIRSTATION_PLAYER_TITLE": JSON.stringify(appTitle),
99 | },
100 | };
101 | });
102 |
--------------------------------------------------------------------------------
/web/studio/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | dev-dist
14 | *.local
15 |
16 | # Editor directories and files
17 | .vscode/*
18 | !.vscode/extensions.json
19 | .idea
20 | .DS_Store
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 |
--------------------------------------------------------------------------------
/web/studio/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "all",
3 | "tabWidth": 4,
4 | "semi": true,
5 | "singleQuote": false,
6 | "printWidth": 120
7 | }
--------------------------------------------------------------------------------
/web/studio/README.md:
--------------------------------------------------------------------------------
1 | # Airstation Studio
2 |
--------------------------------------------------------------------------------
/web/studio/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Airstation Studio
10 |
11 |
12 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/web/studio/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "studio",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "@dnd-kit/core": "^6.3.1",
13 | "@dnd-kit/modifiers": "^9.0.0",
14 | "@dnd-kit/sortable": "^10.0.0",
15 | "@dnd-kit/utilities": "^3.2.2",
16 | "@mantine/core": "^8.0.2",
17 | "@mantine/hooks": "^8.0.2",
18 | "@mantine/modals": "^8.0.2",
19 | "@mantine/notifications": "^8.0.2",
20 | "hls.js": "^1.6.2",
21 | "react": "^19.1.0",
22 | "react-dom": "^19.1.0",
23 | "zustand": "^5.0.5"
24 | },
25 | "devDependencies": {
26 | "@types/react": "^19.1.6",
27 | "@types/react-dom": "^19.1.5",
28 | "@vitejs/plugin-react": "^4.5.0",
29 | "globals": "^16.2.0",
30 | "typescript": "~5.8.3",
31 | "vite": "^6.3.5",
32 | "vite-plugin-pwa": "^1.0.0"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/web/studio/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/web/studio/public/icon128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/studio/public/icon128.png
--------------------------------------------------------------------------------
/web/studio/public/icon144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/studio/public/icon144.png
--------------------------------------------------------------------------------
/web/studio/public/icon152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/studio/public/icon152.png
--------------------------------------------------------------------------------
/web/studio/public/icon192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/studio/public/icon192.png
--------------------------------------------------------------------------------
/web/studio/public/icon256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/studio/public/icon256.png
--------------------------------------------------------------------------------
/web/studio/public/icon48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/studio/public/icon48.png
--------------------------------------------------------------------------------
/web/studio/public/icon512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/studio/public/icon512.png
--------------------------------------------------------------------------------
/web/studio/public/icon72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/studio/public/icon72.png
--------------------------------------------------------------------------------
/web/studio/public/icon96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheatsnake/airstation/ac1f52ade4b47246e947fd178ca0288724905795/web/studio/public/icon96.png
--------------------------------------------------------------------------------
/web/studio/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { createTheme, MantineProvider } from "@mantine/core";
2 | import { Page } from "./page";
3 | import { Notifications } from "@mantine/notifications";
4 | import { ModalsProvider } from "@mantine/modals";
5 | import { AuthGuard } from "./components/AuthGuard";
6 |
7 | const theme = createTheme({
8 | fontFamily: '"Exo 2", serif',
9 | colors: {
10 | main: [
11 | "#dffbff",
12 | "#caf2ff",
13 | "#99e2ff",
14 | "#64d2ff",
15 | "#3cc4fe",
16 | "#23bcfe",
17 | "#09b8ff",
18 | "#00a1e4",
19 | "#008fcd",
20 | "#007cb6",
21 | ],
22 | },
23 | primaryColor: "main",
24 | });
25 |
26 | const App = () => {
27 | return (
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | );
37 | };
38 |
39 | export default App;
40 |
--------------------------------------------------------------------------------
/web/studio/src/api/index.ts:
--------------------------------------------------------------------------------
1 | import { PlaybackState, Playlist, ResponseErr, ResponseOK, Track, TracksPage } from "./types";
2 | import { jsonRequestParams, queryParams } from "./utils";
3 |
4 | export const API_HOST = "";
5 | export const API_PREFIX = "/api/v1";
6 |
7 | class AirstationAPI {
8 | private host: string;
9 | private prefix: string;
10 | private url: () => string;
11 |
12 | constructor(host: string, prefix: string) {
13 | this.host = host;
14 | this.prefix = prefix;
15 | this.url = () => `${this.host + this.prefix}`;
16 | }
17 |
18 | async login(secret: string) {
19 | const url = `${this.url()}/login`;
20 | return await this.makeRequest(url, jsonRequestParams("POST", { secret }));
21 | }
22 |
23 | async getPlayback() {
24 | const url = `${this.url()}/playback`;
25 | return await this.makeRequest(url);
26 | }
27 |
28 | async pausePlayback() {
29 | const url = `${this.url()}/playback/pause`;
30 | return await this.makeRequest(url, jsonRequestParams("POST", {}));
31 | }
32 |
33 | async playPlayback() {
34 | const url = `${this.url()}/playback/play`;
35 | return await this.makeRequest(url, jsonRequestParams("POST", {}));
36 | }
37 |
38 | async getTracks(page: number, limit: number, search: string, sortBy: keyof Track, sortOrder: "asc" | "desc") {
39 | const url = `${this.url()}/tracks?${queryParams({
40 | page,
41 | limit,
42 | search,
43 | sort_by: sortBy,
44 | sort_order: sortOrder,
45 | })}`;
46 | return await this.makeRequest(url);
47 | }
48 |
49 | async uploadTracks(files: File[]) {
50 | const url = `${this.url()}/tracks`;
51 | const formData = new FormData();
52 |
53 | for (let i = 0; i < files.length; i++) {
54 | formData.append("tracks", files[i]);
55 | }
56 |
57 | return await this.makeRequest(url, {
58 | method: "POST",
59 | body: formData,
60 | });
61 | }
62 |
63 | async deleteTracks(ids: string[]) {
64 | const url = `${this.url()}/tracks`;
65 | return await this.makeRequest(url, jsonRequestParams("DELETE", { ids }));
66 | }
67 |
68 | async getQueue() {
69 | const url = `${this.url()}/queue`;
70 | return await this.makeRequest(url);
71 | }
72 |
73 | async addToQueue(trackIDs: string[]) {
74 | const url = `${this.url()}/queue`;
75 | return await this.makeRequest(url, jsonRequestParams("POST", { ids: trackIDs }));
76 | }
77 |
78 | async updateQueue(trackIDs: string[]) {
79 | const url = `${this.url()}/queue`;
80 | return await this.makeRequest(url, jsonRequestParams("PUT", { ids: trackIDs }));
81 | }
82 |
83 | async removeFromQueue(trackIDs: string[]) {
84 | const url = `${this.url()}/queue`;
85 | return await this.makeRequest(url, jsonRequestParams("DELETE", { ids: trackIDs }));
86 | }
87 |
88 | async addPlaylist(name: string, trackIDs: string[], description?: string) {
89 | const url = `${this.url()}/playlist`;
90 | return await this.makeRequest(url, jsonRequestParams("POST", { name, description, trackIDs }));
91 | }
92 |
93 | async getPlaylists() {
94 | const url = `${this.url()}/playlists`;
95 | return await this.makeRequest(url);
96 | }
97 |
98 | async getPlaylist(id: string) {
99 | const url = `${this.url()}/playlist/` + id;
100 | return await this.makeRequest(url);
101 | }
102 |
103 | async editPlaylist(id: string, name: string, trackIDs: string[], description?: string) {
104 | const url = `${this.url()}/playlist/` + id;
105 | return await this.makeRequest(url, jsonRequestParams("PUT", { name, description, trackIDs }));
106 | }
107 |
108 | async deletePlaylist(id: string) {
109 | const url = `${this.url()}/playlist/` + id;
110 | return await this.makeRequest(url, jsonRequestParams("DELETE", {}));
111 | }
112 |
113 | private async makeRequest(url: string, params: RequestInit = {}): Promise {
114 | const resp = await fetch(url, params);
115 | if (!resp.ok) {
116 | const body: ResponseErr = await resp.json();
117 | throw new Error(body.message);
118 | }
119 |
120 | return resp.json();
121 | }
122 | }
123 |
124 | export const airstationAPI = new AirstationAPI(API_HOST, API_PREFIX);
125 |
--------------------------------------------------------------------------------
/web/studio/src/api/types.ts:
--------------------------------------------------------------------------------
1 | export interface Track {
2 | id: string;
3 | name: string;
4 | path: string;
5 | duration: number;
6 | bitRate: number;
7 | }
8 |
9 | export interface TracksPage {
10 | tracks: Track[];
11 | page: number;
12 | limit: number;
13 | total: number;
14 | }
15 |
16 | export interface PlaybackState {
17 | currentTrack: Track | null;
18 | currentTrackElapsed: number;
19 | isPlaying: boolean;
20 | updatedAt: number;
21 | }
22 |
23 | export interface ResponseErr {
24 | message: string;
25 | }
26 |
27 | export interface ResponseOK {
28 | message: string;
29 | }
30 |
31 | export interface Playlist {
32 | id: string;
33 | name: string;
34 | description?: string;
35 | tracks: Track[];
36 | trackCount: number;
37 | }
38 |
--------------------------------------------------------------------------------
/web/studio/src/api/utils.ts:
--------------------------------------------------------------------------------
1 | export const jsonRequestParams = (method: string, body: Record) => {
2 | return {
3 | method,
4 | headers: { "Content-Type": "application/json" },
5 | body: JSON.stringify(body),
6 | };
7 | };
8 |
9 | export const queryParams = (params: Record) => {
10 | removeEmptyFields(params);
11 | return new URLSearchParams(params).toString();
12 | };
13 |
14 | const removeEmptyFields = (obj: Record) => {
15 | for (const key in obj) {
16 | if (obj.hasOwnProperty(key) && [undefined, null, ""].includes(obj[key])) {
17 | delete obj[key];
18 | }
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/web/studio/src/components/AudioPlayer.module.css:
--------------------------------------------------------------------------------
1 | .progress_bar {
2 | background-color: rgba(255, 255, 255, 0.2);
3 | }
4 |
--------------------------------------------------------------------------------
/web/studio/src/components/AudioPlayer.tsx:
--------------------------------------------------------------------------------
1 | import { ActionIcon, Box, Checkbox, Flex, Group, Progress, Text, Tooltip, useMantineColorScheme } from "@mantine/core";
2 | import React, { useEffect, useRef } from "react";
3 | import { IconPlayerPlayFilled } from "../icons";
4 | import { IconPlayerStopFilled } from "../icons";
5 | import { Track } from "../api/types";
6 | import { API_HOST } from "../api";
7 | import { formatTime } from "../utils/time";
8 | import { useThrottledState } from "@mantine/hooks";
9 | import styles from "./AudioPlayer.module.css";
10 |
11 | interface AudioPlayerProps {
12 | track: Track;
13 | isPlaying: boolean;
14 | selected: Set;
15 | setSelected: React.Dispatch>>;
16 | togglePlaying: () => void;
17 | }
18 |
19 | export const AudioPlayer: React.FC = ({ track, isPlaying, selected, setSelected, togglePlaying }) => {
20 | const audioRef = useRef(null);
21 | const [progress, setProgress] = useThrottledState(0, 500);
22 | const [cursorPos, setCursorPos] = useThrottledState(0, 100);
23 | const { colorScheme } = useMantineColorScheme();
24 |
25 | const btnColor = colorScheme === "dark" ? "gray" : "black";
26 |
27 | const handleProgressClick = (e: React.MouseEvent) => {
28 | if (audioRef.current) {
29 | const rect = e.currentTarget.getBoundingClientRect();
30 | const clickPosition = (e.clientX - rect.left) / rect.width;
31 | const newTime = clickPosition * audioRef.current.duration;
32 | audioRef.current.currentTime = newTime;
33 | setProgress(clickPosition * 100);
34 | if (!isPlaying) togglePlaying();
35 | }
36 | };
37 |
38 | const handleTimeUpdate = () => {
39 | if (audioRef.current) {
40 | const currentTime = audioRef.current.currentTime;
41 | const duration = audioRef.current.duration;
42 | setProgress((currentTime / duration) * 100);
43 | }
44 | };
45 |
46 | const handleAudioEnd = () => {
47 | if (audioRef.current) {
48 | audioRef.current.pause();
49 | audioRef.current.currentTime = 0;
50 | togglePlaying();
51 | setProgress(0);
52 | }
53 | };
54 |
55 | useEffect(() => {
56 | if (audioRef.current) {
57 | if (isPlaying) {
58 | audioRef.current.play();
59 | } else {
60 | audioRef.current.pause();
61 | }
62 | }
63 | }, [isPlaying]);
64 |
65 | return (
66 | <>
67 |
75 |
76 | {track.name}
77 |
78 |
79 | {isPlaying ? : }
80 |
81 |
82 |
83 |
84 | {
87 | const rect = e.currentTarget.getBoundingClientRect();
88 | setCursorPos(Math.abs((e.clientX - rect.left) / rect.width));
89 | }}
90 | onClick={handleProgressClick}
91 | value={progress}
92 | />
93 |
94 |
95 |
96 |
97 | {formatTime((progress / 100) * track.duration || 0)}/{formatTime(track.duration || 0)}
98 |
99 |
100 |
101 |
102 |
103 | {
106 | setSelected((prevSelected) => {
107 | const newSelected = new Set(prevSelected);
108 | if (newSelected.has(track.id)) {
109 | newSelected.delete(track.id);
110 | } else {
111 | newSelected.add(track.id);
112 | }
113 | return newSelected;
114 | });
115 | }}
116 | />
117 |
118 |
119 | >
120 | );
121 | };
122 |
--------------------------------------------------------------------------------
/web/studio/src/components/AuthGuard.module.css:
--------------------------------------------------------------------------------
1 | .search_input {
2 | padding: 0rem 0.5rem;
3 | border: 1px solid rgba(255, 255, 255, 0.1);
4 | border-radius: 0.2rem;
5 | }
6 |
--------------------------------------------------------------------------------
/web/studio/src/components/AuthGuard.tsx:
--------------------------------------------------------------------------------
1 | import { FC, JSX, useEffect, useState } from "react";
2 | import { airstationAPI } from "../api";
3 | import { useDisclosure } from "@mantine/hooks";
4 | import { errNotify } from "../notifications";
5 | import { handleErr } from "../utils/error";
6 | import { Box, Button, Flex, Group, LoadingOverlay, Paper, TextInput } from "@mantine/core";
7 | import styles from "./AuthGuard.module.css";
8 |
9 | export const AuthGuard: FC<{ children: JSX.Element }> = (props) => {
10 | const [isAuth, setIsAuth] = useState(false);
11 | const [loader, handLoader] = useDisclosure(false);
12 |
13 | const handleLogin = async (secret: string) => {
14 | try {
15 | handLoader.open();
16 | await airstationAPI.login(secret);
17 | await airstationAPI.getQueue(); // Need to check is cookie setted correctly
18 | setIsAuth(true);
19 | } catch (error) {
20 | errNotify(error);
21 | } finally {
22 | handLoader.close();
23 | }
24 | };
25 |
26 | useEffect(() => {
27 | (async () => {
28 | try {
29 | handLoader.open();
30 | await airstationAPI.getQueue();
31 | setIsAuth(true);
32 | } catch (error) {
33 | const msg = handleErr(error);
34 | if (!msg.includes("Unauthorized")) errNotify(msg);
35 | } finally {
36 | handLoader.close();
37 | }
38 | })();
39 | }, []);
40 |
41 | return (
42 | <>
43 | {isAuth ? (
44 | props.children
45 | ) : (
46 |
47 |
48 | {loader ? null : }
49 |
50 | )}
51 | >
52 | );
53 | };
54 |
55 | const MIN_SECRET_LENGTH = 10;
56 | const LoginForm: FC<{ handleLogin: (s: string) => Promise }> = (props) => {
57 | const [secret, setSecret] = useState("");
58 |
59 | return (
60 |
61 |
62 | {
69 | if (event.key === "Enter" && secret.length >= MIN_SECRET_LENGTH) props.handleLogin(secret);
70 | }}
71 | value={secret}
72 | onChange={(event) => setSecret(event.currentTarget.value)}
73 | placeholder="Enter secret"
74 | />
75 |
76 | props.handleLogin(secret)}
81 | >
82 | Submit
83 |
84 |
85 |
86 |
87 | );
88 | };
89 |
--------------------------------------------------------------------------------
/web/studio/src/components/EmptyLabel.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Text } from "@mantine/core";
2 | import { FC } from "react";
3 |
4 | export const EmptyLabel: FC<{ label: string }> = ({ label }) => {
5 | return (
6 |
7 |
8 | {label}
9 |
10 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/web/studio/src/hooks/useThemeBlackColor.ts:
--------------------------------------------------------------------------------
1 | import { useMantineColorScheme } from "@mantine/core";
2 |
3 | export function useThemeBlackColor() {
4 | const { colorScheme } = useMantineColorScheme();
5 | return colorScheme === "dark" ? "gray" : "black";
6 | }
7 |
--------------------------------------------------------------------------------
/web/studio/src/icons/types.ts:
--------------------------------------------------------------------------------
1 | export interface IconProps extends React.ComponentPropsWithoutRef<"svg"> {
2 | size?: number | string;
3 | }
4 |
--------------------------------------------------------------------------------
/web/studio/src/index.css:
--------------------------------------------------------------------------------
1 | * {
2 | ::-webkit-scrollbar {
3 | width: 7px;
4 | }
5 |
6 | ::-webkit-scrollbar-track {
7 | background: #ffffff15;
8 | border-radius: 1px;
9 | }
10 |
11 | ::-webkit-scrollbar-thumb {
12 | background: #ffffff20;
13 | border-radius: 1px;
14 | }
15 |
16 | ::-webkit-scrollbar-thumb:hover {
17 | background: #dadada;
18 | }
19 | }
20 |
21 | body {
22 | background: #485563;
23 | background: -webkit-linear-gradient(to top, #29323c, #485563);
24 | background: linear-gradient(to top, #29323c, #485563);
25 | }
26 |
27 | .mantine-Checkbox-input {
28 | background-color: rgba(255, 255, 255, 0.2);
29 | border: none;
30 | }
31 |
32 | .mantine-Checkbox-input:disabled {
33 | cursor: not-allowed;
34 | background-color: rgba(255, 255, 255, 0.05);
35 | border: none;
36 | }
37 |
38 | .mantine-Button-root:disabled {
39 | cursor: not-allowed;
40 | background-color: rgba(255, 255, 255, 0.05);
41 | color: rgba(255, 255, 255, 0.4);
42 | }
43 |
44 | .mantine-LoadingOverlay-overlay {
45 | background-color: rgba(0, 0, 0, 0.5);
46 | }
47 |
48 | .mantine-Notifications-notification,
49 | .mantine-Modal-content,
50 | .mantine-Modal-header,
51 | .mantine-Popover-dropdown,
52 | .mantine-Menu-dropdown {
53 | background-color: #29323c;
54 | }
55 |
56 | .mantine-Select-option:hover {
57 | background-color: #485563;
58 | }
59 |
60 | .mantine-Modal-inner {
61 | padding-inline: 1vw;
62 | }
63 |
--------------------------------------------------------------------------------
/web/studio/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from "react-dom/client";
2 | import App from "./App.tsx";
3 |
4 | import "@mantine/core/styles.css";
5 | import "@mantine/notifications/styles.css";
6 | import "./index.css";
7 |
8 | createRoot(document.getElementById("root")!).render( );
9 |
--------------------------------------------------------------------------------
/web/studio/src/notifications/index.ts:
--------------------------------------------------------------------------------
1 | import { notifications } from "@mantine/notifications";
2 | import { handleErr } from "../utils/error";
3 |
4 | export const errNotify = (err: string | unknown) => {
5 | const message = typeof err === "string" ? err : handleErr(err);
6 |
7 | notifications.show({
8 | message,
9 | withBorder: true,
10 | withCloseButton: true,
11 | autoClose: 20_000,
12 | color: "red",
13 | });
14 | };
15 |
16 | export const okNotify = (message: string) => {
17 | notifications.show({
18 | message,
19 | withBorder: true,
20 | withCloseButton: true,
21 | color: "green",
22 | });
23 | };
24 |
25 | export const infoNotify = (message: string) => {
26 | notifications.show({
27 | message,
28 | withBorder: true,
29 | withCloseButton: true,
30 | color: "blue",
31 | });
32 | };
33 |
34 | export const warnNotify = (message: string) => {
35 | notifications.show({
36 | message,
37 | withBorder: true,
38 | withCloseButton: true,
39 | color: "yellow",
40 | });
41 | };
42 |
--------------------------------------------------------------------------------
/web/studio/src/page/DesktopPage.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Flex, SimpleGrid } from "@mantine/core";
2 | import { FC } from "react";
3 | import { Playback } from "./Playback";
4 | import { TrackLibrary } from "./TracksLibrary";
5 | import { TrackQueue } from "./TracksQueue";
6 |
7 | const DesktopPage: FC<{ windowWidth: number }> = ({ windowWidth }) => {
8 | return (
9 | 2500 ? "xl" : "lg"}>
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default DesktopPage;
23 |
--------------------------------------------------------------------------------
/web/studio/src/page/MobileBar.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Flex } from "@mantine/core";
2 | import { FC } from "react";
3 |
4 | interface MobileBarProps {
5 | activeBar: string;
6 | setActiveBar: React.Dispatch>;
7 | }
8 |
9 | export const MOBILE_BARS = ["Playback", "Queue", "Tracks"];
10 |
11 | export const MobileBar: FC = ({ activeBar, setActiveBar }) => {
12 | return (
13 |
14 | {MOBILE_BARS.map((bar) => (
15 | setActiveBar(bar)}
21 | >
22 | {bar}
23 |
24 | ))}
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/web/studio/src/page/MobilePage.tsx:
--------------------------------------------------------------------------------
1 | import { Flex } from "@mantine/core";
2 | import { useState } from "react";
3 | import { MobileBar } from "./MobileBar";
4 | import { Playback } from "./Playback";
5 | import { TrackLibrary } from "./TracksLibrary";
6 | import { TrackQueue } from "./TracksQueue";
7 |
8 | const MobilePage = () => {
9 | const [activeBar, setActiveBar] = useState("Playback");
10 | const isVisible = (bar: string) => (bar === activeBar ? "block" : "none");
11 |
12 | return (
13 |
14 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | };
28 |
29 | export default MobilePage;
30 |
--------------------------------------------------------------------------------
/web/studio/src/page/Playback.tsx:
--------------------------------------------------------------------------------
1 | import { ActionIcon, Box, Flex, MantineSize, Paper, Progress, Space, Text, Tooltip } from "@mantine/core";
2 | import { FC, useEffect, useRef, useState } from "react";
3 | import { airstationAPI, API_HOST } from "../api";
4 | import { usePlaybackStore } from "../store/playback";
5 | import { formatTime } from "../utils/time";
6 | import { useTrackQueueStore } from "../store/track-queue";
7 | import { IconHeadphones, IconPlayerPlayFilled, IconPlayerStopFilled, IconVolumeOff, IconVolumeOn } from "../icons";
8 | import { useDisclosure } from "@mantine/hooks";
9 | import { errNotify } from "../notifications";
10 | import { EVENTS, useEventSourceStore } from "../store/events";
11 | import { useThemeBlackColor } from "../hooks/useThemeBlackColor";
12 | import { PlaybackState } from "../api/types";
13 | import Hls from "hls.js";
14 | import { modals } from "@mantine/modals";
15 | import styles from "./styles.module.css";
16 |
17 | export const Playback: FC<{ isMobile?: boolean }> = (props) => {
18 | const updateIntervalID = useRef(0);
19 | const [loader, handLoader] = useDisclosure(false);
20 | const playback = usePlaybackStore((s) => s.playback);
21 | const setPlayback = usePlaybackStore((s) => s.setPlayback);
22 | const fetchPlayback = usePlaybackStore((s) => s.fetchPlayback);
23 | const syncElapsedTime = usePlaybackStore((s) => s.syncElapsedTime);
24 | const rotateQueue = useTrackQueueStore((s) => s.rotateQueue);
25 | const addEventHandler = useEventSourceStore((s) => s.addEventHandler);
26 |
27 | useEffect(() => {
28 | (async () => {
29 | await fetchPlayback();
30 | })();
31 |
32 | addEventHandler(EVENTS.newTrack, async () => {
33 | rotateQueue();
34 | await fetchPlayback();
35 | });
36 |
37 | if (!updateIntervalID.current) {
38 | updateIntervalID.current = setInterval(() => {
39 | syncElapsedTime();
40 | }, 1000);
41 | }
42 |
43 | return () => {
44 | if (updateIntervalID.current) {
45 | clearInterval(updateIntervalID.current);
46 | updateIntervalID.current = 0;
47 | }
48 | };
49 | }, []);
50 |
51 | const togglePlayback = async () => {
52 | handLoader.open();
53 | try {
54 | const pb = playback.isPlaying ? await airstationAPI.pausePlayback() : await airstationAPI.playPlayback();
55 | setPlayback(pb);
56 | } catch (error) {
57 | errNotify(error);
58 | } finally {
59 | handLoader.close();
60 | }
61 | };
62 |
63 | const handlePlaybackAction = () => {
64 | if (!playback.isPlaying) {
65 | togglePlayback();
66 | return;
67 | }
68 |
69 | modals.openConfirmModal({
70 | title: "Confirm stop playback",
71 | cancelProps: { variant: "light", color: "gray" },
72 | centered: true,
73 | children: (
74 |
75 | Do you really want to stop playing tracks on the station? This action will affect all listeners.
76 |
77 | ),
78 | labels: { confirm: "Confirm", cancel: "Cancel" },
79 | onConfirm: () => togglePlayback(),
80 | });
81 | };
82 |
83 | return (
84 |
85 |
94 | {props.isMobile ? : null}
95 |
96 |
97 |
105 | {playback?.isPlaying ? (
106 |
107 | ) : (
108 |
109 | )}
110 |
111 |
112 |
113 |
114 |
115 |
116 | {playback.isPlaying ? (
117 | {playback?.currentTrack?.name}
118 | ) : (
119 | Stream is stopped
120 | )}
121 |
122 |
123 |
124 |
125 |
130 |
131 | {formatTime(playback?.currentTrackElapsed || 0)}/
132 | {formatTime(playback?.currentTrack?.duration || 0)}
133 |
134 |
135 |
136 |
137 |
138 | );
139 | };
140 |
141 | const ListenerCounter = () => {
142 | const [count, setCount] = useState(0);
143 | const addEventHandler = useEventSourceStore((s) => s.addEventHandler);
144 |
145 | const handleCounter = (msg: MessageEvent) => {
146 | setCount(Number(msg.data));
147 | };
148 |
149 | useEffect(() => {
150 | addEventHandler(EVENTS.countListeners, handleCounter);
151 | }, []);
152 |
153 | return (
154 |
155 |
156 |
157 | {!count ? "" : count}
158 |
159 |
160 | );
161 | };
162 |
163 | const StreamToggler: FC<{ playback: PlaybackState; size: MantineSize }> = (props) => {
164 | const videoRef = useRef(null);
165 | const streamRef = useRef(null);
166 | const [isPlaying, setIsPlaying] = useState(false);
167 |
168 | const initStream = () => {
169 | if (isPlaying) return;
170 |
171 | streamRef.current = new Hls();
172 | streamRef.current.loadSource(API_HOST + "/stream");
173 | streamRef.current.attachMedia(videoRef.current as unknown as HTMLMediaElement);
174 | };
175 |
176 | const destroyStream = () => {
177 | streamRef.current?.destroy();
178 | streamRef.current = null;
179 | setIsPlaying(false);
180 | };
181 |
182 | const handlePause = () => {
183 | videoRef.current?.pause();
184 | destroyStream();
185 | };
186 |
187 | const handlePlay = async () => {
188 | try {
189 | initStream();
190 | await videoRef.current?.play();
191 | setIsPlaying(true);
192 | } catch (error) {
193 | console.log("Failed to play: ", error);
194 | }
195 | };
196 |
197 | useEffect(() => {
198 | if (!props.playback.isPlaying && streamRef.current) {
199 | destroyStream();
200 | }
201 | }, [props.playback]);
202 |
203 | return (
204 | <>
205 |
212 |
213 | videoRef.current?.pause() : () => videoRef.current?.play()}
216 | color={useThemeBlackColor()}
217 | size={props.size}
218 | aria-label="Settings"
219 | >
220 | {isPlaying ? (
221 |
222 | ) : (
223 |
224 | )}
225 |
226 |
227 | >
228 | );
229 | };
230 |
--------------------------------------------------------------------------------
/web/studio/src/page/TracksQueue.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ActionIcon,
3 | Box,
4 | Button,
5 | CloseButton,
6 | Flex,
7 | Group,
8 | LoadingOverlay,
9 | Paper,
10 | Space,
11 | Text,
12 | Tooltip,
13 | } from "@mantine/core";
14 | import { FC, useEffect, useState } from "react";
15 | import { usePlaybackStore } from "../store/playback";
16 | import { useTrackQueueStore } from "../store/track-queue";
17 | import { EmptyLabel } from "../components/EmptyLabel";
18 | import { errNotify, okNotify } from "../notifications";
19 | import { useDisclosure } from "@mantine/hooks";
20 | import { moveArrayItem, shuffleArray } from "../utils/array";
21 | import { Track } from "../api/types";
22 | import { modals } from "@mantine/modals";
23 | import styles from "./styles.module.css";
24 | import { IconReload } from "../icons";
25 | import { PlaylistsModal } from "./Playlists";
26 | import { DndContext, DragEndEvent } from "@dnd-kit/core";
27 | import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
28 | import { SortableContext, useSortable } from "@dnd-kit/sortable";
29 | import { CSS } from "@dnd-kit/utilities";
30 |
31 | export const TrackQueue: FC<{ isMobile?: boolean }> = (props) => {
32 | const [loader, handLoader] = useDisclosure(false);
33 | const playback = usePlaybackStore((s) => s.playback);
34 | const queue = useTrackQueueStore((s) => s.queue);
35 | const fetchQueue = useTrackQueueStore((s) => s.fetchQueue);
36 | const updateQueue = useTrackQueueStore((s) => s.updateQueue);
37 | const removeFromQueue = useTrackQueueStore((s) => s.removeFromQueue);
38 | const [hovered, setHovered] = useState(false);
39 |
40 | const loadQueue = async () => {
41 | handLoader.open();
42 | try {
43 | await fetchQueue();
44 | } catch (error) {
45 | errNotify(error);
46 | } finally {
47 | handLoader.close();
48 | }
49 | };
50 |
51 | const handleRemove = async (trackIDs: string[]) => {
52 | handLoader.open();
53 | try {
54 | await removeFromQueue(trackIDs);
55 | } catch (error) {
56 | errNotify(error);
57 | } finally {
58 | handLoader.close();
59 | setHovered(true);
60 | }
61 | };
62 |
63 | const handleClear = async () => {
64 | handLoader.open();
65 | try {
66 | const trackIDs = queue.filter(({ id }) => id !== playback.currentTrack?.id).map(({ id }) => id);
67 | const { message } = await removeFromQueue(trackIDs);
68 | okNotify(message);
69 | } catch (error) {
70 | errNotify(error);
71 | } finally {
72 | handLoader.close();
73 | }
74 | };
75 |
76 | const confirmClear = () => {
77 | modals.openConfirmModal({
78 | title: "Confirm clear the queue",
79 | cancelProps: { variant: "light", color: "gray" },
80 | centered: true,
81 | children: Do you really want to completely clear the track queue? ,
82 | labels: { confirm: "Confirm", cancel: "Cancel" },
83 | onConfirm: () => handleClear(),
84 | });
85 | };
86 |
87 | const handleShuffle = async () => {
88 | try {
89 | const shuffled = shuffleArray(queue.filter(({ id }) => id !== playback.currentTrack?.id));
90 | await updateQueue(playback.currentTrack ? [playback.currentTrack, ...shuffled] : shuffled);
91 | } catch (error) {
92 | errNotify(error);
93 | }
94 | };
95 |
96 | const confirmShuffle = () => {
97 | modals.openConfirmModal({
98 | title: "Confirm shuffle the queue",
99 | cancelProps: { variant: "light", color: "gray" },
100 | centered: true,
101 | children: Do you really want to shuffle the track queue? ,
102 | labels: { confirm: "Confirm", cancel: "Cancel" },
103 | onConfirm: () => handleShuffle(),
104 | });
105 | };
106 |
107 | const handleDragEvent = async (event: DragEndEvent) => {
108 | const { active, over } = event;
109 | if (over && active.id !== over.id) {
110 | const fromIndex = queue.findIndex((t) => t.id === active.id);
111 | const toIndex = queue.findIndex((t) => t.id === over.id);
112 | setHovered(false);
113 | try {
114 | await updateQueue(moveArrayItem(queue, fromIndex, toIndex));
115 | } catch (error) {
116 | errNotify(error);
117 | }
118 | }
119 | };
120 |
121 | const tracklist = queue.map((track) => {
122 | if (track.id === playback?.currentTrack?.id && playback.isPlaying) return null;
123 | return ;
124 | });
125 |
126 | useEffect(() => {
127 | loadQueue();
128 | }, []);
129 |
130 | return (
131 |
132 |
133 |
134 |
135 |
136 |
142 |
143 | Live queue
144 |
145 | {queue.length > 1 ? queue.length - (playback.isPlaying ? 1 : 0) : ""}
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 | setHovered(true)}
162 | onMouseLeave={() => setHovered(false)}
163 | style={{
164 | overflowX: "hidden",
165 | overflowY: hovered ? "auto" : "hidden",
166 | scrollbarGutter: "stable",
167 | }}
168 | >
169 |
170 | {tracklist}
171 |
172 | {!queue.length || (queue.length === 1 && playback.isPlaying) ? (
173 |
174 | ) : null}
175 |
176 |
177 |
178 |
179 |
180 |
181 | Clear
182 |
183 |
184 | 🎲 Shuffle
185 |
186 |
187 |
188 |
189 | );
190 | };
191 |
192 | const QueueItem: FC<{ track: Track; handleRemove: (ids: string[]) => Promise }> = ({ track, handleRemove }) => {
193 | const [hovered, setHovered] = useState(false);
194 | const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: track.id });
195 | const style = { transform: CSS.Transform.toString(transform), transition };
196 |
197 | return (
198 | setHovered(true)}
205 | onMouseLeave={() => setHovered(false)}
206 | >
207 |
208 |
215 | {track.name}
216 |
217 | {
222 | handleRemove([track.id]);
223 | setHovered(false);
224 | }}
225 | />
226 |
227 |
228 | );
229 | };
230 |
--------------------------------------------------------------------------------
/web/studio/src/page/index.tsx:
--------------------------------------------------------------------------------
1 | import { useViewportSize } from "@mantine/hooks";
2 | import { lazy, Suspense } from "react";
3 |
4 | const DesktopPage = lazy(() => import("./DesktopPage"));
5 | const MobilePage = lazy(() => import("./MobilePage"));
6 | const MAX_MOBILE_WIDTH = 800;
7 |
8 | export const Page = () => {
9 | const { width: windowWidth } = useViewportSize();
10 | const PageComponent = windowWidth > MAX_MOBILE_WIDTH ? DesktopPage : MobilePage;
11 |
12 | return (
13 |
14 |
15 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/web/studio/src/page/styles.module.css:
--------------------------------------------------------------------------------
1 | .transparent_paper {
2 | position: relative;
3 | background-color: rgb(0 0 0 / 30%);
4 | }
5 |
6 | .input {
7 | padding: 0rem 0.5rem;
8 | border: 1px solid rgba(255, 255, 255, 0.1);
9 | border-radius: 0.2rem;
10 | }
11 |
12 | .progress_bar {
13 | background-color: rgba(255, 255, 255, 0.2);
14 | }
15 |
--------------------------------------------------------------------------------
/web/studio/src/store/events.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import { API_HOST, API_PREFIX } from "../api";
3 |
4 | const EVENT_SOURCE_URL = API_HOST + API_PREFIX + "/events";
5 | export const EVENTS = {
6 | newTrack: "new_track",
7 | loadedTracks: "loaded_tracks",
8 | countListeners: "count_listeners",
9 | };
10 |
11 | type EventHandler = (event: MessageEvent) => void;
12 |
13 | interface EventSourceStore {
14 | eventSource?: EventSource;
15 | addEventHandler: (eventName: string, handler: EventHandler) => void;
16 | closeEventSource: () => void;
17 | }
18 |
19 | export const useEventSourceStore = create((set, get) => ({
20 | eventSource: undefined,
21 |
22 | addEventHandler: (eventName: string, handler: EventHandler) => {
23 | let { eventSource } = get();
24 |
25 | if (!eventSource) {
26 | eventSource = new EventSource(EVENT_SOURCE_URL);
27 |
28 | eventSource.onerror = () => {
29 | console.error("EventSource connection error");
30 | };
31 |
32 | set({ eventSource });
33 | }
34 |
35 | eventSource.addEventListener(eventName, handler);
36 | },
37 |
38 | closeEventSource: () => {
39 | const { eventSource } = get();
40 | if (eventSource) {
41 | eventSource.close();
42 | set({ eventSource: undefined });
43 | }
44 | },
45 | }));
46 |
--------------------------------------------------------------------------------
/web/studio/src/store/playback.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import { PlaybackState } from "../api/types";
3 | import { airstationAPI } from "../api";
4 | import { errNotify } from "../notifications";
5 | import { getUnixTime } from "../utils/time";
6 |
7 | interface PlaybackStore {
8 | playback: PlaybackState;
9 | setPlayback: (pb: PlaybackState) => void;
10 | play: () => Promise;
11 | pause: () => Promise;
12 | fetchPlayback: () => Promise;
13 | syncElapsedTime: () => void;
14 | }
15 |
16 | export const usePlaybackStore = create()((set) => ({
17 | playback: { currentTrack: null, currentTrackElapsed: 0, isPlaying: false, updatedAt: getUnixTime() },
18 |
19 | setPlayback(pb) {
20 | if (pb.currentTrack) pb.currentTrack.duration = Math.ceil(pb.currentTrack.duration);
21 | set({ playback: pb });
22 | },
23 |
24 | async fetchPlayback() {
25 | try {
26 | const pb = await airstationAPI.getPlayback();
27 | if (pb.currentTrack) pb.currentTrack.duration = Math.ceil(pb.currentTrack.duration);
28 |
29 | set({ playback: pb });
30 | } catch (error) {
31 | errNotify(error);
32 | }
33 | },
34 |
35 | async play() {
36 | const playback = await airstationAPI.playPlayback();
37 | set({ playback });
38 | return playback;
39 | },
40 |
41 | async pause() {
42 | const playback = await airstationAPI.pausePlayback();
43 | set({ playback });
44 | return playback;
45 | },
46 |
47 | syncElapsedTime() {
48 | set((state) => {
49 | if (!state.playback.currentTrack || !state.playback.isPlaying) return state;
50 |
51 | const currentTime = getUnixTime();
52 | const diff = currentTime - state.playback.updatedAt;
53 | const elapsed = state.playback.currentTrackElapsed + diff;
54 | if (elapsed > state.playback.currentTrack.duration) return state;
55 |
56 | return {
57 | ...state,
58 | playback: {
59 | ...state.playback,
60 | currentTrackElapsed: elapsed,
61 | updatedAt: currentTime,
62 | },
63 | };
64 | });
65 | },
66 | }));
67 |
--------------------------------------------------------------------------------
/web/studio/src/store/playlists.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import { Playlist, ResponseOK } from "../api/types";
3 | import { airstationAPI } from "../api";
4 |
5 | interface PlaylistStore {
6 | playlists: Playlist[];
7 |
8 | setPlaylists(playlists: Playlist[]): void;
9 | addPlaylist(name: string, trackIDs: string[], description?: string): Promise;
10 | fetchPlaylists(): Promise;
11 | editPlaylist(id: string, name: string, trackIDs: string[], description?: string): Promise;
12 | deletePlaylist(id: string): Promise;
13 | }
14 |
15 | export const usePlaylistStore = create()((set, get) => ({
16 | playlists: [],
17 |
18 | setPlaylists(p) {
19 | set({ playlists: p });
20 | },
21 |
22 | async fetchPlaylists() {
23 | const p = await airstationAPI.getPlaylists();
24 | set({ playlists: p });
25 | },
26 |
27 | async addPlaylist(name, trackIDs, description) {
28 | const p = await airstationAPI.addPlaylist(name, trackIDs, description);
29 | set({ playlists: [p, ...get().playlists] });
30 | return p;
31 | },
32 |
33 | async editPlaylist(id: string, name: string, trackIDs: string[], description?: string) {
34 | const resp = await airstationAPI.editPlaylist(id, name, trackIDs, description);
35 | set({
36 | playlists: get().playlists.map((p) =>
37 | p.id === id
38 | ? {
39 | id,
40 | name,
41 | tracks: [],
42 | trackCount: trackIDs.length,
43 | description,
44 | }
45 | : p,
46 | ),
47 | });
48 |
49 | return resp;
50 | },
51 |
52 | async deletePlaylist(id) {
53 | const resp = await airstationAPI.deletePlaylist(id);
54 | set({ playlists: get().playlists.filter((p) => p.id !== id) });
55 | return resp;
56 | },
57 | }));
58 |
--------------------------------------------------------------------------------
/web/studio/src/store/track-queue.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import { ResponseOK, Track } from "../api/types";
3 | import { airstationAPI } from "../api";
4 |
5 | interface TrackQueueStore {
6 | queue: Track[];
7 | fetchQueue: () => Promise;
8 | updateQueue: (tracks: Track[]) => Promise;
9 | addToQueue(trackIDs: string[]): Promise;
10 | removeFromQueue(trackIDs: string[]): Promise;
11 | rotateQueue: () => void;
12 | }
13 |
14 | export const useTrackQueueStore = create()((set, get) => ({
15 | queue: [],
16 |
17 | async fetchQueue() {
18 | const q = await airstationAPI.getQueue();
19 | set({ queue: q });
20 | },
21 |
22 | async updateQueue(tracks) {
23 | set({ queue: tracks });
24 | await airstationAPI.updateQueue(tracks.map(({ id }) => id));
25 | },
26 |
27 | async addToQueue(trackIDs: string[]) {
28 | const resp = await airstationAPI.addToQueue(trackIDs);
29 | const q = await airstationAPI.getQueue();
30 | set({ queue: q });
31 | return resp;
32 | },
33 |
34 | async removeFromQueue(trackIDs: string[]) {
35 | const resp = await airstationAPI.removeFromQueue(trackIDs);
36 | const filtered = get().queue.filter(({ id }) => !trackIDs.includes(id));
37 | set({ queue: filtered });
38 | return resp;
39 | },
40 |
41 | rotateQueue() {
42 | set((state) => {
43 | if (state.queue.length === 0) return state;
44 | return {
45 | ...state,
46 | queue: [...state.queue.slice(1), state.queue[0]],
47 | };
48 | });
49 | },
50 | }));
51 |
--------------------------------------------------------------------------------
/web/studio/src/store/tracks.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import { ResponseOK, Track } from "../api/types";
3 | import { airstationAPI } from "../api";
4 |
5 | interface TracksStore {
6 | tracks: Track[];
7 | totalTracks: number;
8 |
9 | setTracks(tracks: Track[]): void;
10 | fetchTracks(p: number, l: number, s: string, sb: keyof Track, so: "asc" | "desc"): Promise;
11 | uploadTracks(files: File[]): Promise;
12 | deleteTracks(trackIDs: string[]): Promise;
13 | }
14 |
15 | export const useTracksStore = create()((set, get) => ({
16 | tracks: [],
17 | totalTracks: 0,
18 |
19 | setTracks(q) {
20 | set({ tracks: q });
21 | },
22 |
23 | async fetchTracks(p: number, l: number, s: string, sb: keyof Track, so: "asc" | "desc") {
24 | const result = await airstationAPI.getTracks(p, l, s, sb, so);
25 | if (p === 1) {
26 | set({ tracks: result.tracks, totalTracks: result.total });
27 | return;
28 | }
29 |
30 | // If it not a first page, just append new tracks
31 | const trackIDs = new Set(get().tracks.map((t) => t.id));
32 | const tracks = [...get().tracks];
33 |
34 | for (const track of result.tracks) {
35 | if (trackIDs.has(track.id)) continue;
36 | tracks.push(track);
37 | }
38 |
39 | set({ tracks, totalTracks: result.total });
40 | },
41 |
42 | async uploadTracks(files: File[]) {
43 | const resp = await airstationAPI.uploadTracks(files);
44 | return resp;
45 | },
46 |
47 | async deleteTracks(trackIDs: string[]) {
48 | const resp = await airstationAPI.deleteTracks(trackIDs);
49 | const filtered = get().tracks.filter(({ id }) => !trackIDs.includes(id));
50 | set({ tracks: filtered, totalTracks: get().totalTracks - trackIDs.length });
51 | return resp;
52 | },
53 | }));
54 |
--------------------------------------------------------------------------------
/web/studio/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface DisclosureHandler {
2 | open: () => void;
3 | close: () => void;
4 | toggle: () => void;
5 | }
6 |
--------------------------------------------------------------------------------
/web/studio/src/utils/array.ts:
--------------------------------------------------------------------------------
1 | export const moveArrayItem = (array: T[], fromIndex: number, toIndex: number): T[] => {
2 | if (fromIndex < 0 || fromIndex >= array.length || toIndex < 0 || toIndex >= array.length) {
3 | return array;
4 | }
5 |
6 | const newArray = [...array];
7 | const [movedItem] = newArray.splice(fromIndex, 1);
8 |
9 | newArray.splice(toIndex, 0, movedItem);
10 | return newArray;
11 | };
12 |
13 | export const shuffleArray = (array: T[]): T[] => {
14 | const shuffled = [...array];
15 |
16 | for (let i = shuffled.length - 1; i > 0; i--) {
17 | const j = Math.floor(Math.random() * (i + 1));
18 | [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
19 | }
20 |
21 | return shuffled;
22 | };
23 |
--------------------------------------------------------------------------------
/web/studio/src/utils/error.ts:
--------------------------------------------------------------------------------
1 | export const handleErr = (err: unknown) => {
2 | return String(err).replace("Error: ", "");
3 | };
4 |
--------------------------------------------------------------------------------
/web/studio/src/utils/time.ts:
--------------------------------------------------------------------------------
1 | export const formatTime = (seconds: number): string => {
2 | const hours = Math.floor(seconds / 3600);
3 | const minutes = Math.floor((seconds % 3600) / 60);
4 | const remainingSeconds = Math.floor(seconds % 60);
5 |
6 | const formattedHours = String(hours).padStart(2, "0");
7 | const formattedMinutes = String(minutes).padStart(2, "0");
8 | const formattedSeconds = String(remainingSeconds).padStart(2, "0");
9 |
10 | return hours > 0
11 | ? `${formattedHours}:${formattedMinutes}:${formattedSeconds}`
12 | : `${formattedMinutes}:${formattedSeconds}`;
13 | };
14 |
15 | export const getUnixTime = (): number => Math.floor(Date.now() / 1000);
16 |
--------------------------------------------------------------------------------
/web/studio/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/web/studio/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true
24 | },
25 | "include": ["src"]
26 | }
27 |
--------------------------------------------------------------------------------
/web/studio/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/web/studio/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/web/studio/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import { VitePWA } from "vite-plugin-pwa";
4 |
5 | // https://vite.dev/config/
6 | export default defineConfig({
7 | base: "/studio/",
8 | plugins: [
9 | react(),
10 | VitePWA({
11 | scope: "/studio/",
12 | registerType: "autoUpdate",
13 | workbox: { cleanupOutdatedCaches: true },
14 | devOptions: {
15 | enabled: true,
16 | },
17 | manifest: {
18 | scope: "/studio/",
19 | start_url: "/studio/",
20 | lang: "en",
21 | name: "Airstation Studio",
22 | short_name: "Airstation",
23 | icons: [
24 | {
25 | src: "icon48.png",
26 | sizes: "48x48",
27 | type: "image/png",
28 | purpose: "maskable any",
29 | },
30 | {
31 | src: "icon72.png",
32 | sizes: "72x72",
33 | type: "image/png",
34 | purpose: "maskable any",
35 | },
36 | {
37 | src: "icon96.png",
38 | sizes: "96x96",
39 | type: "image/png",
40 | purpose: "maskable any",
41 | },
42 | {
43 | src: "icon128.png",
44 | sizes: "128x128",
45 | type: "image/png",
46 | purpose: "maskable any",
47 | },
48 | {
49 | src: "icon144.png",
50 | sizes: "144x144",
51 | type: "image/png",
52 | purpose: "maskable any",
53 | },
54 | {
55 | src: "icon152.png",
56 | sizes: "152x152",
57 | type: "image/png",
58 | purpose: "maskable any",
59 | },
60 | {
61 | src: "icon192.png",
62 | sizes: "192x192",
63 | type: "image/png",
64 | purpose: "maskable any",
65 | },
66 | {
67 | src: "icon256.png",
68 | sizes: "256x256",
69 | type: "image/png",
70 | purpose: "maskable any",
71 | },
72 | {
73 | src: "icon512.png",
74 | sizes: "512x512",
75 | type: "image/png",
76 | purpose: "maskable any",
77 | },
78 | ],
79 | },
80 | }),
81 | ],
82 | server: {
83 | proxy: {
84 | "/api": { target: "http://localhost:7331", changeOrigin: true },
85 | "/static": { target: "http://localhost:7331", changeOrigin: true },
86 | "/stream": { target: "http://localhost:7331", changeOrigin: true },
87 | },
88 | },
89 | });
90 |
--------------------------------------------------------------------------------