├── .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 | logo 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 | Web studio screenshot 21 | Web studio mobile screenshot 22 | Web player screenshot 23 | 24 |

25 |
Made for fun
26 |
LICENSE 2025 - Present
`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 | Technology stack 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 | Audio pipeline 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 | Playback lifecycle 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 |
DESKTOP_WIDTH ? styles.menu_desktop : styles.menu_mobile 60 | }`} 61 | > 62 |
63 |
64 |
65 |
66 |
67 | {history().map((h) => ( 68 |
copyToClipboard(h.trackName)}> 69 |
{h.trackName}
70 |
{formatDateToTimeFirst(new Date(h.playedAt * 1000))}
71 |
72 | ))} 73 | {hideLoadMore() ? null : ( 74 | 77 | )} 78 |
79 |
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 |
21 | 22 | 23 |
24 |
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 |