Repository: cheatsnake/airstation Branch: master Commit: 78c3e31e60f0 Files: 142 Total size: 278.3 KB Directory structure: gitextract_l3fdckbo/ ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ └── docker-image.yml ├── .gitignore ├── Dockerfile ├── Dockerfile.multiarch ├── LICENSE ├── Makefile ├── README.md ├── cmd/ │ └── main.go ├── docker-compose.yml ├── docs/ │ ├── installation.md │ ├── overview.md │ └── roadmap.md ├── go.mod ├── go.sum ├── internal/ │ ├── config/ │ │ └── config.go │ ├── http/ │ │ ├── consts.go │ │ ├── handlers.go │ │ ├── messages.go │ │ ├── middlewares.go │ │ ├── parser.go │ │ └── server.go │ ├── logger/ │ │ └── logger.go │ ├── pkg/ │ │ ├── ffmpeg/ │ │ │ ├── const.go │ │ │ ├── ffmpeg.go │ │ │ └── types.go │ │ ├── fs/ │ │ │ └── fs.go │ │ ├── hls/ │ │ │ ├── const.go │ │ │ ├── playlist.go │ │ │ ├── playlist_test.go │ │ │ ├── segment.go │ │ │ └── segment_test.go │ │ ├── sql/ │ │ │ └── sql.go │ │ ├── sse/ │ │ │ ├── emitter.go │ │ │ └── event.go │ │ └── ulid/ │ │ └── ulid.go │ ├── playback/ │ │ ├── service.go │ │ ├── state.go │ │ └── types.go │ ├── playlist/ │ │ ├── consts.go │ │ ├── service.go │ │ └── types.go │ ├── queue/ │ │ ├── service.go │ │ └── types.go │ ├── station/ │ │ ├── const.go │ │ ├── service.go │ │ └── types.go │ ├── storage/ │ │ ├── sqlite/ │ │ │ ├── migrations/ │ │ │ │ ├── migrations.go │ │ │ │ └── runner.go │ │ │ ├── playback.go │ │ │ ├── playlist.go │ │ │ ├── queue.go │ │ │ ├── sqlite.go │ │ │ ├── station.go │ │ │ └── track.go │ │ └── storage.go │ └── track/ │ ├── consts.go │ ├── service.go │ └── types.go └── web/ ├── player/ │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── App.tsx │ │ ├── api/ │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── const.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 │ │ │ ├── StationInformation.module.css │ │ │ ├── StationInformation.tsx │ │ │ └── index.tsx │ │ ├── store/ │ │ │ ├── events.ts │ │ │ ├── history.ts │ │ │ └── track.ts │ │ ├── utils/ │ │ │ ├── color.ts │ │ │ ├── date.ts │ │ │ ├── document.ts │ │ │ └── url.ts │ │ └── vite-env.d.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── studio/ ├── .gitignore ├── .prettierrc ├── README.md ├── index.html ├── package.json ├── src/ │ ├── App.tsx │ ├── api/ │ │ ├── index.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── components/ │ │ ├── AudioPlayer.tsx │ │ ├── AuthGuard.tsx │ │ └── EmptyLabel.tsx │ ├── hooks/ │ │ ├── useIsMobile.ts │ │ └── 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 │ │ ├── Settings.tsx │ │ ├── TracksLibrary.tsx │ │ ├── TracksQueue.tsx │ │ ├── index.tsx │ │ └── styles.module.css │ ├── store/ │ │ ├── events.ts │ │ ├── playback.ts │ │ ├── playlists.ts │ │ ├── settings.ts │ │ ├── track-queue.ts │ │ └── tracks.ts │ ├── theme.ts │ ├── types/ │ │ └── index.ts │ ├── utils/ │ │ ├── array.ts │ │ ├── error.ts │ │ ├── json.ts │ │ └── time.ts │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ docs Makefile README.md Dockerfile.multiarch ================================================ FILE: .github/FUNDING.yml ================================================ github: [cheatsnake] buy_me_a_coffee: yurace ================================================ FILE: .github/workflows/docker-image.yml ================================================ name: Build and Push Docker Image on: push: tags: - "*" jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . file: Dockerfile.multiarch push: true platforms: linux/amd64,linux/arm64 tags: | cheatsnake/airstation:${{ github.ref_name }} cheatsnake/airstation:latest ================================================ FILE: .gitignore ================================================ .env static *.db* ================================================ FILE: Dockerfile ================================================ FROM node:22-alpine AS player WORKDIR /app COPY ./web/player/package*.json ./ RUN npm install COPY ./web/player . ARG AIRSTATION_PLAYER_TITLE ENV AIRSTATION_PLAYER_TITLE=$AIRSTATION_PLAYER_TITLE RUN npm run build FROM node:22-alpine AS studio WORKDIR /app COPY ./web/studio/package*.json ./ RUN npm install COPY ./web/studio . RUN npm run build FROM golang:1.26-alpine AS server WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY cmd/ ./cmd/ COPY internal/ ./internal/ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/bin/main ./cmd/main.go FROM alpine:latest WORKDIR /app RUN apk add --no-cache ffmpeg COPY --from=server /app/bin/main . COPY --from=player /app/dist ./web/player/dist COPY --from=studio /app/dist ./web/studio/dist EXPOSE 7331 ENTRYPOINT ["./main"] ================================================ FILE: Dockerfile.multiarch ================================================ FROM node:22-alpine AS player WORKDIR /app COPY ./web/player/package*.json ./ RUN npm install COPY ./web/player . RUN npm run build FROM node:22-alpine AS studio WORKDIR /app COPY ./web/studio/package*.json ./ RUN npm install COPY ./web/studio . RUN npm run build FROM golang:1.26-alpine AS server ARG TARGETOS ARG TARGETARCH WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY cmd/ ./cmd/ COPY internal/ ./internal/ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-s -w" -o /app/bin/main ./cmd/main.go FROM alpine:latest WORKDIR /app RUN apk add --no-cache ffmpeg COPY --from=server /app/bin/main . COPY --from=player /app/dist ./web/player/dist COPY --from=studio /app/dist ./web/studio/dist EXPOSE 7331 ENTRYPOINT ["./main"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 cheatsnake Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ .PHONY: build test: @go test -cover -race ./... | grep -v '^?' fmt: go fmt ./... count-lines: @echo "total code lines:" && find . -name "*.go" -exec cat {} \; | wc -l build: @echo "⚙️ Installing web player dependencies..." @npm ci --prefix ./web/player @echo "⚙️ Installing web studio dependencies..." @npm ci --prefix ./web/studio @echo "🛠️ Building web player..." @npm run build --prefix ./web/player @echo "🛠️ Building web studio..." @npm run build --prefix ./web/studio @echo "🛠️ Building web server..." @go build ./cmd/main.go @echo "✅ Build completed successfully" ================================================ FILE: README.md ================================================

logo

Airstation

Your own online radio station

🔍 Overview   💻 Demo   ⚙️ Installation   🗺️ Roadmap   🚨 Bug report


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. Web studio screenshot Web studio mobile screenshot Web player screenshot

Made for fun
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 > Use [random string generator](https://it-tools.tech/token-generator?length=20) with a length of at least 10 characters for these variables! 3. Build a docker image and start a new container ```sh docker compose up -d ``` And finally you can see: - Control panel on [http://localhost:7331/studio/](http://localhost:7331/studio/) (extra slash matters!) - Radio player on [http://localhost:7331](http://localhost:7331) To stop the container, just type: ```sh docker compose down ``` ### Docker Compose 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: ```yml # docker-compose.yml services: airstation: image: cheatsnake/airstation:latest ports: - "7331:7331" volumes: - airstation-data:/app/storage - ./static:/app/static restart: unless-stopped environment: AIRSTATION_SECRET_KEY: ${AIRSTATION_SECRET_KEY:-PASTE_YOUR_OWN_KEY} AIRSTATION_JWT_SIGN: ${AIRSTATION_JWT_SIGN:-PASTE_RANDOM_STRING} healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:7331/"] interval: 10s timeout: 5s retries: 3 start_period: 10s volumes: airstation-data: ``` > 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` ## Build from source 1. Follow steps 1 and 2 from the previous section 2. Install dependencies ```sh npm ci --prefix ./web/player ``` ```sh npm ci --prefix ./web/studio ``` 3. Build web clients ```sh npm run build --prefix ./web/player ``` ```sh npm run build --prefix ./web/studio ``` 4. Build server ```sh go build ./cmd/main.go ``` 5. Run app ```sh ./main ``` > Make sure you have [FFmpeg](https://ffmpeg.org/) installed on your system. See the result on [http://localhost:7331](http://localhost:7331) and [http://localhost:7331/studio/](http://localhost:7331/studio/) (extra slash matters!) ## Development mode To run the application in development mode, start each part of the application using the commands below: ```sh npm run dev --prefix ./web/player ``` ```sh npm run dev --prefix ./web/studio ``` ```sh go run ./cmd/main.go ``` ================================================ FILE: docs/overview.md ================================================ # Overview 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. 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. 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. Technology stack ## Main features - Permanent storage of tracks in the library - Ability to listen to added tracks - Ability to delete tracks from the library - Search for tracks in the library - Sort tracks by date added, name, duration. - Creating a track queue - Changing the current track queue - Cyclic queue mode - Possibility to randomly mix the queue - Possibility to temporarily stop the radio station - Playback history - Listener counter - Playlists mechanism - Player page customization ## Deep under the hood 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: Audio pipeline - First, as the station owner, you upload the track to the server. Typically, these are `mp3` or `aac` files with varying bitrates. - Next, all files are transcoded into a unified codec and bitrate, then stored permanently on the server. - 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. 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: Playback lifecycle - 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). - As soon as the track enters the playback state, playback time starts counting. - 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). - Each listener periodically requests the current chunks to maintain uninterrupted playback. ## Who is this app suitable for? 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). ================================================ FILE: docs/roadmap.md ================================================ # Roadmap Here you can find a list of future updates. ## 🚧 In Progress - [ ] New music visualizers for player page --- ## 📝 Planned Features - [ ] Tags for tracks (as a grouping mechanism) - [ ] Ability to send voice messages recorded through the microphone - [ ] Scheduling mechanism for tracks/playlists (by [hjdx2009](https://github.com/cheatsnake/airstation/issues/7#issue-3059402373)) --- ## 🌟 Long-Term Goals - [ ] Crossfade effect between tracks (by [rursache](https://github.com/cheatsnake/airstation/issues/5#issuecomment-2873728112)) - [ ] Integration with online music services for downloading tracks directly to the library - [ ] Built-in tunneling feature (to share your local server with others over the Internet) - [ ] Integration with other music programs to synchronize track playback (by [swishkin](https://github.com/cheatsnake/airstation/issues/8#issue-3069650457)) --- ## ✅ Done - [x] Theming for player page (by [ptolemaea](https://github.com/cheatsnake/airstation/issues/21)) - [x] Custom station info (name, description, logo, favicon, links) - [x] Playlists (ability to pre-create and select already created playlists for playback) --- ## 💡 Suggestions? Have a feature request or idea? Feel free to open an [issue](https://github.com/cheatsnake/airstation/issues). ================================================ FILE: go.mod ================================================ module github.com/cheatsnake/airstation go 1.26 require github.com/oklog/ulid/v2 v2.1.1 require github.com/rs/cors v1.11.1 require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/joho/godotenv v1.5.1 modernc.org/sqlite v1.48.1 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/tools v0.43.0 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) ================================================ FILE: go.sum ================================================ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA= modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= ================================================ FILE: internal/config/config.go ================================================ package config import ( "log" "os" "path/filepath" "strings" "github.com/joho/godotenv" ) const minSecretLength = 10 type Config struct { DBDir string DBFile string TracksDir string TmpDir string PlayerDir string StudioDir string HTTPPort string JWTSign string SecretKey string SecureCookie bool } func Load() *Config { _ = godotenv.Load() // For development return &Config{ DBDir: getEnv("AIRSTATION_DB_DIR", filepath.Join("storage")), DBFile: getEnv("AIRSTATION_DB_FILE", "storage.db"), TracksDir: getEnv("AIRSTATION_TRACKS_DIR", filepath.Join("static", "tracks")), TmpDir: getEnv("AIRSTATION_TMP_DIR", filepath.Join("static", "tmp")), PlayerDir: getEnv("AIRSTATION_PLAYER_DIR", filepath.Join("web", "player", "dist")), StudioDir: getEnv("AIRSTATION_STUDIO_DIR", filepath.Join("web", "studio", "dist")), HTTPPort: getEnv("AIRSTATION_HTTP_PORT", "7331"), JWTSign: getSecret("AIRSTATION_JWT_SIGN"), SecretKey: getSecret("AIRSTATION_SECRET_KEY"), SecureCookie: getEnvBool("AIRSTATION_SECURE_COOKIE", false), } } func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } func getEnvBool(key string, defaultValue bool) bool { val := os.Getenv(key) if val == "" { return defaultValue } val = strings.ToLower(val) return val == "1" || val == "true" || val == "yes" || val == "on" } func getSecret(key string) string { secretKey := os.Getenv(key) if secretKey == "" { log.Fatal(key + " environment variable is not set") } if len(secretKey) < minSecretLength { log.Fatal(key + " is too short") } return secretKey } ================================================ FILE: internal/http/consts.go ================================================ package http const ( eventPlay = "play" eventPause = "pause" eventNewTrack = "new_track" eventLoadedTracks = "loaded_tracks" eventCountListeners = "count_listeners" eventChangeTheme = "change_theme" ) ================================================ FILE: internal/http/handlers.go ================================================ package http import ( "crypto/subtle" "errors" "fmt" "io" "mime/multipart" "net/http" "os" "path/filepath" "slices" "time" "github.com/cheatsnake/airstation/internal/pkg/sse" "github.com/cheatsnake/airstation/internal/station" "github.com/cheatsnake/airstation/internal/track" "github.com/golang-jwt/jwt/v5" ) const multipartChunkLimit = 64 * 1024 * 1024 // 64 MB const copyBufferSize = 256 * 1024 // 256 KB func (s *Server) handleHLSPlaylist(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "audio/mpegurl") if s.playbackState.IsPlaying { fmt.Fprint(w, s.playbackState.PlaylistStr) } } func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") eventChan := make(chan *sse.Event) s.eventsEmitter.Subscribe(eventChan) closeNotify := r.Context().Done() go func() { <-closeNotify s.eventsEmitter.Unsubscribe(eventChan) close(eventChan) }() // Send current number of listeners immediately countEvent := s.countListeners() fmt.Fprint(w, countEvent.Stringify()) w.(http.Flusher).Flush() for { event, isOpen := <-eventChan if !isOpen { break } fmt.Fprint(w, event.Stringify()) w.(http.Flusher).Flush() } } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { body, err := parseJSONBody[struct { Secret string `json:"secret"` }](r) if err != nil { jsonBadRequest(w, "Parsing request body failed.") return } isValidSecret := subtle.ConstantTimeCompare([]byte(body.Secret), []byte(s.config.SecretKey)) == 1 if !isValidSecret { jsonForbidden(w, "Wrong secret, access denied.") return } expirationTime := time.Now().Add(7 * 24 * time.Hour) claims := jwt.MapClaims{ "iss": "airstation", "exp": expirationTime.Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString([]byte(s.config.JWTSign)) if err != nil { s.logger.Debug("Failed to generate token: " + err.Error()) jsonInternalError(w, "Failed to generate token.") return } http.SetCookie(w, &http.Cookie{ Name: "jwt", Value: tokenString, Expires: expirationTime, Path: "/", HttpOnly: true, Secure: s.config.SecureCookie, SameSite: http.SameSiteStrictMode, }) s.logger.Info(fmt.Sprintf("New login succeed from %s with secureCookie=%v", r.Host, s.config.SecureCookie)) jsonOK(w, "Login succeed.") } func (s *Server) handleTracks(w http.ResponseWriter, r *http.Request) { queries := r.URL.Query() page := parseIntQuery(queries, "page", 1) limit := parseIntQuery(queries, "limit", 20) search := queries.Get("search") sortBy := queries.Get("sort_by") sortOrder := queries.Get("sort_order") result, err := s.trackService.Tracks(page, limit, search, sortBy, sortOrder) if err != nil { jsonBadRequest(w, "Tracks retrieving failed: "+err.Error()) return } jsonResponse(w, result) } func (s *Server) handleTracksUpload(w http.ResponseWriter, r *http.Request) { err := r.ParseMultipartForm(multipartChunkLimit) if err != nil { jsonBadRequest(w, "Failed to parse multipart form: "+err.Error()) return } files := r.MultipartForm.File["tracks"] if len(files) == 0 { jsonBadRequest(w, "No files uploaded") return } for _, fileHeader := range files { _, err := s.saveFile(fileHeader) if err != nil { jsonBadRequest(w, err.Error()) return } } go s.trackService.LoadTracksFromDisk(s.config.TracksDir) msg := fmt.Sprintf("%d track(s) uploaded successfully. They will be available in your library once processed.", len(files)) jsonOK(w, msg) } func (s *Server) handleDeleteTracks(w http.ResponseWriter, r *http.Request) { body, err := parseJSONBody[track.BodyWithIDs](r) if err != nil { jsonBadRequest(w, "Parsing request body failed: "+err.Error()) return } err = s.trackService.DeleteTracks(body.IDs) if err != nil { s.logger.Debug(err.Error()) jsonBadRequest(w, "Deleting tracks failed") return } jsonOK(w, "Tracks deleted") } func (s *Server) handleQueue(w http.ResponseWriter, _ *http.Request) { queue, err := s.queueService.Queue() if err != nil { s.logger.Debug(err.Error()) jsonBadRequest(w, "Queue retrieving failed: "+err.Error()) return } jsonResponse(w, queue) } func (s *Server) handleAddToQueue(w http.ResponseWriter, r *http.Request) { body, err := parseJSONBody[track.BodyWithIDs](r) if err != nil { jsonBadRequest(w, "Parsing request body failed: "+err.Error()) return } tracks, err := s.trackService.FindTracks(body.IDs) if err != nil { jsonBadRequest(w, "Adding tracks to queue failed: "+err.Error()) return } err = s.queueService.AddToQueue(tracks) if err != nil { jsonBadRequest(w, "Adding tracks to queue failed: "+err.Error()) return } err = s.playbackState.Reload() if err != nil { s.logger.Debug("Playback reload failed: " + err.Error()) } jsonOK(w, "Tracks added") } func (s *Server) handleReorderQueue(w http.ResponseWriter, r *http.Request) { body, err := parseJSONBody[track.BodyWithIDs](r) if err != nil { jsonBadRequest(w, "Parsing request body failed: "+err.Error()) return } err = s.queueService.ReorderQueue(body.IDs) if err != nil { jsonBadRequest(w, "Queue reordering failed: "+err.Error()) return } err = s.playbackState.Reload() if err != nil { s.logger.Debug("Playback reload failed: " + err.Error()) } jsonOK(w, "Queue reordered") } func (s *Server) handleRemoveFromQueue(w http.ResponseWriter, r *http.Request) { body, err := parseJSONBody[track.BodyWithIDs](r) if err != nil { jsonBadRequest(w, "Parsing request body failed: "+err.Error()) return } if s.playbackState.CurrentTrack != nil { hasCurrent := slices.Contains(body.IDs, s.playbackState.CurrentTrack.ID) if hasCurrent { s.playbackState.Pause() } } err = s.queueService.RemoveFromQueue(body.IDs) if err != nil { jsonBadRequest(w, "Removing from queue failed: "+err.Error()) return } err = s.playbackState.Reload() if err != nil { s.logger.Debug("Playback reload failed: " + err.Error()) } jsonOK(w, "Tracks removed") } func (s *Server) handlePlaybackState(w http.ResponseWriter, _ *http.Request) { jsonResponse(w, s.playbackState) } func (s *Server) handlePausePlayback(w http.ResponseWriter, _ *http.Request) { s.playbackState.Pause() jsonResponse(w, s.playbackState) } func (s *Server) handlePlayPlayback(w http.ResponseWriter, _ *http.Request) { err := s.playbackState.Play() if err != nil { jsonBadRequest(w, "Playback failed to start: "+err.Error()) return } jsonResponse(w, s.playbackState) } func (s *Server) handlePlaybackHistory(w http.ResponseWriter, r *http.Request) { queries := r.URL.Query() limit := parseIntQuery(queries, "limit", 50) history, err := s.playbackService.RecentPlaybackHistory(limit) if err != nil { s.logger.Debug(err.Error()) jsonBadRequest(w, "Playback history retrieving failed") return } jsonResponse(w, history) } func (s *Server) handleAddPlaylist(w http.ResponseWriter, r *http.Request) { body, err := parseJSONBody[struct { Name string `json:"name"` Description string `json:"description"` TrackIDs []string `json:"trackIDs"` }](r) if err != nil { jsonBadRequest(w, "Parsing request body failed: "+err.Error()) return } pl, err := s.playlistService.AddPlaylist(body.Name, body.Description, body.TrackIDs) if err != nil { jsonBadRequest(w, "Playlist creation failed: "+err.Error()) return } jsonResponse(w, pl) } func (s *Server) handlePlaylists(w http.ResponseWriter, r *http.Request) { pls, err := s.playlistService.Playlists() if err != nil { jsonBadRequest(w, "Playlists retrieving failed: "+err.Error()) } jsonResponse(w, pls) } func (s *Server) handlePlaylist(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") pl, err := s.playlistService.Playlist(id) if err != nil { jsonBadRequest(w, "Playlist retrieving failed: "+err.Error()) } jsonResponse(w, pl) } func (s *Server) handleEditPlaylist(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") body, err := parseJSONBody[struct { Name string `json:"name"` Description string `json:"description"` TrackIDs []string `json:"trackIDs"` }](r) if err != nil { jsonBadRequest(w, "Parsing request body failed: "+err.Error()) return } err = s.playlistService.EditPlaylist(id, body.Name, body.Description, body.TrackIDs) if err != nil { jsonBadRequest(w, "Playlist creation failed: "+err.Error()) return } jsonOK(w, "Playlist updated") } func (s *Server) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") err := s.playlistService.DeletePlaylist(id) if err != nil { jsonBadRequest(w, "Playlist deletion failed: "+err.Error()) } jsonOK(w, "Playlist deleted") } func (s *Server) handleStaticDir(prefix string, path string) http.Handler { return http.StripPrefix(prefix, http.FileServer(http.Dir(path))) } func (s *Server) handleStaticDirWithoutCache(prefix string, path string) http.Handler { fileHandler := http.StripPrefix(prefix, http.FileServer(http.Dir(path))) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache") fileHandler.ServeHTTP(w, r) }) } func (s *Server) handleStationInfo(w http.ResponseWriter, _ *http.Request) { info, err := s.stationService.Info() if err != nil { jsonBadRequest(w, "Failed to get station info: "+err.Error()) return } jsonResponse(w, info) } func (s *Server) handleEditStationInfo(w http.ResponseWriter, r *http.Request) { body, err := parseJSONBody[station.Info](r) if err != nil { jsonBadRequest(w, "Parsing request body failed: "+err.Error()) return } info, err := s.stationService.EditInfo(body) if err != nil { jsonBadRequest(w, "Station info editing failed: "+err.Error()) return } s.eventsEmitter.RegisterEvent(eventChangeTheme, " ") jsonResponse(w, info) } func (s *Server) saveFile(fileHeader *multipart.FileHeader) (string, error) { file, err := fileHeader.Open() if err != nil { msg := "Failed to open file: " + err.Error() s.logger.Debug(msg) return "", errors.New(msg) } fileName := filepath.Base(fileHeader.Filename) filePath := filepath.Join(s.config.TracksDir, fileName) dst, err := os.Create(filePath) if err != nil { msg := "Failed to create file on disk: " + err.Error() s.logger.Debug(msg) return "", errors.New(msg) } _, err = io.CopyBuffer(dst, file, make([]byte, copyBufferSize)) if err != nil { msg := "Failed to save file: " + err.Error() s.logger.Debug(msg) return "", errors.New(msg) } file.Close() dst.Close() return filePath, nil } ================================================ FILE: internal/http/messages.go ================================================ package http import ( "encoding/json" "net/http" ) type Message struct { Message string `json:"message"` } func jsonResponse(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(data); err != nil { http.Error(w, "JSON encoding failed", http.StatusInternalServerError) } } func jsonMessage(w http.ResponseWriter, code int, body string) { msg := Message{Message: body} w.WriteHeader(code) jsonResponse(w, msg) } func jsonOK(w http.ResponseWriter, body string) { jsonMessage(w, http.StatusOK, body) } func jsonBadRequest(w http.ResponseWriter, body string) { jsonMessage(w, http.StatusBadRequest, body) } func jsonUnauthorized(w http.ResponseWriter, body string) { jsonMessage(w, http.StatusUnauthorized, body) } func jsonForbidden(w http.ResponseWriter, body string) { jsonMessage(w, http.StatusForbidden, body) } func jsonInternalError(w http.ResponseWriter, body string) { jsonMessage(w, http.StatusInternalServerError, body) } ================================================ FILE: internal/http/middlewares.go ================================================ package http import ( "fmt" "net/http" "github.com/golang-jwt/jwt/v5" ) func (s *Server) jwtAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("jwt") if err != nil { jsonUnauthorized(w, "Unauthorized, access denied.") return } token, err := jwt.Parse(cookie.Value, func(token *jwt.Token) (any, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return []byte(s.config.JWTSign), nil }) if err != nil || !token.Valid { jsonUnauthorized(w, "Invalid token.") return } next.ServeHTTP(w, r) }) } ================================================ FILE: internal/http/parser.go ================================================ package http import ( "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" ) func parseIntQuery(queries url.Values, key string, defaultValue int) int { queryValue := queries.Get(key) parsed, err := strconv.Atoi(queryValue) if err != nil { parsed = defaultValue } return parsed } func parseJSONBody[T any](r *http.Request) (*T, error) { rawBytes, err := io.ReadAll(r.Body) if err != nil { return nil, err } defer r.Body.Close() if len(rawBytes) == 0 { return nil, fmt.Errorf("request body is empty") } var jsonData T if err := json.Unmarshal(rawBytes, &jsonData); err != nil { return nil, err } return &jsonData, nil } ================================================ FILE: internal/http/server.go ================================================ package http import ( "log/slog" "mime" "net/http" "strconv" "time" "github.com/cheatsnake/airstation/internal/config" "github.com/cheatsnake/airstation/internal/pkg/ffmpeg" "github.com/cheatsnake/airstation/internal/pkg/hls" "github.com/cheatsnake/airstation/internal/pkg/sse" "github.com/cheatsnake/airstation/internal/playback" "github.com/cheatsnake/airstation/internal/playlist" "github.com/cheatsnake/airstation/internal/queue" "github.com/cheatsnake/airstation/internal/station" "github.com/cheatsnake/airstation/internal/storage" "github.com/cheatsnake/airstation/internal/track" "github.com/rs/cors" ) type Server struct { playbackState *playback.State eventsEmitter *sse.Emitter trackService *track.Service queueService *queue.Service playbackService *playback.Service playlistService *playlist.Service stationService *station.Service config *config.Config logger *slog.Logger router *http.ServeMux } func NewServer(store storage.Storage, conf *config.Config, logger *slog.Logger) *Server { ffmpegCLI := ffmpeg.NewCLI() ts := track.NewService(store, ffmpegCLI, logger.WithGroup("trackservice")) qs := queue.NewService(store) ps := playback.NewService(store) pls := playlist.NewService(store) ss := station.NewService(store) state := playback.NewState(ts, qs, ps, conf.TmpDir, logger.WithGroup("playback")) return &Server{ playbackState: state, eventsEmitter: sse.NewEmitter(), trackService: ts, queueService: qs, playbackService: ps, playlistService: pls, stationService: ss, config: conf, logger: logger.WithGroup("http"), router: http.NewServeMux(), } } func (s *Server) Run() { s.registerMP2TMimeType() // Public handlers s.router.HandleFunc("GET /stream", s.handleHLSPlaylist) s.router.HandleFunc("GET /api/v1/events", s.handleEvents) s.router.HandleFunc("GET /api/v1/station/info", s.handleStationInfo) s.router.HandleFunc("POST /api/v1/login", s.handleLogin) s.router.Handle("GET /static/tmp/", s.handleStaticDirWithoutCache("/static/tmp", s.config.TmpDir)) s.router.Handle("GET /api/v1/playback", http.HandlerFunc(s.handlePlaybackState)) s.router.Handle("GET /api/v1/playback/history", http.HandlerFunc(s.handlePlaybackHistory)) // Protected handlers s.router.Handle("POST /api/v1/tracks", s.jwtAuth(http.HandlerFunc(s.handleTracksUpload))) s.router.Handle("GET /api/v1/tracks", s.jwtAuth(http.HandlerFunc(s.handleTracks))) s.router.Handle("DELETE /api/v1/tracks", s.jwtAuth(http.HandlerFunc(s.handleDeleteTracks))) s.router.Handle("GET /api/v1/queue", s.jwtAuth(http.HandlerFunc(s.handleQueue))) s.router.Handle("POST /api/v1/queue", s.jwtAuth(http.HandlerFunc(s.handleAddToQueue))) s.router.Handle("PUT /api/v1/queue", s.jwtAuth(http.HandlerFunc(s.handleReorderQueue))) s.router.Handle("DELETE /api/v1/queue", s.jwtAuth(http.HandlerFunc(s.handleRemoveFromQueue))) s.router.Handle("POST /api/v1/playback/pause", s.jwtAuth(http.HandlerFunc(s.handlePausePlayback))) s.router.Handle("POST /api/v1/playback/play", s.jwtAuth(http.HandlerFunc(s.handlePlayPlayback))) s.router.Handle("POST /api/v1/playlist", s.jwtAuth(http.HandlerFunc(s.handleAddPlaylist))) s.router.Handle("GET /api/v1/playlists", s.jwtAuth(http.HandlerFunc(s.handlePlaylists))) s.router.Handle("GET /api/v1/playlist/{id}/", s.jwtAuth(http.HandlerFunc(s.handlePlaylist))) s.router.Handle("PUT /api/v1/playlist/{id}/", s.jwtAuth(http.HandlerFunc(s.handleEditPlaylist))) s.router.Handle("DELETE /api/v1/playlist/{id}/", s.jwtAuth(http.HandlerFunc(s.handleDeletePlaylist))) s.router.Handle("GET /static/tracks/", s.jwtAuth(s.handleStaticDir("/static/tracks", s.config.TracksDir))) s.router.Handle("PUT /api/v1/station/info", s.jwtAuth(http.HandlerFunc(s.handleEditStationInfo))) s.router.Handle("GET /studio/", s.handleStaticDir("/studio/", s.config.StudioDir)) s.router.Handle("GET /", s.handleStaticDir("/", s.config.PlayerDir)) s.listenEvents() err := s.playbackState.Play() if err != nil { s.logger.Warn("Auto start playing failed: " + err.Error()) } go s.playbackState.Run() go s.trackService.LoadTracksFromDisk(s.config.TracksDir) s.playbackService.DeleteOldPlaybackHistory() s.logger.Info("Server starts on http://localhost:" + s.config.HTTPPort) err = http.ListenAndServe(":"+s.config.HTTPPort, cors.Default().Handler(s.router)) if err != nil { s.logger.Error("Listen and serve failed", slog.String("info", err.Error())) } } func (s *Server) registerMP2TMimeType() { err := mime.AddExtensionType(hls.SegmentExtension, "video/mp2t") if err != nil { s.logger.Error("MP2T mime type registration failed", slog.String("info", err.Error())) } } func (s *Server) countListeners() *sse.Event { count := s.eventsEmitter.CountSubscribers() return sse.NewEvent(eventCountListeners, strconv.Itoa(count)) } func (s *Server) listenEvents() { countConnectionTicker := time.Tick(5 * time.Second) // TODO: add context for gracefull shutdown go func() { for range countConnectionTicker { event := s.countListeners() s.eventsEmitter.RegisterEvent(event.Name, event.Data) } }() go func() { for { select { case <-s.playbackState.PlayNotify: s.eventsEmitter.RegisterEvent(eventPlay, s.playbackState.CurrentTrack.Name) case <-s.playbackState.PauseNotify: s.eventsEmitter.RegisterEvent(eventPause, " ") case trackName := <-s.playbackState.NewTrackNotify: s.eventsEmitter.RegisterEvent(eventNewTrack, trackName) case loadedTracks := <-s.trackService.LoadedTracksNotify: s.eventsEmitter.RegisterEvent(eventLoadedTracks, strconv.Itoa(loadedTracks)) } } }() } ================================================ FILE: internal/logger/logger.go ================================================ package logger import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "os" "time" ) type logEntry struct { Time string `json:"time"` Level string `json:"level"` Group string `json:"group,omitempty"` Message string `json:"message"` Details map[string]any `json:"details,omitempty"` } type customJSONHandler struct { output io.Writer opts *slog.HandlerOptions group string attrs []slog.Attr } func (h *customJSONHandler) Enabled(ctx context.Context, level slog.Level) bool { if h.opts != nil && h.opts.Level != nil { return level >= h.opts.Level.Level() } return level >= slog.LevelInfo } func (h *customJSONHandler) Handle(ctx context.Context, r slog.Record) error { details := make(map[string]any) for _, attr := range h.attrs { details[attr.Key] = attr.Value.Any() } r.Attrs(func(attr slog.Attr) bool { details[attr.Key] = attr.Value.Any() return true }) entry := logEntry{ Time: r.Time.Format(time.DateTime), Level: r.Level.String(), Message: r.Message, Group: h.group, } if len(details) > 0 { entry.Details = details } var buf bytes.Buffer buf.WriteString("{") fmt.Fprintf(&buf, `"time":"%s"`, entry.Time) fmt.Fprintf(&buf, `,"level":"%s"`, entry.Level) if entry.Group != "" { fmt.Fprintf(&buf, `,"group":"%s"`, entry.Group) } fmt.Fprintf(&buf, `,"message":"%s"`, jsonEscape(entry.Message)) if len(details) > 0 { buf.WriteString(`,"details":`) detailsJSON, err := json.Marshal(details) if err != nil { return err } buf.Write(detailsJSON) } buf.WriteString("}\n") _, err := h.output.Write(buf.Bytes()) return err } func jsonEscape(s string) string { b, _ := json.Marshal(s) return string(b[1 : len(b)-1]) } func (h *customJSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler { newAttrs := make([]slog.Attr, len(h.attrs)+len(attrs)) copy(newAttrs, h.attrs) copy(newAttrs[len(h.attrs):], attrs) return &customJSONHandler{ output: h.output, opts: h.opts, group: h.group, attrs: newAttrs, } } func (h *customJSONHandler) WithGroup(name string) slog.Handler { return &customJSONHandler{ output: h.output, opts: h.opts, group: name, attrs: h.attrs, } } func newCustomJSONHandler(w io.Writer, opts *slog.HandlerOptions) *customJSONHandler { return &customJSONHandler{ output: w, opts: opts, attrs: []slog.Attr{}, } } func New() *slog.Logger { handler := newCustomJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, }) return slog.New(handler) } ================================================ FILE: internal/pkg/ffmpeg/const.go ================================================ package ffmpeg // Constants defining the executable names. const ( ffmpegBin = "ffmpeg" ffprobeBin = "ffprobe" ) ================================================ FILE: internal/pkg/ffmpeg/ffmpeg.go ================================================ // Package ffmpeg provides a CLI wrapper for executing FFmpeg and FFprobe commands, // enabling audio processing functionalities. package ffmpeg import ( "bytes" "encoding/json" "fmt" "os/exec" "path/filepath" "strconv" "github.com/cheatsnake/airstation/internal/pkg/fs" "github.com/cheatsnake/airstation/internal/pkg/ulid" ) // CLI represents a command-line interface for interacting with FFmpeg and FFprobe. type CLI struct{} // NewCLI creates and returns a new instance of CLI. func NewCLI() *CLI { return &CLI{} } // MakeHLSPlaylist converts an audio track into an HLS (HTTP Live Streaming) playlist with segmented files. // It generates a playlist (.m3u8) and segment files (.ts) in the specified output directory. // // Parameters: // - trackPath: The path to the source audio file to be converted into HLS format. // - outDir: The directory where the HLS playlist and segments will be stored. // - segName: The base name for the segment files, which will be suffixed with an index. // - segDuration: The duration (in seconds) of each segment. // // Returns: // - An error if the input file does not exist, or if the HLS generation process fails. func (cli *CLI) MakeHLSPlaylist(trackPath, outDir, segName string, segDuration int) error { if err := fs.FileExists(trackPath); err != nil { return err } hlsTime := strconv.Itoa(segDuration) hlsSegName := fmt.Sprintf("%s/%s", outDir, segName) + "%d.ts" hlsPlName := fmt.Sprintf("%s/%s", outDir, segName) + ".m3u8" cmd := exec.Command( ffmpegBin, "-i", trackPath, "-codec:", "copy", "-start_number", "0", "-hls_time", hlsTime, "-hls_playlist_type", "event", "-hls_segment_filename", hlsSegName, hlsPlName, ) var errBuf bytes.Buffer cmd.Stderr = &errBuf err := cmd.Run() if err != nil { return fmt.Errorf("hls playlist generation failed: %v\n%s", err, errBuf.String()) } return nil } // AudioMetadata extracts and returns metadata information from the specified audio file. // It uses ffprobe to retrieve details such as duration, bit rate, codec name, sample rate, and channel count. // // Parameters: // - filePath: The path to the audio file whose metadata is to be retrieved. // // Returns: // - AudioMetadata: A struct containing the extracted metadata (duration, bit rate, codec, sample rate, and channels). // - An error if the file does not exist, ffprobe execution fails, or metadata parsing encounters an issue. func (cli *CLI) AudioMetadata(filePath string) (AudioMetadata, error) { metadata := AudioMetadata{} if err := fs.FileExists(filePath); err != nil { return metadata, err } cmd := exec.Command( ffprobeBin, "-i", filePath, "-v", "error", "-show_entries", "format=duration,bit_rate:stream=codec_name,sample_rate,channels:format_tags=title:stream_tags=title", "-of", "json", ) var outBuf bytes.Buffer cmd.Stdout = &outBuf cmd.Stderr = &outBuf err := cmd.Run() if err != nil { return metadata, fmt.Errorf("metadata retrieve failed: %v\n%s", err, outBuf.String()) } var rawMetadata rawAudioMetadata if err = json.Unmarshal(outBuf.Bytes(), &rawMetadata); err != nil { return metadata, fmt.Errorf("parsing metadata retrieve failed: %v", err) } name := rawMetadata.Format.Tags.Title duration, err := strconv.ParseFloat(rawMetadata.Format.Duration, 64) if err != nil { return metadata, fmt.Errorf("parsing metadata duration failed: %v", err) } bitRate, err := strconv.Atoi(rawMetadata.Format.BitRate) if err != nil { return metadata, fmt.Errorf("parsing metadata bitrate failed: %v", err) } if len(rawMetadata.Streams) == 0 { return metadata, fmt.Errorf("couldn't extract all metadata") } channels := rawMetadata.Streams[0].Channels codecName := rawMetadata.Streams[0].CodecName sampleRate, err := strconv.Atoi(rawMetadata.Streams[0].SampleRate) if err != nil { return metadata, fmt.Errorf("parsing metadata sample rate failed: %v", err) } metadata.Name = name metadata.Duration = duration metadata.BitRate = int(bitRate / 1000) metadata.ChannelCount = channels metadata.CodecName = codecName metadata.SampleRate = sampleRate return metadata, nil } // PadAudio appends a period of silence to the given audio file, extending its duration by padDuration seconds. // It generates a silence file based on the provided audio metadata and concatenates it with the original file. // // Parameters: // - filePath: The path to the audio file to be padded. // - padDuration: The duration of silence (in seconds) to be added at the end of the audio. // - meta: The metadata of the original audio file, including codec, bit rate, sample rate, and channel count. // // Returns: // - An error if the file does not exist, silence generation fails, padding fails, or file operations encounter an issue. func (cli *CLI) PadAudio(filePath string, padDuration float64, meta AudioMetadata) error { if err := fs.FileExists(filePath); err != nil { return err } dir, name := filepath.Split(filePath) tmpFilePath := filepath.Join(dir, "xtmp-"+name) silenceFile := filepath.Join(dir, ulid.New()+"."+meta.CodecName) if err := cli.generateSilence(padDuration, meta.BitRate, meta.SampleRate, meta.ChannelCount, silenceFile); err != nil { return err } cmd := exec.Command( ffmpegBin, "-i", fmt.Sprintf("concat:%s|%s", filePath, silenceFile), "-c", "copy", tmpFilePath, "-y", ) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("padding audio failed: %v\nOutput: %s", err, string(output)) } err = fs.RenameFile(tmpFilePath, filePath) if err != nil { return err } go fs.DeleteFile(tmpFilePath) go fs.DeleteFile(silenceFile) return nil } // TrimAudio trims the audio file at the specified filePath to the given totalDuration. // It creates a temporary file, processes the trimming using ffmpeg, and replaces the original file. // // Parameters: // - filePath: The path to the audio file to be trimmed. // - totalDuration: The desired duration of the trimmed audio in seconds. // // Returns: // - An error if the file does not exist, the trimming process fails, or file operations encounter an issue. func (cli *CLI) TrimAudio(filePath string, totalDuration float64) error { if err := fs.FileExists(filePath); err != nil { return err } dir, name := filepath.Split(filePath) tmpFilePath := filepath.Join(dir, "xtmp-"+name) cmd := exec.Command( ffmpegBin, "-i", filePath, "-t", strconv.FormatFloat(totalDuration, 'f', 1, 64), "-c:a", "copy", tmpFilePath, "-y", ) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("triming audio failed: %v\nOutput: %s", err, string(output)) } err = fs.RenameFile(tmpFilePath, filePath) if err != nil { return err } fs.DeleteFile(tmpFilePath) return nil } // ConvertAudioToAAC converts an audio file to AAC format. // // Parameters: // - inputPath: Path to the input audio file (supports various formats like MP3, WAV, etc.) // - outputPath: Destination path for the converted AAC file (should end with .aac or .m4a) // - bitRate: Audio bitrate in kbps (e.g., 128 for 128kbps) // // Returns: // - error: Returns nil on success, or an error if conversion fails. The error includes // FFmpeg's output when available for debugging purposes. func (cli *CLI) ConvertAudioToAAC(inputPath, outputPath string, bitRate int) error { cmd := exec.Command( ffmpegBin, "-i", inputPath, "-vn", // Ignore video streams "-c:a", "aac", "-b:a", strconv.Itoa(bitRate)+"k", outputPath, "-y", ) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("triming audio failed: %v\nOutput: %s", err, string(output)) } return nil } // generateSilence generates a silent audio file with the specified duration, bitrate, sample rate, // and number of channels. The resulting audio file is saved to the provided file path. func (cli *CLI) generateSilence(duration float64, bitRate, sampleRate, channelCount int, filePath string) error { layout := "stereo" if channelCount == 1 { layout = "mono" } cmd := exec.Command( ffmpegBin, "-f", "lavfi", "-i", "anullsrc=r="+strconv.Itoa(sampleRate)+":cl="+layout, "-t", strconv.FormatFloat(duration, 'f', 1, 64), "-b:a", strconv.Itoa(bitRate)+"k", "-ar", strconv.Itoa(sampleRate), "-ac", strconv.Itoa(channelCount), filePath, "-y", ) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("generating silence audio failed: %v\nOutput: %s", err, string(output)) } return nil } ================================================ FILE: internal/pkg/ffmpeg/types.go ================================================ package ffmpeg // AudioMetadata holds metadata information about an audio file. type AudioMetadata struct { Name string // The name of the audio Duration float64 // The total duration of the audio file in seconds. BitRate int // The bit rate of the audio file in kbps (kilobits per second). CodecName string // The name of the codec used for encoding the audio. SampleRate int // The sample rate of the audio file in Hz (hertz). ChannelCount int // The number of audio channels (e.g., 1 for mono, 2 for stereo). } type rawAudioMetadata struct { Format struct { Duration string `json:"duration"` BitRate string `json:"bit_rate"` Tags struct { Title string `json:"title"` } `json:"tags"` } `json:"format"` Streams []struct { CodecName string `json:"codec_name"` SampleRate string `json:"sample_rate"` Channels int `json:"channels"` } `json:"streams"` } ================================================ FILE: internal/pkg/fs/fs.go ================================================ package fs import ( "fmt" "os" "path/filepath" "strings" ) func MustDir(dirPath string) { err := os.MkdirAll(dirPath, 0755) if err != nil { panic(err) } } func FileExists(filePath string) error { _, err := os.Stat(filePath) if os.IsNotExist(err) { return fmt.Errorf("file does not exist: %v", filePath) } if err != nil { return fmt.Errorf("retrieving file info failed: %v", err) } return nil } func DeleteFile(filePath string) error { err := os.Remove(filePath) if err != nil { return fmt.Errorf("deleting file failed: %v", err) } return nil } func DeleteDirIfExists(path string) error { _, err := os.Stat(path) if os.IsNotExist(err) { return nil } else if err != nil { return fmt.Errorf("error checking directory: %v", err) } err = os.RemoveAll(path) if err != nil { return fmt.Errorf("failed to delete directory: %v", err) } return nil } func RenameFile(oldPath, newPath string) error { err := os.Rename(oldPath, newPath) if err != nil { return fmt.Errorf("renaming file failed: %v", err) } return nil } func ListFilesFromDir(dirPath, fileExt string) ([]string, error) { var filenames []string fileInfo, err := os.Stat(dirPath) if err != nil { return nil, fmt.Errorf("failed to access directory: %v", err) } if !fileInfo.IsDir() { return nil, fmt.Errorf("path is not a directory: %s", dirPath) } err = filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { ext := strings.ToLower(filepath.Ext(path)) if strings.Contains(ext, fileExt) { relPath, err := filepath.Rel(dirPath, path) if err != nil { return err } filenames = append(filenames, relPath) } } return nil }) return filenames, err } ================================================ FILE: internal/pkg/hls/const.go ================================================ package hls const SegmentExtension = ".ts" const timeFormat = "2006-01-02T15:04:05.000Z" const ( DefaultMaxSegmentDuration = 5 DefaultLiveSegmentsAmount = 3 ) ================================================ FILE: internal/pkg/hls/playlist.go ================================================ // Package hls provides functionality for handling HTTP Live Streaming (HLS) playlists and segments. package hls import ( "math" "strconv" "time" ) // Playlist represents an HLS playlist structure. type Playlist struct { LiveSegmentsAmount int // The number of live segments in the playlist. MaxSegmentDuration int // The maximum duration (in seconds) of a segment in the playlist. mediaSequence int64 disconSequence int64 lastDisconUpdate time.Time currentTrackSegments []*Segment nextTrackSegments []*Segment currentSegmentPath string } // NewPlaylist creates and returns a new Playlist instance with the provided current and next track segments. // It initializes the playlist with default values for live segments amount, max segment duration, media sequence, // discontinuity sequence, and last discontinuity update time. // // Parameters: // - cur: The list of segments for the current track. // - next: The list of segments for the next track. // // Returns: // - A pointer to the newly created Playlist instance. func NewPlaylist(cur, next []*Segment) *Playlist { return &Playlist{ LiveSegmentsAmount: DefaultLiveSegmentsAmount, MaxSegmentDuration: DefaultMaxSegmentDuration, mediaSequence: 0, disconSequence: 0, lastDisconUpdate: time.Now(), currentTrackSegments: cur, nextTrackSegments: next, currentSegmentPath: "", } } // Generate constructs and returns the HLS playlist as a string based on the elapsed time. // It updates the discontinuity sequence, calculates the starting segment index, collects live segments, // and formats them into the HLS playlist format. // // Parameters: // - elapsedTime: The elapsed time in seconds used to determine the current segment index. // // Returns: // - A string representing the generated HLS playlist. func (p *Playlist) Generate(elapsedTime float64) string { offset := math.Mod(elapsedTime, float64(p.MaxSegmentDuration)) liveSegments := p.currentSegments(elapsedTime) prevSegmentPath := p.currentSegmentPath if len(liveSegments) > 0 { p.currentSegmentPath = liveSegments[0].Path } p.UpdateDisconSequence(elapsedTime) if prevSegmentPath != p.currentSegmentPath { p.mediaSequence++ } playlist := hlsHeader(p.MaxSegmentDuration, p.mediaSequence, p.disconSequence, offset) for _, seg := range liveSegments { playlist += hlsSegment(seg.Duration, seg.Path, seg.IsFirst) } return playlist } // Next updates the playlist by moving the next track segments to the current track segments // and assigning the provided segments as the new next track segments. // // Parameters: // - next: The new list of segments to be set as the next track segments. func (p *Playlist) Next(next []*Segment) { p.currentTrackSegments = p.nextTrackSegments p.nextTrackSegments = next } // ChangeNext replays segments for the next track. // // Parameters: // - next: The new list of segments to be set as the next track segments. func (p *Playlist) ChangeNext(next []*Segment) { p.nextTrackSegments = next } // AddSegments appends the provided segments to the next track segments list. // // Parameters: // - segments: The list of segments to append to the next track segments. func (p *Playlist) AddSegments(segments []*Segment) { p.nextTrackSegments = append(p.nextTrackSegments, segments...) } // SetMediaSequence set a new sequence number for mediaSequence. func (p *Playlist) SetMediaSequence(sequence int64) { p.mediaSequence = sequence } // UpdateDisconSequence updates the discontinuity sequence if a discontinuity is detected. // // Parameters: // - elapsedTime: The elapsed time in seconds used to calculate the current segment index. func (p *Playlist) UpdateDisconSequence(elapsedTime float64) { elapsedFromLastUpdate := time.Until(p.lastDisconUpdate).Seconds() if math.Abs(elapsedFromLastUpdate) < float64(p.MaxSegmentDuration) { return } index := p.calcCurrentSegmentIndex(elapsedTime) // if the current track segment is the second and it is not the very first track, // there was a discontinuty, so we increment the discontinuty counter if index == 1 && p.mediaSequence > 1 { p.disconSequence++ p.lastDisconUpdate = time.Now() } } func (p *Playlist) FirstNextTrackSegment() *Segment { if len(p.nextTrackSegments) > 0 { return p.nextTrackSegments[0] } return nil } // currentSegments gathers enough segments from current and next tracks to meet liveSegmentsAmount func (p *Playlist) currentSegments(elapsedTime float64) []*Segment { startIndex := p.calcCurrentSegmentIndex(elapsedTime) liveSegments := make([]*Segment, 0, p.LiveSegmentsAmount) if startIndex < len(p.currentTrackSegments) { endIndex := startIndex + p.LiveSegmentsAmount if endIndex >= len(p.currentTrackSegments) { endIndex = len(p.currentTrackSegments) } liveSegments = append(liveSegments, p.currentTrackSegments[startIndex:endIndex]...) } if len(liveSegments) < p.LiveSegmentsAmount { required := p.LiveSegmentsAmount - len(liveSegments) liveSegments = append(liveSegments, p.nextTrackSegments[:min(len(p.nextTrackSegments), required)]...) } return liveSegments } func (p *Playlist) calcCurrentSegmentIndex(elapsedTime float64) int { return int(math.Floor(elapsedTime / float64(p.MaxSegmentDuration))) } // hlsHeader generates the header string for an HLS playlist with the specified target duration. func hlsHeader(dur int, mediaSeq, disconSeq int64, offset float64) string { currentTime := time.Now().UTC().Round(time.Millisecond).Format(timeFormat) return "#EXTM3U\n" + "#EXT-X-VERSION:6\n" + "#EXT-X-PROGRAM-DATE-TIME:" + currentTime + "\n" + "#EXT-X-TARGETDURATION:" + strconv.Itoa(dur) + "\n" + "#EXT-X-MEDIA-SEQUENCE:" + strconv.FormatInt(mediaSeq, 10) + "\n" + "#EXT-X-DISCONTINUITY-SEQUENCE:" + strconv.FormatInt(disconSeq, 10) + "\n" + "#EXT-X-START:TIME-OFFSET=" + strconv.FormatFloat(offset, 'f', 2, 64) + "\n" } // hlsSegment generates an HLS segment entry with the specified duration and path. func hlsSegment(dur float64, path string, isDiscon bool) string { disconTag := "" if isDiscon { disconTag = "#EXT-X-DISCONTINUITY\n" } duration := strconv.FormatFloat(dur, 'f', 2, 64) return disconTag + "#EXTINF:" + duration + ",\n" + path + "\n" } ================================================ FILE: internal/pkg/hls/playlist_test.go ================================================ package hls import ( "reflect" "strings" "testing" ) func TestNewPlaylist(t *testing.T) { current := []*Segment{{Duration: 10.5, Path: "segment1.ts"}} next := []*Segment{{Duration: 9.0, Path: "segment2.ts"}} playlist := NewPlaylist(current, next) if playlist.MaxSegmentDuration != DefaultMaxSegmentDuration { t.Errorf("Expected maxSegmentDuration to be %d, got %d", DefaultMaxSegmentDuration, playlist.MaxSegmentDuration) } if playlist.LiveSegmentsAmount != DefaultLiveSegmentsAmount { t.Errorf("Expected liveSegmentsAmount to be %d, got %d", DefaultLiveSegmentsAmount, playlist.LiveSegmentsAmount) } if len(playlist.currentTrackSegments) != len(current) { t.Errorf("Expected %d segment in currentTrackSegments, got %d", len(current), len(playlist.currentTrackSegments)) } if len(playlist.nextTrackSegments) != len(next) { t.Errorf("Expected %d segment in nextTrackSegments, got %d", len(next), len(playlist.nextTrackSegments)) } } func TestGenerate(t *testing.T) { cases := []struct { name string current []*Segment next []*Segment elapsedTime float64 expectedPaths []string unexpected []string }{ { name: "full from current track", current: []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, {Duration: 5.0, Path: "segment2.ts"}, {Duration: 5.0, Path: "segment3.ts"}, }, next: []*Segment{ {Duration: 5.0, Path: "segment4.ts"}, }, elapsedTime: 0.0, expectedPaths: []string{"segment1.ts", "segment2.ts", "segment3.ts"}, unexpected: []string{"segment4.ts"}, }, { name: "partial from current and next track", current: []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, {Duration: 5.0, Path: "segment2.ts"}, }, next: []*Segment{ {Duration: 5.0, Path: "segment3.ts"}, {Duration: 5.0, Path: "segment4.ts"}, }, elapsedTime: 5.0, expectedPaths: []string{"segment2.ts", "segment3.ts", "segment4.ts"}, unexpected: []string{"segment1.ts"}, }, { name: "full from next track", current: []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, }, next: []*Segment{ {Duration: 5.0, Path: "segment2.ts"}, {Duration: 5.0, Path: "segment3.ts"}, {Duration: 5.0, Path: "segment4.ts"}, }, elapsedTime: 20.0, expectedPaths: []string{"segment2.ts", "segment3.ts", "segment4.ts"}, unexpected: []string{"segment1.ts"}, }, { name: "not enough segments", current: []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, }, next: []*Segment{ {Duration: 5.0, Path: "segment2.ts"}, }, elapsedTime: 0.0, expectedPaths: []string{"segment1.ts", "segment2.ts"}, unexpected: []string{"segment3.ts"}, }, { name: "empty tracks", current: []*Segment{}, next: []*Segment{}, elapsedTime: 0.0, expectedPaths: []string{}, unexpected: []string{"segment1.ts"}, }, { name: "start index beyond current track", current: []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, }, next: []*Segment{ {Duration: 5.0, Path: "segment2.ts"}, {Duration: 5.0, Path: "segment3.ts"}}, elapsedTime: 20.0, expectedPaths: []string{"segment2.ts", "segment3.ts"}, unexpected: []string{"segment1.ts"}, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { playlist := NewPlaylist(c.current, c.next) got := playlist.Generate(c.elapsedTime) for _, expected := range c.expectedPaths { if !strings.Contains(got, expected) { t.Errorf("Expected segment %s not found in playlist: %s", expected, got) } } for _, path := range c.unexpected { if strings.Contains(got, path) { t.Errorf("Unexpected segment %s found in playlist: %s", path, got) } } if !strings.HasPrefix(got, "#EXTM3U") { t.Errorf("Playlist header is missing: %s", got) } }) } } func TestNext(t *testing.T) { current := []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, {Duration: 5.0, Path: "segment2.ts"}, } next := []*Segment{ {Duration: 5.0, Path: "segment3.ts"}, } playlist := NewPlaylist(current, next) playlist.Next([]*Segment{{Duration: 8.0, Path: "segment4.ts"}}) if len(playlist.currentTrackSegments) != 1 || playlist.currentTrackSegments[0].Path != "segment3.ts" { t.Errorf("Expected currentTrackSegments to contain nextTrackSegments, got: %v", playlist.currentTrackSegments) } if len(playlist.nextTrackSegments) != 1 || playlist.nextTrackSegments[0].Path != "segment4.ts" { t.Errorf("Expected nextTrackSegments to be updated, got: %v", playlist.nextTrackSegments) } } func TestAddSegments(t *testing.T) { current := []*Segment{{Duration: 5.0, Path: "segment1.ts"}} next := []*Segment{{Duration: 5.0, Path: "segment2.ts"}} playlist := NewPlaylist(current, next) newSegments := []*Segment{ {Duration: 8.0, Path: "segment3.ts"}, {Duration: 7.5, Path: "segment4.ts"}, } playlist.AddSegments(newSegments) if len(playlist.nextTrackSegments) != 3 { t.Errorf("Expected nextTrackSegments to contain 3 segments, got: %d", len(playlist.nextTrackSegments)) } if playlist.nextTrackSegments[2].Path != "segment4.ts" { t.Errorf("Expected last segment to be segment4.ts, got: %s", playlist.nextTrackSegments[2].Path) } } func TestCollectLiveSegments(t *testing.T) { t.Run("full from current track", func(t *testing.T) { current := []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, {Duration: 5.0, Path: "segment2.ts"}, {Duration: 5.0, Path: "segment3.ts"}, } next := []*Segment{ {Duration: 5.0, Path: "segment4.ts"}, } playlist := NewPlaylist(current, next) got := playlist.currentSegments(0) expected := current if !reflect.DeepEqual(got, expected) { t.Errorf("Expected %v, got %v", expected, got) } }) t.Run("partitial from current track", func(t *testing.T) { current := []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, {Duration: 5.0, Path: "segment2.ts"}, {Duration: 5.0, Path: "segment3.ts"}, } next := []*Segment{ {Duration: 5.0, Path: "segment4.ts"}, } playlist := NewPlaylist(current, next) got := playlist.currentSegments(0) expected := []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, {Duration: 5.0, Path: "segment2.ts"}, {Duration: 5.0, Path: "segment3.ts"}, } if !reflect.DeepEqual(got, expected) { t.Errorf("Expected %v, got %v", expected, got) } }) t.Run("partial from current and next track", func(t *testing.T) { current := []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, {Duration: 5.0, Path: "segment2.ts"}, } next := []*Segment{ {Duration: 5.0, Path: "segment3.ts"}, {Duration: 5.0, Path: "segment4.ts"}, } playlist := NewPlaylist(current, next) got := playlist.currentSegments(1) expected := []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, {Duration: 5.0, Path: "segment2.ts"}, {Duration: 5.0, Path: "segment3.ts"}, } if !reflect.DeepEqual(got, expected) { t.Errorf("Expected %v, got %v", expected, got) } }) t.Run("full from next track", func(t *testing.T) { current := []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, } next := []*Segment{ {Duration: 5.0, Path: "segment2.ts"}, {Duration: 5.0, Path: "segment3.ts"}, {Duration: 5.0, Path: "segment4.ts"}, } playlist := NewPlaylist(current, next) got := playlist.currentSegments(DefaultMaxSegmentDuration) expected := next if !reflect.DeepEqual(got, expected) { t.Errorf("Expected %v, got %v", expected, got) } }) t.Run("not enough segments", func(t *testing.T) { current := []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, } next := []*Segment{ {Duration: 5.0, Path: "segment2.ts"}, } playlist := NewPlaylist(current, next) got := playlist.currentSegments(0) expected := []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, {Duration: 5.0, Path: "segment2.ts"}, } if !reflect.DeepEqual(got, expected) { t.Errorf("Expected %v, got %v", expected, got) } }) t.Run("start index beyond current track", func(t *testing.T) { current := []*Segment{ {Duration: 5.0, Path: "segment1.ts"}, } next := []*Segment{ {Duration: 5.0, Path: "segment2.ts"}, {Duration: 5.0, Path: "segment3.ts"}, } playlist := NewPlaylist(current, next) got := playlist.currentSegments(DefaultMaxSegmentDuration) expected := next if !reflect.DeepEqual(got, expected) { t.Errorf("Expected %v, got %v", expected, got) } }) t.Run("empty tracks", func(t *testing.T) { current := []*Segment{} next := []*Segment{} playlist := NewPlaylist(current, next) got := playlist.currentSegments(0) expected := []*Segment{} if !reflect.DeepEqual(got, expected) { t.Errorf("Expected %v, got %v", expected, got) } }) } func TestHlsHeader(t *testing.T) { cases := []struct { name string dur int mediaSeq int64 disconSeq int64 offset float64 expected []string }{ { name: "Basic case", dur: 10, mediaSeq: 1, disconSeq: 0, offset: 0.0, expected: []string{ "#EXTM3U\n", "#EXT-X-VERSION:6\n", "#EXT-X-TARGETDURATION:10\n", "#EXT-X-MEDIA-SEQUENCE:1\n", "#EXT-X-DISCONTINUITY-SEQUENCE:0\n", "#EXT-X-START:TIME-OFFSET=0.00\n", }, }, { name: "Non-zero discontinuity sequence", dur: 15, mediaSeq: 5, disconSeq: 2, offset: 5.5, expected: []string{ "#EXTM3U\n", "#EXT-X-VERSION:6\n", "#EXT-X-TARGETDURATION:15\n", "#EXT-X-MEDIA-SEQUENCE:5\n", "#EXT-X-DISCONTINUITY-SEQUENCE:2\n", "#EXT-X-START:TIME-OFFSET=5.50\n", }, }, { name: "Zero values", dur: 0, mediaSeq: 0, disconSeq: 0, offset: 0.0, expected: []string{ "#EXTM3U\n", "#EXT-X-VERSION:6\n", "#EXT-X-TARGETDURATION:0\n", "#EXT-X-MEDIA-SEQUENCE:0\n", "#EXT-X-DISCONTINUITY-SEQUENCE:0\n", "#EXT-X-START:TIME-OFFSET=0.00\n", }, }, { name: "Large values", dur: 999, mediaSeq: 123456789, disconSeq: 987654321, offset: 123.45, expected: []string{ "#EXTM3U\n", "#EXT-X-VERSION:6\n", "#EXT-X-TARGETDURATION:999\n", "#EXT-X-MEDIA-SEQUENCE:123456789\n", "#EXT-X-DISCONTINUITY-SEQUENCE:987654321\n", "#EXT-X-START:TIME-OFFSET=123.45\n", }, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := hlsHeader(c.dur, c.mediaSeq, c.disconSeq, c.offset) for _, expect := range c.expected { if !strings.Contains(got, expect) { t.Errorf("hlsHeader(%d, %d, %d, %.2f) = %q; want %q", c.dur, c.mediaSeq, c.disconSeq, c.offset, got, c.expected) } } }) } } func TestHlsSegment(t *testing.T) { cases := []struct { name string dur float64 path string isDiscon bool expected string }{ { name: "Basic segment without discontinuity", dur: 5.5, path: "segment0.ts", isDiscon: false, expected: "#EXTINF:5.50,\nsegment0.ts\n", }, { name: "Segment with discontinuity", dur: 8.333, path: "segment1.ts", isDiscon: true, expected: "#EXT-X-DISCONTINUITY\n#EXTINF:8.33,\nsegment1.ts\n", }, { name: "Zero duration segment without discontinuity", dur: 0, path: "segment2.ts", isDiscon: false, expected: "#EXTINF:0.00,\nsegment2.ts\n", }, { name: "Large duration segment with discontinuity", dur: 1234.56789, path: "segment3.ts", isDiscon: true, expected: "#EXT-X-DISCONTINUITY\n#EXTINF:1234.57,\nsegment3.ts\n", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { result := hlsSegment(c.dur, c.path, c.isDiscon) if result != c.expected { t.Errorf("hlsSegment(%f, %q, %v) = %q; want %q", c.dur, c.path, c.isDiscon, result, c.expected) } }) } } ================================================ FILE: internal/pkg/hls/segment.go ================================================ package hls import ( "math" "path/filepath" "strconv" ) // Segment represents a single segment in an HLS playlist. type Segment struct { Duration float64 // The length of the segment in seconds. Path string // The file path or URL of the segment. IsFirst bool // A flag indicating whether this segment is the first segment in the track. } // NewSegment creates and returns a new Segment instance with the provided duration, path, and first segment flag. // // Parameters: // - duration: The duration of the segment in seconds. // - path: The file path or URL of the segment. // - isFirst: A boolean flag indicating whether this segment is the first segment in the track. // // Returns: // - A pointer to the newly created Segment instance. func NewSegment(duration float64, path string, isFirst bool) *Segment { return &Segment{ Duration: duration, Path: path, IsFirst: isFirst, } } // GenerateSegments creates a list of Segment instances for a given track based on its duration and segment duration. // It divides the track into segments of the specified duration and generates metadata for each segment. // // Parameters: // - trackDuration: The total duration of the track in seconds. // - segmentDuration: The desired duration of each segment in seconds. // - trackID: The unique identifier for the track, used to generate segment names. // - outDir: The output directory where the segments will be stored. // // Returns: // - A slice of pointers to Segment instances representing the generated segments. func GenerateSegments(trackDuration float64, segmentDuration int, trackID, outDir string) []*Segment { if trackDuration <= 0 || segmentDuration <= 0 { return []*Segment{} } // Calculate total possible number of segments (rounded up) totalSegments := int(math.Round(trackDuration / float64(segmentDuration))) segments := make([]*Segment, 0, totalSegments) remaining := trackDuration index := 0 // Generate segments until the entire track is covered for remaining > 0 { segName := trackID + strconv.Itoa(index) + SegmentExtension segPath := filepath.Join(outDir, segName) // Use the smaller of the remaining or full segment duration duration := math.Min(remaining, float64(segmentDuration)) isFirst := index == 0 segments = append(segments, NewSegment(duration, segPath, isFirst)) remaining -= duration index++ } return segments } ================================================ FILE: internal/pkg/hls/segment_test.go ================================================ package hls import ( "testing" ) func TestNewSegment(t *testing.T) { cases := []struct { name string duration float64 path string isFirst bool expected *Segment }{ { name: "Basic segment", duration: 5.5, path: "segment0.ts", isFirst: false, expected: &Segment{ Duration: 5.5, Path: "segment0.ts", IsFirst: false, }, }, { name: "First segment", duration: 8.333, path: "segment1.ts", isFirst: true, expected: &Segment{ Duration: 8.333, Path: "segment1.ts", IsFirst: true, }, }, { name: "Zero duration", duration: 0, path: "segment2.ts", isFirst: false, expected: &Segment{ Duration: 0, Path: "segment2.ts", IsFirst: false, }, }, { name: "Empty path", duration: 10.0, path: "", isFirst: false, expected: &Segment{ Duration: 10.0, Path: "", IsFirst: false, }, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := NewSegment(c.duration, c.path, c.isFirst) if got.Duration != c.expected.Duration { t.Errorf("Duration = %f; want %f", got.Duration, c.expected.Duration) } if got.Path != c.expected.Path { t.Errorf("Path = %q; want %q", got.Path, c.expected.Path) } if got.IsFirst != c.expected.IsFirst { t.Errorf("IsFirst = %v; want %v", got.IsFirst, c.expected.IsFirst) } }) } } func TestGenerateSegments(t *testing.T) { cases := []struct { name string trackDuration float64 segmentDuration int trackID string outDir string expectedSegments []*Segment }{ { name: "Basic case", trackDuration: 10.0, segmentDuration: 3, trackID: "track1", outDir: "/out", expectedSegments: []*Segment{ {Duration: 3.0, Path: "/out/track10.ts", IsFirst: true}, {Duration: 3.0, Path: "/out/track11.ts", IsFirst: false}, {Duration: 3.0, Path: "/out/track12.ts", IsFirst: false}, {Duration: 1.0, Path: "/out/track13.ts", IsFirst: false}, }, }, { name: "Zero track duration", trackDuration: 0, segmentDuration: 3, trackID: "track2", outDir: "/out", expectedSegments: nil, }, { name: "Zero segment duration", trackDuration: 10.0, segmentDuration: 0, trackID: "track3", outDir: "/out", expectedSegments: nil, }, { name: "Exact division of track duration", trackDuration: 9.0, segmentDuration: 3, trackID: "track4", outDir: "/out", expectedSegments: []*Segment{ {Duration: 3.0, Path: "/out/track40.ts", IsFirst: true}, {Duration: 3.0, Path: "/out/track41.ts", IsFirst: false}, {Duration: 3.0, Path: "/out/track42.ts", IsFirst: false}, }, }, { name: "Large track duration", trackDuration: 25.0, segmentDuration: 10, trackID: "track5", outDir: "/out", expectedSegments: []*Segment{ {Duration: 10.0, Path: "/out/track50.ts", IsFirst: true}, {Duration: 10.0, Path: "/out/track51.ts", IsFirst: false}, {Duration: 5.0, Path: "/out/track52.ts", IsFirst: false}, }, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := GenerateSegments(c.trackDuration, c.segmentDuration, c.trackID, c.outDir) if len(got) != len(c.expectedSegments) { t.Fatalf("Expected %d segments, got %d", len(c.expectedSegments), len(got)) } for i, gotSegment := range got { if gotSegment.Duration != c.expectedSegments[i].Duration { t.Errorf("Segment %d: expected duration %f, got %f", i, c.expectedSegments[i].Duration, gotSegment.Duration) } if gotSegment.Path != c.expectedSegments[i].Path { t.Errorf("Segment %d: expected path %q, got %q", i, c.expectedSegments[i].Path, gotSegment.Path) } if gotSegment.IsFirst != c.expectedSegments[i].IsFirst { t.Errorf("Segment %d: expected IsFirst %v, got %v", i, c.expectedSegments[i].IsFirst, gotSegment.IsFirst) } } }) } } ================================================ FILE: internal/pkg/sql/sql.go ================================================ package sql import ( "database/sql" "fmt" "strings" ) func BuildInClause(column string, num int) string { if num == 0 { return fmt.Sprintf("%s IN ()", column) // Edge case: Empty IN clause (should be avoided in real queries) } placeholders := strings.Repeat("?,", num) placeholders = placeholders[:len(placeholders)-1] // Remove trailing comma return fmt.Sprintf("%s IN (%s)", column, placeholders) } func ColumnExists(db *sql.DB, tableName, columnName string) (bool, error) { query := ` SELECT COUNT(*) > 0 FROM pragma_table_info(?) WHERE name = ? ` var exists bool err := db.QueryRow(query, tableName, columnName).Scan(&exists) if err != nil { return false, fmt.Errorf("failed to check column existence: %w", err) } return exists, nil } func TableExists(db *sql.DB, tableName string) (bool, error) { query := ` SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = ? ` var exists bool err := db.QueryRow(query, tableName).Scan(&exists) if err != nil { return false, fmt.Errorf("failed to check table existence: %w", err) } return exists, nil } ================================================ FILE: internal/pkg/sse/emitter.go ================================================ package sse import ( "sync" ) // Emitter manages a set of subscribers and broadcasts events to them. type Emitter struct { subscribers sync.Map // A thread-safe map storing subscriber channels. } // NewEmitter creates and returns a new Emitter instance. // // Returns: // - A pointer to a new Emitter. func NewEmitter() *Emitter { return &Emitter{} } // RegisterEvent broadcasts an event with the specified name and data // to all currently subscribed channels. // // Parameters: // - name: The event name/type. // - data: The string payload of the event. func (ee *Emitter) RegisterEvent(name, data string) { event := NewEvent(name, data) ee.subscribers.Range(func(key, value any) bool { eventChan := key.(chan *Event) eventChan <- event return true }) } // CountSubscribers returns the number of currently active subscribers. // // Returns: // - An integer count of subscriber channels. func (ee *Emitter) CountSubscribers() int { count := 0 ee.subscribers.Range(func(key, value any) bool { count++ return true }) return count } // Subscribe adds a new subscriber channel to receive events. // // Parameters: // - eventChan: A channel to which events will be sent. func (ee *Emitter) Subscribe(eventChan chan *Event) { ee.subscribers.Store(eventChan, true) } // Unsubscribe removes a previously added subscriber channel. // // Parameters: // - eventChan: The channel to remove from the list of subscribers. func (ee *Emitter) Unsubscribe(eventChan chan *Event) { ee.subscribers.Delete(eventChan) } ================================================ FILE: internal/pkg/sse/event.go ================================================ // Package events provides a lightweight structure and utilities for creating // and formatting Server-Sent Events (SSE) to be sent over HTTP connections. package sse import ( "fmt" "strings" ) // Event represents a Server-Sent Event (SSE) with a name and data payload. type Event struct { Name string // The name/type of the event (used as "event:" in SSE format). Data string // The data payload of the event (used as "data:" in SSE format). } // NewEvent creates a new Event instance with the given name and data. // // Parameters: // - name: The name/type of the event. // - data: The string data payload associated with the event. // // Returns: // - A pointer to the newly created Event. func NewEvent(name, data string) *Event { return &Event{ Name: name, Data: data, } } // Stringify converts the Event into a string formatted for Server-Sent Events (SSE). // The format includes the "event" and "data" fields as per SSE specification, // followed by a double newline to indicate the end of the event. // // Returns: // - A string representation of the event in SSE format. func (e *Event) Stringify() string { var builder strings.Builder if e.Name != "" { builder.WriteString(fmt.Sprintf("event: %s\n", e.Name)) } if e.Data != "" { builder.WriteString(fmt.Sprintf("data: %s\n", e.Data)) } builder.WriteString("\n") return builder.String() } ================================================ FILE: internal/pkg/ulid/ulid.go ================================================ package ulid import ( "crypto/rand" "fmt" "strings" "sync" "time" "github.com/oklog/ulid/v2" ) const Length = ulid.EncodedSize var ( once sync.Once instance *generator ) type generator struct { timestamp uint64 entropy *ulid.MonotonicEntropy } func New() string { u := initGenerator() return strings.ToLower(ulid.MustNew(u.timestamp, u.entropy).String()) } func Verify(s string) error { _, err := ulid.Parse(s) if err != nil { return fmt.Errorf("id %s is not allowed, must satisfy the ulid format", s) } return nil } func initGenerator() *generator { once.Do(func() { instance = &generator{ timestamp: ulid.Timestamp(time.Now()), entropy: ulid.Monotonic(rand.Reader, 0), } }) return instance } ================================================ FILE: internal/playback/service.go ================================================ package playback import ( "log/slog" "time" ) type Service struct { store Store log *slog.Logger } func NewService(store Store) *Service { return &Service{ store: store, } } // AddPlaybackHistory logs a playback event for a given track. // // Parameters: // - trackName: The name of the track that was played. func (s *Service) AddPlaybackHistory(trackName string) { err := s.store.AddPlaybackHistory(time.Now().Unix(), trackName) if err != nil { s.log.Error("Failed to add playback history: " + err.Error()) } } // RecentPlaybackHistory retrieves the most recent playback history records. // // Parameters: // - limit: The maximum number of history entries to retrieve. // // Returns: // - A slice of PlaybackHistory pointers, or an error. func (s *Service) RecentPlaybackHistory(limit int) ([]*History, error) { history, err := s.store.RecentPlaybackHistory(limit) return history, err } // DeleteOldPlaybackHistory removes outdated playback history entries from the store. func (s *Service) DeleteOldPlaybackHistory() { _, err := s.store.DeleteOldPlaybackHistory() if err != nil { s.log.Warn("Failed to delete old playback history: " + err.Error()) } } ================================================ FILE: internal/playback/state.go ================================================ // Package playback manages audio playback state, track transitions, and HLS playlist generation. // It coordinates the timing and sequencing of audio tracks, maintaining synchronized state // for streaming playback, including current position, play/pause control, and playlist updates. // This package interacts with the track service to load tracks, generate segments, and handle // queue changes in a thread-safe manner. package playback import ( "errors" "log/slog" "strings" "sync" "time" "github.com/cheatsnake/airstation/internal/pkg/hls" "github.com/cheatsnake/airstation/internal/queue" "github.com/cheatsnake/airstation/internal/track" ) // State represents the current playback state of the application, including the currently playing track, // elapsed playback time, playlist management, and synchronization tools for safe concurrent access. type State struct { CurrentTrack *track.Track `json:"currentTrack"` // The currently playing track CurrentTrackElapsed float64 `json:"currentTrackElapsed"` // Seconds elapsed since the current track started playing IsPlaying bool `json:"isPlaying"` // Whether a track is currently playing UpdatedAt int64 `json:"updatedAt"` // Unix timestamp of the last state update NewTrackNotify chan string `json:"-"` // Channel to notify when a new track starts playing PlayNotify chan bool `json:"-"` // Channel to notify when playback starts PauseNotify chan bool `json:"-"` // Channel to notify when playback is paused PlaylistStr string `json:"-"` // Current HLS playlist as a string playlist *hls.Playlist // Internal representation of the HLS playlist playlistDir string // Directory where HLS playlist segments are stored refreshCount int64 // Number of state refresh cycles completed refreshInterval float64 // Time interval (in seconds) between state updates trackService *track.Service queueService *queue.Service playbackService *Service log *slog.Logger mutex sync.Mutex } // NewState creates and initializes a new playback State instance. func NewState(ts *track.Service, qs *queue.Service, ps *Service, tmpDir string, log *slog.Logger) *State { return &State{ CurrentTrack: nil, CurrentTrackElapsed: 0, IsPlaying: false, UpdatedAt: time.Now().Unix(), NewTrackNotify: make(chan string), PlayNotify: make(chan bool), PauseNotify: make(chan bool), trackService: ts, queueService: qs, playbackService: ps, refreshCount: 0, playlistDir: tmpDir, refreshInterval: 1, log: log, } } // Run starts the state update loop which refreshes playback progress and switches tracks when needed. func (s *State) Run() { ticker := time.NewTicker(time.Duration(s.refreshInterval) * time.Second) defer ticker.Stop() for range ticker.C { if !s.IsPlaying { continue } s.mutex.Lock() s.CurrentTrackElapsed += s.refreshInterval s.refreshCount++ if s.CurrentTrackElapsed >= s.CurrentTrack.Duration { err := s.loadNextTrack() if err != nil { s.log.Error(err.Error()) } go s.queueService.CleanupHLSPlaylists(s.playlistDir) go s.playbackService.AddPlaybackHistory(s.CurrentTrack.Name) } s.PlaylistStr = s.playlist.Generate(s.CurrentTrackElapsed) s.UpdatedAt = time.Now().Unix() s.mutex.Unlock() } } // Play starts playback by loading the current and next tracks into the HLS playlist. func (s *State) Play() error { current, next, err := s.queueService.CurrentAndNextTrack() if err != nil { return err } if current == nil { return errors.New("playback queue is empty") } err = s.initHLSPlaylist(current, next) if err != nil { return err } s.mutex.Lock() s.CurrentTrack = current s.PlaylistStr = s.playlist.Generate(s.CurrentTrackElapsed) s.UpdatedAt = time.Now().Unix() s.IsPlaying = true s.mutex.Unlock() s.PlayNotify <- true go s.playbackService.AddPlaybackHistory(current.Name) return nil } // Pause stops playback, clears current playback state and playlist. func (s *State) Pause() { s.mutex.Lock() s.CurrentTrack = nil s.CurrentTrackElapsed = 0 s.playlist = nil s.PlaylistStr = "" s.IsPlaying = false s.UpdatedAt = time.Now().Unix() s.mutex.Unlock() s.PauseNotify <- false } // Reload refreshes the current playlist based on updated queue state, used after queue changes. func (s *State) Reload() error { if !s.IsPlaying { return nil } current, next, err := s.queueService.CurrentAndNextTrack() if err != nil { return err } isCurrentTrackChanged := current != nil && s.CurrentTrack.ID != current.ID if isCurrentTrackChanged { // Restart if current track changed s.Pause() err = s.Play() if err != nil { return err } } segment := s.playlist.FirstNextTrackSegment() isNextTrackChanged := segment != nil && !strings.Contains(segment.Path, next.ID) if isNextTrackChanged { // Change segments for next track if it changed nextSeg, err := s.makeHLSSegments(next, s.playlistDir) if err != nil { return err } s.mutex.Lock() s.playlist.ChangeNext(nextSeg) s.mutex.Unlock() } return nil } // initHLSPlaylist prepares HLS segments for the current and next tracks, initializing a new playlist. func (s *State) initHLSPlaylist(current, next *track.Track) error { currentSeg, err := s.makeHLSSegments(current, s.playlistDir) if err != nil { return err } nextSeg, err := s.makeHLSSegments(next, s.playlistDir) if err != nil { return err } s.mutex.Lock() s.playlist = hls.NewPlaylist(currentSeg, nextSeg) s.UpdatedAt = time.Now().Unix() s.mutex.Unlock() return nil } // loadNextTrack advances the queue, resets elapsed time, and updates playlist with next segments. func (s *State) loadNextTrack() error { s.CurrentTrackElapsed = 0 err := s.queueService.SpinQueue() if err != nil { return err } current, next, err := s.queueService.CurrentAndNextTrack() if err != nil { return err } s.CurrentTrack = current nextTrackSegments, err := s.makeHLSSegments(next, s.playlistDir) if err != nil { return err } s.NewTrackNotify <- current.Name s.playlist.Next(nextTrackSegments) return nil } // makeHLSSegments generates HLS segments for a given track. func (s *State) makeHLSSegments(track *track.Track, dir string) ([]*hls.Segment, error) { if track == nil { return []*hls.Segment{}, nil } err := s.trackService.MakeHLSPlaylist(track.Path, dir, track.ID, hls.DefaultMaxSegmentDuration) if err != nil { return nil, err } segments := hls.GenerateSegments( track.Duration, hls.DefaultMaxSegmentDuration, track.ID, dir, ) return segments, nil } ================================================ FILE: internal/playback/types.go ================================================ package playback type History struct { ID int `json:"id"` PlayedAt int64 `json:"playedAt"` TrackName string `json:"trackName"` } type Store interface { AddPlaybackHistory(playedAt int64, trackName string) error RecentPlaybackHistory(limit int) ([]*History, error) DeleteOldPlaybackHistory() (int64, error) } ================================================ FILE: internal/playlist/consts.go ================================================ package playlist const ( minNameLen = 3 maxNameLen = 128 maxDescrLen = 4096 maxTracks = 100 ) ================================================ FILE: internal/playlist/service.go ================================================ package playlist import ( "errors" "fmt" ) type Service struct { store Store } func NewService(store Store) *Service { return &Service{ store: store, } } func (s *Service) AddPlaylist(name, description string, trackIDs []string) (*Playlist, error) { err := validateName(name) if err != nil { return nil, err } err = validateDescr(description) if err != nil { return nil, err } err = validateTracks(trackIDs) if err != nil { return nil, err } isExists, err := s.store.IsPlaylistExists(name) if err != nil { return nil, err } if isExists { return nil, fmt.Errorf("playlist with this name already exists") } pl, err := s.store.AddPlaylist(name, description, trackIDs) return pl, err } func (s *Service) Playlists() ([]*Playlist, error) { pls, err := s.store.Playlists() return pls, err } func (s *Service) Playlist(id string) (*Playlist, error) { pl, err := s.store.Playlist(id) return pl, err } func (s *Service) EditPlaylist(id, name, description string, trackIDs []string) error { err := validateName(name) if err != nil { return err } err = validateDescr(description) if err != nil { return err } err = validateTracks(trackIDs) if err != nil { return err } err = s.store.EditPlaylist(id, name, description, trackIDs) return err } func (s *Service) DeletePlaylist(id string) error { err := s.store.DeletePlaylist(id) return err } func validateName(name string) error { if len(name) < minNameLen { return fmt.Errorf("name must be at least %d characters", minNameLen) } if len(name) > maxNameLen { return fmt.Errorf("name must be at most %d characters", maxNameLen) } return nil } func validateDescr(descr string) error { if len(descr) > maxDescrLen { return fmt.Errorf("description must be at most %d characters", maxDescrLen) } return nil } func validateTracks(trackIDs []string) error { if len(trackIDs) > maxTracks { return fmt.Errorf("playlist cannot have more than %d tracks", maxTracks) } seen := make(map[string]struct{}, len(trackIDs)) for _, id := range trackIDs { if id == "" { return errors.New("track ID cannot be empty") } if _, exists := seen[id]; exists { return fmt.Errorf("duplicate track ID found: %s", id) } seen[id] = struct{}{} } return nil } ================================================ FILE: internal/playlist/types.go ================================================ package playlist import "github.com/cheatsnake/airstation/internal/track" type Playlist struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Tracks []*track.Track `json:"tracks"` TrackCount int `json:"trackCount"` } type Store interface { AddPlaylist(name, description string, trackIDs []string) (*Playlist, error) Playlists() ([]*Playlist, error) Playlist(id string) (*Playlist, error) IsPlaylistExists(name string) (bool, error) EditPlaylist(id, name, description string, trackIDs []string) error DeletePlaylist(id string) error } ================================================ FILE: internal/queue/service.go ================================================ package queue import ( "path" "strings" "time" "github.com/cheatsnake/airstation/internal/pkg/fs" "github.com/cheatsnake/airstation/internal/pkg/hls" "github.com/cheatsnake/airstation/internal/track" ) type Service struct { store Store } func NewService(store Store) *Service { return &Service{ store: store, } } // Queue retrieves the current playback queue. // // Returns: // - A slice of Track pointers or an error. func (s *Service) Queue() ([]*track.Track, error) { q, err := s.store.Queue() return q, err } // AddToQueue adds one or more tracks to the playback queue. // // Parameters: // - tracks: A slice of Track pointers to add. // // Returns: // - An error if the operation fails. func (s *Service) AddToQueue(tracks []*track.Track) error { err := s.store.AddToQueue(tracks) return err } // ReorderQueue updates the order of tracks in the playback queue. // // Parameters: // - ids: A slice of strings contains track IDs. // // Returns: // - An error if reordering fails. func (s *Service) ReorderQueue(ids []string) error { err := s.store.ReorderQueue(ids) return err } // RemoveFromQueue removes specific tracks from the playback queue. // // Parameters: // - ids: A slice of strings contains track IDs. // // Returns: // - An error if removal fails. func (s *Service) RemoveFromQueue(ids []string) error { err := s.store.RemoveFromQueue(ids) return err } // SpinQueue rotates the playback queue, moving the current track to the end. // // Returns: // - An error if the operation fails. func (s *Service) SpinQueue() error { err := s.store.SpinQueue() return err } // CurrentAndNextTrack retrieves the currently playing track and the next track in the queue. // // Returns: // - Pointers to the current and next tracks, and an error if retrieval fails. func (s *Service) CurrentAndNextTrack() (*track.Track, *track.Track, error) { current, next, err := s.store.CurrentAndNextTrack() return current, next, err } // CleanupHLSPlaylists removes old HLS playlist files that are no longer needed. // // Parameters: // - dirPath: Directory containing the HLS playlist files. // // Returns: // - An error if file cleanup fails. func (s *Service) CleanupHLSPlaylists(dirPath string) error { // waiting for all the listeners to listen to the last segments of ended track time.Sleep(hls.DefaultMaxSegmentDuration * 2 * time.Second) current, next, err := s.store.CurrentAndNextTrack() if err != nil { return err } utilized := []string{current.ID, next.ID} tmpFiles, err := fs.ListFilesFromDir(dirPath, "") if err != nil { return err } for _, tmpFile := range tmpFiles { keep := false for _, prefix := range utilized { if strings.HasPrefix(tmpFile, prefix) { keep = true break } } if !keep { fs.DeleteFile(path.Join(dirPath, tmpFile)) } } return nil } ================================================ FILE: internal/queue/types.go ================================================ package queue import "github.com/cheatsnake/airstation/internal/track" type Store interface { Queue() ([]*track.Track, error) AddToQueue(tracks []*track.Track) error RemoveFromQueue(trackIDs []string) error ReorderQueue(trackIDs []string) error SpinQueue() error CurrentAndNextTrack() (*track.Track, *track.Track, error) } ================================================ FILE: internal/station/const.go ================================================ package station const ( propName = "name" propDescription = "description" propFaviconURL = "faviconURL" propLogoURL = "logoURL" propLocation = "location" propTimezone = "timezone" propLinks = "links" propTheme = "theme" ) ================================================ FILE: internal/station/service.go ================================================ package station type Service struct { store Store } func NewService(store Store) *Service { return &Service{ store: store, } } func (s *Service) Info() (*Info, error) { rawProps, err := s.store.StationProperties() if err != nil { return nil, err } info := &Info{} for _, prop := range rawProps { switch prop.Key { case propName: info.Name = prop.Value case propDescription: info.Description = prop.Value case propFaviconURL: info.FaviconURL = prop.Value case propLogoURL: info.LogoURL = prop.Value case propLocation: info.Location = prop.Value case propTimezone: info.Timezone = prop.Value case propLinks: info.Links = prop.Value case propTheme: info.Theme = prop.Value } } return info, nil } func (s *Service) EditInfo(editedInfo *Info) (*Info, error) { currentInfo, err := s.Info() if err != nil { return nil, err } if currentInfo.Name != editedInfo.Name { if _, err := s.store.UpsertStationProperty(propName, editedInfo.Name); err != nil { return nil, err } } if currentInfo.Description != editedInfo.Description { if _, err := s.store.UpsertStationProperty(propDescription, editedInfo.Description); err != nil { return nil, err } } if currentInfo.FaviconURL != editedInfo.FaviconURL { if _, err := s.store.UpsertStationProperty(propFaviconURL, editedInfo.FaviconURL); err != nil { return nil, err } } if currentInfo.LogoURL != editedInfo.LogoURL { if _, err := s.store.UpsertStationProperty(propLogoURL, editedInfo.LogoURL); err != nil { return nil, err } } if currentInfo.Location != editedInfo.Location { if _, err := s.store.UpsertStationProperty(propLocation, editedInfo.Location); err != nil { return nil, err } } if currentInfo.Timezone != editedInfo.Timezone { if _, err := s.store.UpsertStationProperty(propTimezone, editedInfo.Timezone); err != nil { return nil, err } } if currentInfo.Links != editedInfo.Links { if _, err := s.store.UpsertStationProperty(propLinks, editedInfo.Links); err != nil { return nil, err } } if currentInfo.Theme != editedInfo.Theme { if _, err := s.store.UpsertStationProperty(propTheme, editedInfo.Theme); err != nil { return nil, err } } freshInfo, err := s.Info() if err != nil { return nil, err } return freshInfo, nil } ================================================ FILE: internal/station/types.go ================================================ package station type Info struct { Name string `json:"name"` Description string `json:"description"` FaviconURL string `json:"faviconURL"` LogoURL string `json:"logoURL"` Location string `json:"location"` Timezone string `json:"timezone"` Links string `json:"links"` Theme string `json:"theme"` } type Property struct { Key string Value string } type Store interface { StationProperties() ([]*Property, error) UpsertStationProperty(key, value string) (*Property, error) DeleteStationProperty(key string) error } ================================================ FILE: internal/storage/sqlite/migrations/migrations.go ================================================ package migrations import ( "database/sql" "fmt" ) type Migration struct { Version int Name string Up func(*sql.Tx) error Down func(*sql.Tx) error } var migrations = []Migration{ { Version: 1, Name: "create_main_tables", Up: func(tx *sql.Tx) error { queries := []string{ `CREATE TABLE IF NOT EXISTS migrations ( version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) );`, `CREATE TABLE IF NOT EXISTS tracks ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, path TEXT NOT NULL, duration REAL NOT NULL, bitRate INTEGER NOT NULL );`, `CREATE TABLE IF NOT EXISTS queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, track_id TEXT NOT NULL UNIQUE, FOREIGN KEY (track_id) REFERENCES tracks (id) );`, `CREATE TABLE IF NOT EXISTS playback_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, played_at INTEGER NOT NULL, track_name TEXT NOT NULL );`, `CREATE TABLE IF NOT EXISTS playlist ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT );`, `CREATE TABLE IF NOT EXISTS playlist_track ( playlist_id TEXT NOT NULL, track_id TEXT NOT NULL, position INTEGER NOT NULL, FOREIGN KEY (playlist_id) REFERENCES playlist (id) ON DELETE CASCADE, FOREIGN KEY (track_id) REFERENCES tracks (id), PRIMARY KEY (playlist_id, position), UNIQUE (playlist_id, track_id) );`, `CREATE TABLE IF NOT EXISTS station_properties ( key VARCHAR(100) PRIMARY KEY, value TEXT, created_at INTEGER DEFAULT (strftime('%s', 'now')), updated_at INTEGER DEFAULT (strftime('%s', 'now')) );`, } for _, query := range queries { if _, err := tx.Exec(query); err != nil { return fmt.Errorf("failed to execute query: %w, query: %s", err, query) } } return nil }, }, { Version: 2, Name: "create_main_indexes", Up: func(tx *sql.Tx) error { indexes := []string{ `CREATE INDEX IF NOT EXISTS idx_tracks_name ON tracks (name COLLATE NOCASE);`, `CREATE INDEX IF NOT EXISTS idx_playback_history_played_at ON playback_history(played_at);`, `CREATE INDEX IF NOT EXISTS idx_playlist_track_ids ON playlist_track (playlist_id, track_id);`, } for _, query := range indexes { if _, err := tx.Exec(query); err != nil { return fmt.Errorf("failed to create index: %w, query: %s", err, query) } } return nil }, }, } ================================================ FILE: internal/storage/sqlite/migrations/runner.go ================================================ package migrations import ( "database/sql" "fmt" "log/slog" sqltool "github.com/cheatsnake/airstation/internal/pkg/sql" ) func RunMigrations(db *sql.DB, log *slog.Logger) error { migrationTableExists, err := sqltool.TableExists(db, "migrations") if err != nil { return err } var currentVersion int if migrationTableExists { err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM migrations").Scan(¤tVersion) if err != nil { return fmt.Errorf("failed to get current version: %w", err) } } else { currentVersion = 0 log.Info("Fresh database, starting migrations from the beginning") } for _, migration := range migrations { if migration.Version > currentVersion { log.Info("Applying migration", "version", migration.Version, "name", migration.Name) tx, err := db.Begin() if err != nil { return fmt.Errorf("failed to begin transaction for migration %d: %w", migration.Version, err) } defer func() { if tx != nil { tx.Rollback() } }() if err := migration.Up(tx); err != nil { return fmt.Errorf("migration %d (%s) failed: %w", migration.Version, migration.Name, err) } if migrationTableExists || migration.Version >= 1 { _, err = tx.Exec( "INSERT INTO migrations (version, name) VALUES (?, ?)", migration.Version, migration.Name, ) if err != nil { return fmt.Errorf("failed to record migration %d: %w", migration.Version, err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit migration %d: %w", migration.Version, err) } tx = nil // Remove defer rollback after successful commit log.Info("Migration applied successfully", "version", migration.Version, "name", migration.Name) } } var finalVersion int err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM migrations").Scan(&finalVersion) if err != nil { return fmt.Errorf("failed to get final version: %w", err) } if finalVersion > currentVersion { log.Info("Database migration completed", "from_version", currentVersion, "to_version", finalVersion, "migrations_applied", finalVersion-currentVersion) } else { log.Info("Database is up to date", "version", finalVersion) } return nil } ================================================ FILE: internal/storage/sqlite/playback.go ================================================ package sqlite import ( "database/sql" "fmt" "sync" "github.com/cheatsnake/airstation/internal/playback" ) type PlaybackStore struct { db *sql.DB mutex *sync.Mutex } func NewPlaybackStore(db *sql.DB, mutex *sync.Mutex) PlaybackStore { return PlaybackStore{ db: db, mutex: mutex, } } func (ps *PlaybackStore) AddPlaybackHistory(playedAt int64, trackName string) error { ps.mutex.Lock() defer ps.mutex.Unlock() query := `INSERT INTO playback_history (played_at, track_name) VALUES (?, ?)` _, err := ps.db.Exec(query, playedAt, trackName) if err != nil { return fmt.Errorf("failed to insert playback entry: %v", err) } return nil } func (ps *PlaybackStore) RecentPlaybackHistory(limit int) ([]*playback.History, error) { ps.mutex.Lock() defer ps.mutex.Unlock() query := ` SELECT id, played_at, track_name FROM playback_history ORDER BY played_at DESC` query += fmt.Sprintf(" LIMIT %d", limit) rows, err := ps.db.Query(query) if err != nil { return nil, err } defer rows.Close() var history []*playback.History for rows.Next() { var item playback.History if err := rows.Scan(&item.ID, &item.PlayedAt, &item.TrackName); err != nil { return nil, err } history = append(history, &item) } return history, nil } func (ps *PlaybackStore) DeleteOldPlaybackHistory() (int64, error) { ps.mutex.Lock() defer ps.mutex.Unlock() query := ` DELETE FROM playback_history WHERE played_at < (strftime('%s', 'now') - 30 * 24 * 60 * 60)` result, err := ps.db.Exec(query) if err != nil { return 0, fmt.Errorf("failed to delete old entries: %v", err) } rowsAffected, err := result.RowsAffected() if err != nil { return 0, fmt.Errorf("failed to get rows affected: %v", err) } return rowsAffected, nil } ================================================ FILE: internal/storage/sqlite/playlist.go ================================================ package sqlite import ( "database/sql" "fmt" "sync" "github.com/cheatsnake/airstation/internal/pkg/ulid" "github.com/cheatsnake/airstation/internal/playlist" "github.com/cheatsnake/airstation/internal/track" ) type PlaylistStore struct { db *sql.DB mutex *sync.Mutex } func NewPlaylistStore(db *sql.DB, mutex *sync.Mutex) PlaylistStore { return PlaylistStore{ db: db, mutex: mutex, } } // AddPlaylist inserts a new playlist and associates tracks func (ps *PlaylistStore) AddPlaylist(name, description string, trackIDs []string) (*playlist.Playlist, error) { id := ulid.New() tx, err := ps.db.Begin() if err != nil { return nil, err } defer tx.Rollback() _, err = tx.Exec(`INSERT INTO playlist (id, name, description) VALUES (?, ?, ?)`, id, name, description) if err != nil { return nil, err } for _, trackID := range trackIDs { _, err = tx.Exec(`INSERT OR IGNORE INTO playlist_track (playlist_id, track_id) VALUES (?, ?)`, id, trackID) if err != nil { return nil, err } } if err := tx.Commit(); err != nil { return nil, err } return ps.Playlist(id) } // Playlists returns all playlists without tracks func (ps *PlaylistStore) Playlists() ([]*playlist.Playlist, error) { query := ` SELECT p.id, p.name, p.description, COUNT(pt.track_id) as track_count FROM playlist p LEFT JOIN playlist_track pt ON p.id = pt.playlist_id GROUP BY p.id ` rows, err := ps.db.Query(query) if err != nil { return nil, err } defer rows.Close() playlists := make([]*playlist.Playlist, 0) for rows.Next() { var p playlist.Playlist if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.TrackCount); err != nil { return nil, err } p.Tracks = []*track.Track{} playlists = append(playlists, &p) } return playlists, nil } // Playlist returns a playlist with all its tracks func (ps *PlaylistStore) Playlist(id string) (*playlist.Playlist, error) { p := playlist.Playlist{Tracks: make([]*track.Track, 0)} err := ps.db.QueryRow(`SELECT id, name, description FROM playlist WHERE id = ?`, id). Scan(&p.ID, &p.Name, &p.Description) if err != nil { return nil, err } rows, err := ps.db.Query(` SELECT t.id, t.name, t.path, t.bitRate, t.duration FROM playlist_track pt JOIN tracks t ON pt.track_id = t.id WHERE pt.playlist_id = ? ORDER BY pt.position `, id) if err != nil { return nil, fmt.Errorf("failed to query playlist tracks: %w", err) } defer rows.Close() for rows.Next() { var t track.Track if err := rows.Scan(&t.ID, &t.Name, &t.Path, &t.BitRate, &t.Duration); err != nil { return nil, fmt.Errorf("failed to scan track: %w", err) } p.Tracks = append(p.Tracks, &t) } p.TrackCount = len(p.Tracks) return &p, nil } // IsPlaylistExists checks if playlist with provided name exists func (ps *PlaylistStore) IsPlaylistExists(name string) (bool, error) { var exists bool err := ps.db.QueryRow(` SELECT EXISTS( SELECT 1 FROM playlist WHERE name = ? ) `, name).Scan(&exists) if err != nil { return false, err } return exists, nil } // EditPlaylist updates playlist and its tracks func (ps *PlaylistStore) EditPlaylist(id, name, description string, trackIDs []string) error { tx, err := ps.db.Begin() if err != nil { return err } defer tx.Rollback() _, err = tx.Exec(`UPDATE playlist SET name = ?, description = ? WHERE id = ?`, name, description, id) if err != nil { return err } _, err = tx.Exec(`DELETE FROM playlist_track WHERE playlist_id = ?`, id) if err != nil { return err } for position, trackID := range trackIDs { _, err = tx.Exec( `INSERT OR IGNORE INTO playlist_track (playlist_id, track_id, position) VALUES (?, ?, ?)`, id, trackID, position, ) if err != nil { return err } } return tx.Commit() } // DeletePlaylist deletes playlist and its track associations func (ps *PlaylistStore) DeletePlaylist(id string) error { tx, err := ps.db.Begin() if err != nil { return err } defer tx.Rollback() _, err = tx.Exec(`DELETE FROM playlist_track WHERE playlist_id = ?`, id) if err != nil { return err } _, err = tx.Exec(`DELETE FROM playlist WHERE id = ?`, id) if err != nil { return err } return tx.Commit() } ================================================ FILE: internal/storage/sqlite/queue.go ================================================ package sqlite import ( "database/sql" "errors" "fmt" "sync" "github.com/cheatsnake/airstation/internal/track" ) type QueueStore struct { db *sql.DB mutex *sync.Mutex } func NewQueueStore(db *sql.DB, mutex *sync.Mutex) QueueStore { return QueueStore{ db: db, mutex: mutex, } } func (qs *QueueStore) Queue() ([]*track.Track, error) { qs.mutex.Lock() defer qs.mutex.Unlock() tracks := make([]*track.Track, 0, 10) query := ` SELECT t.id, t.name, t.path, t.duration, t.bitRate FROM tracks t JOIN queue q ON t.id = q.track_id ORDER BY q.id ASC` rows, err := qs.db.Query(query) if err != nil { return tracks, fmt.Errorf("failed to query tracks in queue: %w", err) } defer rows.Close() for rows.Next() { var track track.Track err := rows.Scan(&track.ID, &track.Name, &track.Path, &track.Duration, &track.BitRate) if err != nil { return tracks, fmt.Errorf("failed to scan track: %w", err) } tracks = append(tracks, &track) } if err = rows.Err(); err != nil { return tracks, fmt.Errorf("error iterating over rows: %w", err) } return tracks, nil } func (qs *QueueStore) AddToQueue(tracks []*track.Track) error { qs.mutex.Lock() defer qs.mutex.Unlock() query := ` INSERT INTO queue (track_id) VALUES (?) ON CONFLICT (track_id) DO NOTHING ` for _, track := range tracks { _, err := qs.db.Exec(query, track.ID) if err != nil { return fmt.Errorf("failed to add track to queue: %w", err) } } return nil } func (qs *QueueStore) RemoveFromQueue(trackIDs []string) error { qs.mutex.Lock() defer qs.mutex.Unlock() query := `DELETE FROM queue WHERE track_id = ?` for _, id := range trackIDs { _, err := qs.db.Exec(query, id) if err != nil { return fmt.Errorf("failed to remove track from queue: %w", err) } } return nil } func (qs *QueueStore) ReorderQueue(trackIDs []string) error { qs.mutex.Lock() defer qs.mutex.Unlock() _, err := qs.db.Exec(`DELETE FROM queue`) if err != nil { return fmt.Errorf("failed to clear queue: %w", err) } query := `INSERT INTO queue (track_id) VALUES (?)` for _, id := range trackIDs { _, err := qs.db.Exec(query, id) if err != nil { return fmt.Errorf("failed to reorder queue: %w", err) } } return nil } func (qs *QueueStore) CurrentAndNextTrack() (*track.Track, *track.Track, error) { qs.mutex.Lock() defer qs.mutex.Unlock() query := ` SELECT t.id, t.name, t.path, t.duration, t.bitRate FROM tracks t JOIN queue q ON t.id = q.track_id ORDER BY q.id ASC LIMIT 2` rows, err := qs.db.Query(query) if err != nil { return nil, nil, fmt.Errorf("failed to query first and second tracks: %w", err) } defer rows.Close() var firstTrack, secondTrack track.Track count := 0 for rows.Next() { if count == 0 { err := rows.Scan(&firstTrack.ID, &firstTrack.Name, &firstTrack.Path, &firstTrack.Duration, &firstTrack.BitRate) if err != nil { return nil, nil, fmt.Errorf("failed to scan first track: %w", err) } } else if count == 1 { err := rows.Scan(&secondTrack.ID, &secondTrack.Name, &secondTrack.Path, &secondTrack.Duration, &secondTrack.BitRate) if err != nil { return nil, nil, fmt.Errorf("failed to scan second track: %w", err) } } count++ } if err = rows.Err(); err != nil { return nil, nil, fmt.Errorf("error iterating over rows: %w", err) } if count == 0 { return nil, nil, nil } else if count == 1 { return &firstTrack, &firstTrack, nil } return &firstTrack, &secondTrack, nil } func (qs *QueueStore) SpinQueue() error { qs.mutex.Lock() defer qs.mutex.Unlock() tx, err := qs.db.Begin() if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() var firstTrackID string var firstTrackQueueID int query := `SELECT id, track_id FROM queue ORDER BY id ASC LIMIT 1` err = tx.QueryRow(query).Scan(&firstTrackQueueID, &firstTrackID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil // Queue is empty } return fmt.Errorf("failed to get first track: %w", err) } var maxID int err = tx.QueryRow(`SELECT MAX(id) FROM queue`).Scan(&maxID) if err != nil { return fmt.Errorf("failed to get max ID: %w", err) } query = `UPDATE queue SET id = ? WHERE id = ?` _, err = tx.Exec(query, maxID+1, firstTrackQueueID) if err != nil { return fmt.Errorf("failed to update first track ID: %w", err) } err = tx.Commit() if err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } ================================================ FILE: internal/storage/sqlite/sqlite.go ================================================ package sqlite import ( "database/sql" "fmt" "log/slog" "sync" "github.com/cheatsnake/airstation/internal/storage/sqlite/migrations" _ "modernc.org/sqlite" ) type Instance struct { TrackStore QueueStore PlaybackStore PlaylistStore StationStore db *sql.DB log *slog.Logger mutex sync.Mutex } func New(dbPath string, log *slog.Logger) (*Instance, error) { db, err := sql.Open("sqlite", dbPath) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } _, err = db.Exec("PRAGMA foreign_keys = ON") if err != nil { return nil, fmt.Errorf("failed to enable foreign keys: %w", err) } db.SetMaxOpenConns(1) _, _ = db.Exec("PRAGMA journal_mode = WAL") _, _ = db.Exec("PRAGMA synchronous = NORMAL") log.Info("Sqlite database connected") err = migrations.RunMigrations(db, log) if err != nil { return nil, fmt.Errorf("failed to run migrations: %w", err) } instance := &Instance{ db: db, log: log, } instance.TrackStore = NewTrackStore(db, &instance.mutex) instance.QueueStore = NewQueueStore(db, &instance.mutex) instance.PlaybackStore = NewPlaybackStore(db, &instance.mutex) instance.PlaylistStore = NewPlaylistStore(db, &instance.mutex) instance.StationStore = NewStationStore(db, &instance.mutex) return instance, nil } func (ins *Instance) Close() error { ins.mutex.Lock() if _, err := ins.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { ins.mutex.Unlock() return fmt.Errorf("failed to run wal checkpoint: %w", err) } err := ins.db.Close() ins.mutex.Unlock() return err } ================================================ FILE: internal/storage/sqlite/station.go ================================================ package sqlite import ( "database/sql" "errors" "sync" "github.com/cheatsnake/airstation/internal/station" ) type StationStore struct { db *sql.DB mutex *sync.Mutex } func NewStationStore(db *sql.DB, mutex *sync.Mutex) StationStore { return StationStore{ db: db, mutex: mutex, } } func (ss *StationStore) StationProperties() ([]*station.Property, error) { query := ` SELECT key, value FROM station_properties ORDER BY key ` rows, err := ss.db.Query(query) if err != nil { return nil, err } defer rows.Close() var properties []*station.Property for rows.Next() { var prop station.Property if err := rows.Scan(&prop.Key, &prop.Value); err != nil { return nil, err } properties = append(properties, &prop) } if err = rows.Err(); err != nil { return nil, err } return properties, nil } func (ss *StationStore) UpsertStationProperty(key, value string) (*station.Property, error) { if key == "" { return nil, errors.New("key cannot be empty") } query := ` INSERT INTO station_properties (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = strftime('%s', 'now') ` _, err := ss.db.Exec(query, key, value) if err != nil { return nil, err } return &station.Property{Key: key, Value: value}, nil } func (ss *StationStore) DeleteStationProperty(key string) error { if key == "" { return errors.New("key cannot be empty") } query := "DELETE FROM station_properties WHERE key = ?" result, err := ss.db.Exec(query, key) if err != nil { return err } rowsAffected, err := result.RowsAffected() if err != nil { return err } if rowsAffected == 0 { return errors.New("property not found") } return nil } ================================================ FILE: internal/storage/sqlite/track.go ================================================ package sqlite import ( "database/sql" "errors" "fmt" "strings" "sync" sqltool "github.com/cheatsnake/airstation/internal/pkg/sql" "github.com/cheatsnake/airstation/internal/pkg/ulid" "github.com/cheatsnake/airstation/internal/track" ) type TrackStore struct { db *sql.DB mutex *sync.Mutex } func NewTrackStore(db *sql.DB, mutex *sync.Mutex) TrackStore { return TrackStore{ db: db, mutex: mutex, } } func (ts *TrackStore) Tracks(page, limit int, search, sortBy, sortOrder string) ([]*track.Track, int, error) { ts.mutex.Lock() defer ts.mutex.Unlock() countQuery := "SELECT COUNT(*) FROM tracks" if search != "" { countQuery += " WHERE LOWER(name) LIKE LOWER(?)" } var total int var err error tracks := make([]*track.Track, 0, limit) if search != "" { err = ts.db.QueryRow(countQuery, "%"+search+"%").Scan(&total) } else { err = ts.db.QueryRow(countQuery).Scan(&total) } if err != nil { return tracks, 0, fmt.Errorf("failed to get total track count: %w", err) } query := "SELECT id, name, path, duration, bitRate FROM tracks" if search != "" { query += " WHERE name LIKE ?" } query += fmt.Sprintf(" ORDER BY %s %s LIMIT ? OFFSET ?", sortBy, sortOrder) var rows *sql.Rows offset := (page - 1) * limit if search != "" { rows, err = ts.db.Query(query, "%"+strings.ToLower(search)+"%", limit, offset) } else { rows, err = ts.db.Query(query, limit, offset) } if err != nil { return tracks, 0, fmt.Errorf("failed to query tracks: %w", err) } defer rows.Close() for rows.Next() { var track track.Track err := rows.Scan(&track.ID, &track.Name, &track.Path, &track.Duration, &track.BitRate) if err != nil { return tracks, 0, fmt.Errorf("failed to scan track: %w", err) } tracks = append(tracks, &track) } if err = rows.Err(); err != nil { return tracks, 0, fmt.Errorf("error iterating over rows: %w", err) } return tracks, total, nil } func (ts *TrackStore) AddTrack(name, path string, duration float64, bitRate int) (*track.Track, error) { ts.mutex.Lock() defer ts.mutex.Unlock() id := ulid.New() track := &track.Track{ ID: id, Name: name, Path: path, Duration: duration, BitRate: bitRate, } query := `INSERT INTO tracks (id, name, path, duration, bitRate) VALUES (?, ?, ?, ?, ?)` _, err := ts.db.Exec(query, track.ID, track.Name, track.Path, track.Duration, track.BitRate) if err != nil { return nil, fmt.Errorf("failed to insert track: %w", err) } return track, nil } func (ts *TrackStore) DeleteTracks(IDs []string) error { ts.mutex.Lock() defer ts.mutex.Unlock() query := `DELETE FROM tracks WHERE id = ?` for _, id := range IDs { _, err := ts.db.Exec(query, id) if err != nil { return fmt.Errorf("failed to delete track with ID %s: %w", id, err) } } return nil } func (ts *TrackStore) EditTrack(track *track.Track) (*track.Track, error) { ts.mutex.Lock() defer ts.mutex.Unlock() query := ` UPDATE tracks SET name = ?, path = ?, duration = ?, bitRate = ? WHERE id = ?` _, err := ts.db.Exec(query, track.Name, track.Path, track.Duration, track.BitRate, track.ID) if err != nil { return nil, fmt.Errorf("failed to update track: %w", err) } return track, nil } func (ts *TrackStore) TrackByID(ID string) (*track.Track, error) { ts.mutex.Lock() defer ts.mutex.Unlock() query := `SELECT id, name, path, duration, bitRate FROM tracks WHERE id = ?` row := ts.db.QueryRow(query, ID) var track track.Track err := row.Scan(&track.ID, &track.Name, &track.Path, &track.Duration, &track.BitRate) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("track with ID %s not found", ID) } return nil, fmt.Errorf("failed to scan track: %w", err) } return &track, nil } func (ts *TrackStore) TracksByIDs(IDs []string) ([]*track.Track, error) { ts.mutex.Lock() defer ts.mutex.Unlock() tracks := make([]*track.Track, 0, len(IDs)) whereClause := sqltool.BuildInClause("id", len(IDs)) query := fmt.Sprintf("SELECT id, name, path, duration, bitRate FROM tracks WHERE %s", whereClause) args := make([]interface{}, len(IDs)) for i, id := range IDs { args[i] = id } rows, err := ts.db.Query(query, args...) if err != nil { return tracks, fmt.Errorf("failed to query tracks: %w", err) } defer rows.Close() for rows.Next() { var track track.Track err := rows.Scan(&track.ID, &track.Name, &track.Path, &track.Duration, &track.BitRate) if err != nil { return tracks, fmt.Errorf("failed to scan track: %w", err) } tracks = append(tracks, &track) } if err = rows.Err(); err != nil { return tracks, fmt.Errorf("error iterating over rows: %w", err) } return tracks, nil } ================================================ FILE: internal/storage/storage.go ================================================ package storage import ( "github.com/cheatsnake/airstation/internal/playback" "github.com/cheatsnake/airstation/internal/playlist" "github.com/cheatsnake/airstation/internal/queue" "github.com/cheatsnake/airstation/internal/station" "github.com/cheatsnake/airstation/internal/track" ) type Storage interface { track.Store queue.Store playback.Store playlist.Store station.Store Close() error } ================================================ FILE: internal/track/consts.go ================================================ package track import "github.com/cheatsnake/airstation/internal/pkg/hls" const ( minAllowedTrackDuration = hls.DefaultMaxSegmentDuration * hls.DefaultLiveSegmentsAmount maxAllowedTrackDuration = 36000 // 10 hours (just an adequate barrier) defaultAudioBitRate = 192 // best balance between quallity and size ) const ( m4aExtension = "m4a" mp3Extension = "mp3" aacExtension = "aac" wavExtension = "wav" flacExtension = "flac" ) ================================================ FILE: internal/track/service.go ================================================ // Package trackservice provides services related to audio track management. package track import ( "fmt" "log/slog" "math" "path/filepath" "strings" "github.com/cheatsnake/airstation/internal/pkg/ffmpeg" "github.com/cheatsnake/airstation/internal/pkg/fs" "github.com/cheatsnake/airstation/internal/pkg/hls" ) // Service provides audio processing functionalities by interacting with a database and the FFmpeg CLI. type Service struct { store Store // An instance of Storage for managing audio file storage. ffmpegCLI *ffmpeg.CLI // A pointer to the FFmpeg CLI wrapper for executing media processing commands. log *slog.Logger LoadedTracksNotify chan int // Notification of the number of loaded tracks } // New creates and returns a new instance of Service. // // Parameters: // - store: An implementation of TrackStore for managing audio file storage. // - ffmpegCLI: A pointer to the FFmpeg CLI wrapper for executing media processing commands. // // Returns: // - A pointer to an initialized Service instance. func NewService(store Store, ffmpegCLI *ffmpeg.CLI, log *slog.Logger) *Service { return &Service{ store: store, ffmpegCLI: ffmpegCLI, log: log, LoadedTracksNotify: make(chan int), } } // AddTrack adds a new audio track to the database, extracting metadata and modifying its duration if necessary. // // Parameters: // - name: The name to assign to the new track. // - path: The file path of the audio track to be added. // // Returns: // - A pointer to the newly added Track, or an error if any step in the process fails. func (s *Service) AddTrack(name, path string) (*Track, error) { metadata, err := s.ffmpegCLI.AudioMetadata(path) if err != nil { return nil, err } modDuration, err := s.modifyTrackDuration(path, metadata) if err != nil { return nil, err } if modDuration < minAllowedTrackDuration { return nil, fmt.Errorf("%s is too short for streaming", name) } if modDuration > maxAllowedTrackDuration { return nil, fmt.Errorf("%s is too long for streaming", name) } trackName := defineTrackName(name, metadata.Name) newTrack, err := s.store.AddTrack(trackName, path, modDuration, metadata.BitRate) if err != nil { return nil, err } return newTrack, nil } // PrepareTrack converts the audio file at filePath to AAC format with a fixed bitrate, // saving the output to a new file with an .m4a extension. // // Parameters: // - filePath: The full path of the original audio file. // // Returns: // - The path to the converted .m4a file, or an error if the conversion fails. func (s *Service) PrepareTrack(filePath string) (string, error) { newPath := replaceExtension(filePath, m4aExtension) err := s.ffmpegCLI.ConvertAudioToAAC(filePath, newPath, defaultAudioBitRate) if err != nil { return "", err } return newPath, nil } // Tracks retrieves a paginated list of tracks from the store, applying optional search, sort, and order. // // Parameters: // - page: The page number of results. // - limit: The number of results per page. // - search: A string to filter track names. // - sortBy: The field to sort by (id, name, or duration). // - sortOrder: The order of sorting (asc or desc). // // Returns: // - A TracksPage object with paginated track data, or an error. func (s *Service) Tracks(page, limit int, search, sortBy, sortOrder string) (*Page, error) { if sortBy != "id" && sortBy != "name" && sortBy != "duration" { sortBy = "id" } if sortOrder != "asc" { sortOrder = "desc" } tracks, total, err := s.store.Tracks(page, limit, search, sortBy, sortOrder) if err != nil { return nil, err } return &Page{ Tracks: tracks, Page: page, Limit: limit, Total: total, }, nil } // DeleteTracks deletes tracks from the database and also removes their files from disk. // // Parameters: // - ids: A slice of strings contains track IDs. // // Returns: // - An error if deletion fails. func (s *Service) DeleteTracks(ids []string) error { tracks, err := s.store.TracksByIDs(ids) if err != nil { return err } err = s.store.DeleteTracks(ids) if err != nil { return err } for _, t := range tracks { err := fs.DeleteFile(t.Path) if err != nil { s.log.Warn("Failed to delete track from disk: " + err.Error()) } } return err } // FindTracks fetches track records by their IDs. // // Parameters: // - ids: A slice of strings contains track IDs. // // Returns: // - A slice of Track pointers or an error. func (s *Service) FindTracks(ids []string) ([]*Track, error) { tracks, err := s.store.TracksByIDs(ids) return tracks, err } // MakeHLSPlaylist generates an HLS playlist for streaming using FFmpeg. // // Parameters: // - trackPath: The path of the audio track to segment. // - outDir: Output directory for the HLS segments and playlist. // - segName: Prefix for the segment files. // - segDuration: Duration of each HLS segment in seconds. // // Returns: // - An error if playlist generation fails. func (s *Service) MakeHLSPlaylist(trackPath string, outDir string, segName string, segDuration int) error { err := s.ffmpegCLI.MakeHLSPlaylist(trackPath, outDir, segName, segDuration) return err } // LoadTracksFromDisk scans a directory for audio files, converts them if needed, // adds them to the store, and deletes the original copies. // // Parameters: // - tracksDir: Directory path to load tracks from. // // Returns: // - A slice of loaded Track pointers, or an error. func (s *Service) LoadTracksFromDisk(tracksDir string) ([]*Track, error) { tracks := make([]*Track, 0) mp3Filenames, err := fs.ListFilesFromDir(tracksDir, mp3Extension) if err != nil { return tracks, err } aacFilenames, err := fs.ListFilesFromDir(tracksDir, aacExtension) if err != nil { return tracks, err } wavFilenames, err := fs.ListFilesFromDir(tracksDir, wavExtension) if err != nil { return tracks, err } flacFilenames, err := fs.ListFilesFromDir(tracksDir, flacExtension) if err != nil { return tracks, err } trackFilenames := make([]string, 0, len(mp3Filenames)+len(aacFilenames)+len(wavFilenames)+len(flacFilenames)) trackFilenames = append(trackFilenames, mp3Filenames...) trackFilenames = append(trackFilenames, aacFilenames...) trackFilenames = append(trackFilenames, wavFilenames...) trackFilenames = append(trackFilenames, flacFilenames...) for _, trackFilename := range trackFilenames { trackPath := filepath.Join(tracksDir, trackFilename) preparedTrackPath, err := s.PrepareTrack(trackPath) if err != nil { s.log.Warn("Failed to prepare a track for streaming: " + err.Error()) return tracks, err } track, err := s.AddTrack(trackFilename, preparedTrackPath) if err != nil { s.log.Warn("Failed to save track to database: " + err.Error()) return tracks, err } err = fs.DeleteFile(trackPath) if err != nil { s.log.Warn("Failed to delete original copy of prepared track: " + err.Error()) } tracks = append(tracks, track) } if len(tracks) > 0 { s.log.Info(fmt.Sprintf("Loaded %d new track(s) from disk.", len(tracks))) s.LoadedTracksNotify <- len(tracks) } return tracks, nil } // modifyTrackDuration changes the original track duration (slightly) to avoid small HLS segments. func (s *Service) modifyTrackDuration(path string, metadata ffmpeg.AudioMetadata) (float64, error) { roundDur := roundDuration(metadata.Duration, hls.DefaultMaxSegmentDuration) roundDur -= 0.001 // need to avoid extra ms after padding/trimming if err := s.ffmpegCLI.TrimAudio(path, roundDur); err != nil { return 0, err } return roundDur, nil } // roundDuration define proper track length to be multiple for segment duration. func roundDuration(trackDuration, segmentDuration float64) float64 { remainder := math.Mod(trackDuration, segmentDuration) // if the difference is not significant (less than 1.2 second), just crop it if remainder < 1.2 { return math.Floor(trackDuration - remainder) } // padding := segmentDuration - remainder // return math.Floor(trackDuration + padding) return math.Floor(trackDuration) } func defineTrackName(fileName, metaName string) string { if len(metaName) != 0 { return metaName } name := strings.ReplaceAll(fileName, ".mp3", "") name = strings.ReplaceAll(name, ".aac", "") name = strings.ReplaceAll(name, ".wav", "") name = strings.ReplaceAll(name, ".flac", "") name = strings.ReplaceAll(name, "_", " ") return name } func replaceExtension(path string, newExt string) string { if newExt != "" && !strings.HasPrefix(newExt, ".") { newExt = "." + newExt } ext := filepath.Ext(path) name := path[:len(path)-len(ext)] return name + newExt } ================================================ FILE: internal/track/types.go ================================================ package track // Track represents an audio track with its associated metadata. type Track struct { ID string `json:"id"` // A unique identifier for the track, typically generated using ULID. Name string `json:"name"` // The name of the audio track. Path string `json:"path"` // The file path of the audio track. Duration float64 `json:"duration"` // The duration of the audio track in seconds. BitRate int `json:"bitRate"` // The bit rate of the audio track in kilobits per second (kbps). } type Store interface { Tracks(page, limit int, search, sortBy, sortOrder string) ([]*Track, int, error) TrackByID(ID string) (*Track, error) TracksByIDs(IDs []string) ([]*Track, error) AddTrack(name, path string, duration float64, bitRate int) (*Track, error) DeleteTracks(IDs []string) error EditTrack(track *Track) (*Track, error) } // Page represents a paginated response containing a list of audio tracks. type Page struct { Tracks []*Track `json:"tracks"` // A slice of Track pointers returned for the current page. Page int `json:"page"` // The current page number in the pagination result. Limit int `json:"limit"` // The maximum number of tracks per page. Total int `json:"total"` // The total number of tracks matching the query. } type BodyWithIDs struct { IDs []string `json:"ids"` } ================================================ FILE: web/player/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr dev-dist *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: web/player/.prettierrc ================================================ { "trailingComma": "all", "tabWidth": 4, "semi": true, "singleQuote": false, "printWidth": 120 } ================================================ FILE: web/player/README.md ================================================ # Airstation Player ================================================ FILE: web/player/index.html ================================================ %AIRSTATION_PLAYER_TITLE%
================================================ FILE: web/player/package.json ================================================ { "name": "player", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview" }, "dependencies": { "hls.js": "^1.6.15", "solid-js": "^1.9.12" }, "devDependencies": { "@types/node": "^22.15.29", "typescript": "^6.0.2", "vite": "^8.0.3", "vite-plugin-pwa": "^1.2.0", "vite-plugin-solid": "^2.11.11" }, "overrides": { "vite-plugin-pwa": { "vite": "$vite" } } } ================================================ FILE: web/player/src/App.tsx ================================================ import { Page } from "./page"; const App = () => { return ; }; export default App; ================================================ FILE: web/player/src/api/index.ts ================================================ import { PlaybackHistory, PlaybackState, ResponseErr, StationInfo } from "./types"; import { queryParams } from "./utils"; export const API_HOST = ""; export const API_PREFIX = "/api/v1"; class AirstationAPI { private host: string; private prefix: string; private url: () => string; constructor(host: string, prefix: string) { this.host = host; this.prefix = prefix; this.url = () => `${this.host + this.prefix}`; } async getPlayback() { const url = `${this.url()}/playback`; return await this.makeRequest(url); } async getPlaybackHistory(limit?: number) { let url = `${this.url()}/playback/history`; if (limit) url += `?${queryParams({ limit })}`; return await this.makeRequest(url); } async getStationInfo() { const url = `${this.url()}/station/info`; return await this.makeRequest(url); } private async makeRequest(url: string, params: RequestInit = {}): Promise { const resp = await fetch(url, params); if (!resp.ok) { const body: ResponseErr = await resp.json(); throw new Error(body.message); } return resp.json(); } } export const airstationAPI = new AirstationAPI(API_HOST, API_PREFIX); ================================================ FILE: web/player/src/api/types.ts ================================================ export interface Track { id: string; name: string; path: string; duration: number; bitRate: number; } export interface PlaybackState { currentTrack: Track | null; currentTrackElapsed: number; isPlaying: boolean; } export interface PlaybackHistory { id: number; playedAt: number; trackName: string; } export interface StationInfo { name: string; description: string; faviconURL: string; logoURL: string; location: string; timezone: string; links: string; theme: string; } export interface ResponseErr { message: string; } export interface ResponseOK { message: string; } ================================================ FILE: web/player/src/api/utils.ts ================================================ export const jsonRequestParams = (method: string, body: Record) => { return { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }; }; export const queryParams = (params: Record) => { removeEmptyFields(params); return new URLSearchParams(params).toString(); }; const removeEmptyFields = (obj: Record) => { for (const key in obj) { if (obj.hasOwnProperty(key) && [undefined, null, ""].includes(obj[key])) { delete obj[key]; } } }; ================================================ FILE: web/player/src/const.ts ================================================ export const DESKTOP_WIDTH = 1100; export const MAX_HISTORY_LIMIT = 500; ================================================ FILE: web/player/src/index.css ================================================ :root { --bg-gradient-start: #29323c; --bg-gradient-end: #485563; --bg-icon: #a8a8a8; --text-color: #ffffff; --text-dimmed-color: color-mix(in srgb, var(--text-color) 70%, transparent); --sidebar-color: color-mix(in srgb, var(--bg-gradient-start) 80%, black 20%); --scrollbar-color: color-mix(in srgb, var(--bg-gradient-end) 90%, black 10%); } * { font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; margin: 0; padding: 0; box-sizing: border-box; color: var(--text-color); } ::-webkit-scrollbar { width: 8px; } ::-webkit-scrollbar-track { background: color-mix(in srgb, var(--bg-gradient-start) 80%, black 20%); } ::-webkit-scrollbar-thumb { background: var(--scrollbar-color); } ::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-color); } body { background: var(--bg-gradient-end); background: -webkit-linear-gradient(to top, var(--bg-gradient-start), var(--bg-gradient-end)); background: linear-gradient(to top, var(--bg-gradient-start), var(--bg-gradient-end)); background-size: cover; background-position: center; background-repeat: no-repeat; overflow: hidden; } .empty_icon { width: 24px; height: 24px; } ================================================ FILE: web/player/src/index.tsx ================================================ /* @refresh reload */ import { render } from 'solid-js/web' import './index.css' import App from './App.tsx' const root = document.getElementById('root') render(() => , root!) ================================================ FILE: web/player/src/page/CurrentTrack.module.css ================================================ .box { width: 100%; } .label, .offline_label { display: flex; justify-content: center; align-items: center; gap: 0.5rem; font-size: 0.9rem; cursor: pointer; padding: 1rem 0.5rem; } .offline_label { user-select: none; cursor: default; color: var(--text-dimmed-color); } .offline_label_icon { width: 10px; height: 10px; border-radius: 50%; background: rgb(240, 78, 78); } ================================================ FILE: web/player/src/page/CurrentTrack.tsx ================================================ import { onMount, Show } from "solid-js"; import { airstationAPI } from "../api"; import styles from "./CurrentTrack.module.css"; import { addEventListener, EVENTS } from "../store/events"; import { setTrackStore, trackStore } from "../store/track"; import { addHistory } from "../store/history"; import { getUnixTime } from "../utils/date"; export const CurrentTrack = () => { onMount(async () => { try { const cs = await airstationAPI.getPlayback(); if (cs.isPlaying && cs.currentTrack) setTrackStore("trackName", cs.currentTrack.name); } catch (error) { console.log(error); } addEventListener(EVENTS.newTrack, (e: MessageEvent) => { const unixTime = getUnixTime(); setTrackStore("trackName", e.data); addHistory({ id: unixTime, playedAt: unixTime, trackName: e.data }); }); }); const copyToClipboard = async () => { try { await navigator.clipboard.writeText(trackStore.trackName); } catch (error) { console.log(error); } }; return (
0} fallback={}>
{trackStore.trackName}
); }; const OfflineLabel = () => { return (
Stream offline
); }; ================================================ FILE: web/player/src/page/History.module.css ================================================ .history_icon { width: 24px; height: 24px; cursor: pointer; background-color: var(--bg-icon); -webkit-mask: url('data:image/svg+xml;utf8,'); mask: -webkit-mask; } .history_menu { position: fixed; top: 0; left: -500px; width: 100%; max-width: 500px; height: 100vh; padding: 0.5rem; transition: left 0.3s ease; overflow-y: auto; z-index: 10; } .history_open { left: 0; } .history { display: flex; flex-direction: column; gap: 1rem; } .history_item { font-size: 0.9rem; cursor: pointer; } .history_timestamp { color: var(--text-dimmed-color); } .load_more_btn { background: transparent; border: none; cursor: pointer; color: var(--text-dimmed-color); } .load_more_btn:hover { opacity: 1; } ================================================ FILE: web/player/src/page/History.tsx ================================================ import { Accessor, Component, createSignal, onMount } from "solid-js"; import styles from "./History.module.css"; import pageStyles from "./Page.module.css"; import { airstationAPI } from "../api"; import { formatDateToTimeFirst } from "../utils/date"; import { history, setHistory } from "../store/history"; import { DESKTOP_WIDTH, MAX_HISTORY_LIMIT } from "../const"; export const History = () => { const [isOpen, setIsOpen] = createSignal(false); const open = () => setIsOpen(true); const close = () => setIsOpen(false); return ( <>
); }; const Menu: Component<{ isOpen: Accessor; close: () => void }> = ({ isOpen, close }) => { const [hideLoadMore, setHideLoadMore] = createSignal(false); const loadHistory = async (limit?: number) => { try { const h = await airstationAPI.getPlaybackHistory(limit); setHistory(h); } catch (error) { console.log(error); } }; const loadMoreHistory = () => { loadHistory(MAX_HISTORY_LIMIT); setHideLoadMore(true); }; const copyToClipboard = async (text: string) => { try { await navigator.clipboard.writeText(text); } catch (error) { console.log(error); } }; onMount(() => { loadHistory(); }); return (
DESKTOP_WIDTH ? pageStyles.menu_desktop : pageStyles.menu_mobile }`} >
{history().map((h) => (
copyToClipboard(h.trackName)}>
{h.trackName}
{formatDateToTimeFirst(new Date(h.playedAt * 1000))}
))} {hideLoadMore() ? null : ( )}
); }; ================================================ FILE: web/player/src/page/ListenersCounter.module.css ================================================ .counter { color: var(--bg-gradient-end); font-size: 0.9rem; } .box { background-color: var(--bg-icon); display: flex; gap: 0.3rem; align-items: center; justify-content: center; padding: 0.05rem 0.45rem; border-radius: 0.25rem; } .icon { width: 16px; height: 16px; background-color: var(--bg-gradient-end); -webkit-mask: url('data:image/svg+xml;utf8,'); mask: -webkit-mask; } .number { color: var(--bg-gradient-end); user-select: none; line-height: 0rem; font-weight: 700; font-size: 1rem; } ================================================ FILE: web/player/src/page/ListenersCounter.tsx ================================================ import { createSignal, onMount } from "solid-js"; import { addEventListener, EVENTS } from "../store/events"; import styles from "./ListenersCounter.module.css"; export const ListenersCounter = () => { const [count, setCount] = createSignal(0); onMount(() => { addEventListener(EVENTS.countListeners, (e: MessageEvent) => { setCount(+e.data); }); }); return (
{!count() ? "" : count()}
); }; ================================================ FILE: web/player/src/page/Page.module.css ================================================ .page { display: flex; flex-direction: column; height: 100vh; } .header { display: flex; align-items: center; justify-content: space-between; padding: 0.25rem 0.5rem; } .menu_mobile { background-color: var(--sidebar-color); } .menu_desktop { background-color: color-mix(in srgb, var(--sidebar-color) 60%, transparent); } .menu_header { width: 100%; display: flex; justify-content: flex-end; } .menu_title { font-size: 1rem; user-select: none; } .close_icon { width: 18px; height: 18px; cursor: pointer; background-color: var(--text-color); -webkit-mask: url('data:image/svg+xml;utf8,'); mask: -webkit-mask; } ================================================ FILE: web/player/src/page/RadioButton.module.css ================================================ video { display: none; } .container { flex: 1; } .box { width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; } .pause_icon { margin: 0px auto 0; width: 74px; height: 74px; box-sizing: border-box; border-color: transparent transparent transparent var(--text-color); background-color: var(--text-color); border-radius: 0.2rem; transition: transform 0.05s ease-out, background-color 0.3s, box-shadow 0.3s; display: flex; align-items: center; justify-content: center; overflow: hidden; } .play_icon { width: 74px; height: 74px; box-sizing: border-box; border-style: solid; border-width: 36px 0px 36px 74px; border-color: transparent transparent transparent var(--text-color); transition: all 200ms ease-in-out; border-radius: 0.2rem; } .pause_icon, .play_icon { cursor: pointer; user-select: none; } ================================================ FILE: web/player/src/page/RadioButton.tsx ================================================ import HLS from "hls.js"; import styles from "./RadioButton.module.css"; import { setTrackStore, trackStore } from "../store/track"; import { Component, onCleanup, onMount } from "solid-js"; import { addEventListener, EVENTS } from "../store/events"; import { getUnixTime } from "../utils/date"; import { addHistory } from "../store/history"; import { getCssVariable } from "../utils/document"; import { getHueFromHex } from "../utils/color"; const STREAM_SOURCE = "/stream"; export const RadioButton = () => { let videoRef: HTMLAudioElement | undefined; let hls: HLS | undefined; const initStream = () => { if (!trackStore.isPlay && HLS.isSupported()) { hls = new HLS(); hls.loadSource(STREAM_SOURCE); hls.attachMedia(videoRef as unknown as HTMLMediaElement); } }; const handlePlay = () => { initStream(); if (!trackStore.trackName) return; setTrackStore("isPlay", true); }; const handlePause = () => { setTrackStore("isPlay", false); hls?.destroy(); }; onMount(() => { addEventListener(EVENTS.pause, (_e: MessageEvent) => { setTrackStore("trackName", ""); (() => videoRef?.pause())(); }); addEventListener(EVENTS.play, (e: MessageEvent) => { const unixTime = getUnixTime(); setTrackStore("trackName", e.data); addHistory({ id: unixTime, playedAt: unixTime, trackName: e.data }); if (trackStore.isPlay) (() => videoRef?.pause())(); (() => videoRef?.play())(); }); document.body.addEventListener("keydown", (event) => { if (event.key === " ") { event.preventDefault(); trackStore.isPlay ? videoRef?.pause() : videoRef?.play(); } }); }); return (
{trackStore.isPlay ? ( videoRef?.pause()} media={videoRef} /> ) : (
videoRef?.play()}>
)}
); }; let audioSource: MediaElementAudioSourceNode | null = null; let audioContext: AudioContext | null = null; const AnimatedPauseButton: Component<{ pause: () => void; media?: HTMLAudioElement }> = (props) => { let pauseIconRef: HTMLDivElement | undefined; let analyser: AnalyserNode | null = null; let dataArray: Uint8Array | null = null; let animationId: number | null = null; let gainNode: GainNode | null = null; let accentHue: number | null = null; let currentHue = 0; let currentSaturation = 50; let currentLightness = 60; const loadAccentColor = () => { const accentColor = getCssVariable("--accent-color"); accentHue = accentColor ? getHueFromHex(accentColor) : null; currentHue = accentHue !== null ? accentHue : 0; currentSaturation = accentHue !== null ? 100 : 50; }; onMount(async () => { loadAccentColor(); setInterval(loadAccentColor, 1000); // Need for hot reload if (!pauseIconRef || !props.media) return; await initAudio(); draw(); }); onCleanup(async () => { if (animationId !== null) { cancelAnimationFrame(animationId); animationId = null; } if (gainNode) { gainNode.disconnect(); gainNode = null; } if (analyser) { analyser.disconnect(); analyser = null; } dataArray = null; if (pauseIconRef) { pauseIconRef.style.transform = "scale(1)"; pauseIconRef.style.backgroundColor = "white"; pauseIconRef.style.boxShadow = "none"; } }); const initAudio = async () => { try { if (!props.media) return; if (!audioContext) audioContext = new window.AudioContext(); analyser = audioContext.createAnalyser(); analyser.fftSize = 256; gainNode = audioContext.createGain(); gainNode.gain.value = 1; if (!audioSource) audioSource = audioContext.createMediaElementSource(props.media); audioSource.connect(gainNode); gainNode.connect(analyser); analyser.connect(audioContext.destination); const bufferLength = analyser.frequencyBinCount; dataArray = new Uint8Array(bufferLength); } catch (err) { console.error("Error initializing audio:", err); } }; const draw = () => { if (!pauseIconRef || !analyser || !dataArray) return; animationId = requestAnimationFrame(draw); analyser.getByteFrequencyData(dataArray as Uint8Array); let bass = 0; let treble = 0; const bassEnd = Math.floor(dataArray.length * 0.3); const trebleStart = Math.floor(dataArray.length * 0.6); for (let i = 0; i < dataArray.length; i++) { if (i < bassEnd) bass += dataArray[i]; else if (i > trebleStart) treble += dataArray[i]; } bass /= bassEnd; treble /= dataArray.length - trebleStart; const scale = 1 + bass / 300; const jump = (bass / 300) * 20; pauseIconRef.style.transform = `translateY(${-jump}px) scale(${scale})`; const bassImpact = bass / 255; const trebleImpact = treble / 255; if (accentHue == null) { currentHue += (Math.random() - 0.5) * bassImpact * 120; currentHue += trebleImpact * 2; currentHue = (currentHue + 360) % 360; } const color = `hsl(${currentHue}, ${currentSaturation}%, ${currentLightness}%)`; pauseIconRef.style.backgroundColor = color; const glowIntensity = bass / 2 + 20; pauseIconRef.style.boxShadow = `0 0 ${glowIntensity}px ${color}`; }; return
; }; ================================================ FILE: web/player/src/page/StationInformation.module.css ================================================ .info_menu { position: fixed; top: 0; right: -500px; width: 100%; max-width: 500px; height: 100vh; transition: right 0.3s ease; overflow-y: hidden; z-index: 10; } .info_icon { background-color: var(--bg-icon); -webkit-mask: url('data:image/svg+xml;utf8,'); mask: -webkit-mask; width: 24px; height: 24px; cursor: pointer; } .info_open { right: 0; } .header { position: absolute; padding: 0.5rem; display: flex; align-items: center; } .content { padding: 1rem 0.5rem; } .logo { max-width: 100%; object-fit: contain; display: block; margin-left: auto; margin-right: auto; } .title { margin: 0; font-size: 1.3rem; font-weight: 600; text-align: center; } .metadata { margin-top: 0.25rem; display: flex; justify-content: center; gap: 0.5rem; } .location, .timezone { color: var(--text-dimmed-color); } .description { margin-top: 1rem; } .footer { margin-top: 1rem; display: flex; gap: 0.25rem; align-items: center; flex-direction: column; justify-content: space-between; } .footer a { color: var(--text-dimmed-color); text-decoration: none; transition: 0.2s all; } .footer a:hover { text-decoration: underline; } ================================================ FILE: web/player/src/page/StationInformation.tsx ================================================ import { Accessor, Component, createSignal, onMount } from "solid-js"; import pageStyles from "./Page.module.css"; import styles from "./StationInformation.module.css"; import { airstationAPI } from "../api"; import { DESKTOP_WIDTH } from "../const"; import { StationInfo } from "../api/types"; import { isValidURL } from "../utils/url"; import { isValidHexColor } from "../utils/color"; import { setCssVariable, setFavicon, setPageTitle } from "../utils/document"; import { addEventListener, EVENTS } from "../store/events"; export const StationInformation = () => { const [isOpen, setIsOpen] = createSignal(false); const open = () => setIsOpen(true); const close = () => setIsOpen(false); return ( <>
); }; const parseLinks = (rawLinks: string): { title: string; url: string }[] => { const regex = /\[([^\]]+)]\((https?:\/\/[^\s)]+)\)/g; return Array.from(rawLinks.matchAll(regex), (m) => ({ title: m[1], url: m[2], })); }; const parseTheme = (rawTheme: string) => { const [bgStart, bgEnd, bgIcon, text, accent, bgImage] = rawTheme.split(";"); if (bgStart && isValidHexColor(bgStart)) setCssVariable("--bg-gradient-start", bgStart); if (bgEnd && isValidHexColor(bgEnd)) setCssVariable("--bg-gradient-end", bgEnd); if (bgIcon && isValidHexColor(bgIcon)) setCssVariable("--bg-icon", bgIcon); if (text && isValidHexColor(text)) setCssVariable("--text-color", text); if (accent && isValidHexColor(accent)) { setCssVariable("--accent-color", accent); } else { setCssVariable("--accent-color", ""); } if (bgImage && isValidURL(bgImage)) { document.body.style.backgroundImage = `url(${bgImage})`; } else { document.body.style.backgroundImage = ""; } }; const Card: Component<{ isOpen: Accessor; close: () => void }> = ({ isOpen, close }) => { const [info, setInfo] = createSignal(null); const loadInfo = async () => { try { const h = await airstationAPI.getStationInfo(); setInfo(h); if (h.name) setPageTitle(h.name); if (isValidURL(h.faviconURL)) setFavicon(h.faviconURL); if (h.theme) parseTheme(h.theme); } catch (error) { console.log(error); } }; onMount(() => { loadInfo(); addEventListener(EVENTS.changeTheme, (_e: MessageEvent) => { loadInfo(); }); }); return (
DESKTOP_WIDTH ? pageStyles.menu_desktop : pageStyles.menu_mobile }`} >
{info()?.logoURL && {info?.name}}
{info()?.name}
{info()?.location && {info()!.location}} {info()?.timezone && {info()!.timezone}}
{info()?.links && (
{parseLinks(info()?.links!).map((link) => ( {link.title} ))}
)}
); }; ================================================ FILE: web/player/src/page/index.tsx ================================================ import { onMount, onCleanup } from "solid-js"; import { CurrentTrack } from "./CurrentTrack"; import { ListenersCounter } from "./ListenersCounter"; import { RadioButton } from "./RadioButton"; import { closeEventSource, initEventSource } from "../store/events"; import { History } from "./History"; import styles from "./Page.module.css"; import { StationInformation } from "./StationInformation"; export const Page = () => { onMount(() => { initEventSource(); }); onCleanup(() => { closeEventSource(); }); return (
); }; ================================================ FILE: web/player/src/store/events.ts ================================================ import { createStore } from "solid-js/store"; import { API_HOST, API_PREFIX } from "../api"; export const EVENT_SOURCE_URL = API_HOST + API_PREFIX + "/events"; export const EVENTS = { newTrack: "new_track", changeTheme: "change_theme", countListeners: "count_listeners", pause: "pause", play: "play", }; const [eventSourceStore, setEventSourceStore] = createStore<{ eventSource: EventSource | null }>({ eventSource: null, }); export const initEventSource = () => { if (eventSourceStore.eventSource) eventSourceStore.eventSource.close(); const es = new EventSource(EVENT_SOURCE_URL); setEventSourceStore("eventSource", es); }; export const addEventListener = (event: string, listener: (event: MessageEvent) => void) => { if (eventSourceStore.eventSource) { eventSourceStore.eventSource.addEventListener(event, listener); } }; export const closeEventSource = () => { if (eventSourceStore.eventSource) { eventSourceStore.eventSource.close(); setEventSourceStore("eventSource", null); } }; ================================================ FILE: web/player/src/store/history.ts ================================================ import { createSignal } from "solid-js"; import { PlaybackHistory } from "../api/types"; export const [history, setHistory] = createSignal([]); export const addHistory = (h: PlaybackHistory) => { setHistory([h, ...history()]); }; ================================================ FILE: web/player/src/store/track.ts ================================================ import { createStore } from "solid-js/store"; export const [trackStore, setTrackStore] = createStore({ trackName: "", isPlay: false, }); ================================================ FILE: web/player/src/utils/color.ts ================================================ export function getHueFromHex(hex: string) { hex = hex.replace("#", ""); const r = parseInt(hex.slice(0, 2), 16) / 255; const g = parseInt(hex.slice(2, 4), 16) / 255; const b = parseInt(hex.slice(4, 6), 16) / 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const d = max - min; if (d === 0) return 0; let h; switch (max) { case r: h = ((g - b) / d) % 6; break; case g: h = (b - r) / d + 2; break; default: h = (r - g) / d + 4; } return Math.round(h * 60 < 0 ? h * 60 + 360 : h * 60); } export function isValidHexColor(hex: string) { return /^#[0-9a-f]{6}$/.test(hex); } ================================================ FILE: web/player/src/utils/date.ts ================================================ export const formatDateToTimeFirst = (date: Date) => { const timeParts = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }).formatToParts(date); const dateParts = new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "2-digit", year: "numeric", }).formatToParts(date); const time = timeParts.map((p) => p.value).join(""); const dateStr = dateParts.map((p) => p.value).join(""); return `${time} ${dateStr}`; }; export const getUnixTime = (): number => Math.floor(Date.now() / 1000); ================================================ FILE: web/player/src/utils/document.ts ================================================ export const setFavicon = (url: string) => { let link = document.querySelector("link[rel*='icon']") || document.createElement("link"); link.type = "image/png"; link.rel = "icon"; link.href = url; document.getElementsByTagName("head")[0].appendChild(link); }; export const setPageTitle = (title: string) => { document.title = title; }; export const getCssVariable = (name: string): string => { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }; export const setCssVariable = (name: string, value: string): void => { document.documentElement.style.setProperty(name, value); }; ================================================ FILE: web/player/src/utils/url.ts ================================================ export const isValidURL = (str: string) => { try { const url = new URL(str); return url.protocol === "http:" || url.protocol === "https:"; } catch (err) { return false; } }; ================================================ FILE: web/player/src/vite-env.d.ts ================================================ /// ================================================ FILE: web/player/tsconfig.app.json ================================================ { "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, "jsx": "preserve", "jsxImportSource": "solid-js", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, "include": ["src"] } ================================================ FILE: web/player/tsconfig.json ================================================ { "files": [], "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" } ] } ================================================ FILE: web/player/tsconfig.node.json ================================================ { "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "target": "ES2022", "lib": ["ES2023"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: web/player/vite.config.ts ================================================ import { defineConfig, loadEnv } from "vite"; import solid from "vite-plugin-solid"; import { VitePWA } from "vite-plugin-pwa"; import path from "path"; export default defineConfig(({ mode }) => { const globalEnv = loadEnv(mode, path.join(process.cwd(), "..", ".."), ""); const localEnv = loadEnv(mode, process.cwd(), ""); const appTitle = globalEnv.AIRSTATION_PLAYER_TITLE || localEnv.AIRSTATION_PLAYER_TITLE || "Radio"; return { plugins: [ solid(), VitePWA({ scope: "/", registerType: "autoUpdate", workbox: { cleanupOutdatedCaches: true, navigateFallback: "/index.html", navigateFallbackDenylist: [/^\/studio\//], }, devOptions: { enabled: true, }, manifest: { scope: "/", start_url: "/", lang: "en", name: "Radio", short_name: "Radio", icons: [ { src: "icon48.png", sizes: "48x48", type: "image/png", purpose: "maskable any", }, { src: "icon72.png", sizes: "72x72", type: "image/png", purpose: "maskable any", }, { src: "icon96.png", sizes: "96x96", type: "image/png", purpose: "maskable any", }, { src: "icon128.png", sizes: "128x128", type: "image/png", purpose: "maskable any", }, { src: "icon144.png", sizes: "144x144", type: "image/png", purpose: "maskable any", }, { src: "icon152.png", sizes: "152x152", type: "image/png", purpose: "maskable any", }, { src: "icon192.png", sizes: "192x192", type: "image/png", purpose: "maskable any", }, { src: "icon256.png", sizes: "256x256", type: "image/png", purpose: "maskable any", }, { src: "icon512.png", sizes: "512x512", type: "image/png", purpose: "maskable any", }, ], }, }), ], server: { proxy: { "/api": { target: "http://localhost:7331", changeOrigin: true }, "/stream": { target: "http://localhost:7331", changeOrigin: true }, "/static": { target: "http://localhost:7331", changeOrigin: true }, }, }, envPrefix: "AIRSTATION_PLAYER_", define: { "import.meta.env.AIRSTATION_PLAYER_TITLE": JSON.stringify(appTitle), }, }; }); ================================================ FILE: web/studio/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr dev-dist *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: web/studio/.prettierrc ================================================ { "trailingComma": "all", "tabWidth": 4, "semi": true, "singleQuote": false, "printWidth": 120 } ================================================ FILE: web/studio/README.md ================================================ # Airstation Studio ================================================ FILE: web/studio/index.html ================================================ Airstation Studio
================================================ FILE: web/studio/package.json ================================================ { "name": "studio", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview" }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@mantine/core": "^9.0.0", "@mantine/form": "^9.0.0", "@mantine/hooks": "^9.0.0", "@mantine/modals": "^9.0.0", "@mantine/notifications": "^9.0.0", "hls.js": "^1.6.15", "react": "^19.2.4", "react-dom": "^19.2.4", "zustand": "^5.0.12" }, "devDependencies": { "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "globals": "^17.4.0", "typescript": "~6.0.2", "vite": "^8.0.3", "vite-plugin-pwa": "^1.2.0" }, "overrides": { "vite-plugin-pwa": { "vite": "$vite" } } } ================================================ FILE: web/studio/src/App.tsx ================================================ import { MantineProvider } from "@mantine/core"; import { ModalsProvider } from "@mantine/modals"; import { Notifications } from "@mantine/notifications"; import { AuthGuard } from "./components/AuthGuard"; import { theme } from "./theme"; import { Page } from "./page"; const App = () => { return ( ); }; export default App; ================================================ FILE: web/studio/src/api/index.ts ================================================ import { PlaybackState, Playlist, ResponseErr, ResponseOK, StationInfo, Track, TracksPage } from "./types"; import { jsonRequestParams, queryParams } from "./utils"; export const API_HOST = ""; export const API_PREFIX = "/api/v1"; class AirstationAPI { private host: string; private prefix: string; private url: () => string; constructor(host: string, prefix: string) { this.host = host; this.prefix = prefix; this.url = () => `${this.host + this.prefix}`; } async login(secret: string) { const url = `${this.url()}/login`; return await this.makeRequest(url, jsonRequestParams("POST", { secret })); } async getPlayback() { const url = `${this.url()}/playback`; return await this.makeRequest(url); } async pausePlayback() { const url = `${this.url()}/playback/pause`; return await this.makeRequest(url, jsonRequestParams("POST", {})); } async playPlayback() { const url = `${this.url()}/playback/play`; return await this.makeRequest(url, jsonRequestParams("POST", {})); } async getTracks(page: number, limit: number, search: string, sortBy: keyof Track, sortOrder: "asc" | "desc") { const url = `${this.url()}/tracks?${queryParams({ page, limit, search, sort_by: sortBy, sort_order: sortOrder, })}`; return await this.makeRequest(url); } async uploadTracks(files: File[]) { const url = `${this.url()}/tracks`; const formData = new FormData(); for (let i = 0; i < files.length; i++) { formData.append("tracks", files[i]); } return await this.makeRequest(url, { method: "POST", body: formData, }); } async deleteTracks(ids: string[]) { const url = `${this.url()}/tracks`; return await this.makeRequest(url, jsonRequestParams("DELETE", { ids })); } async getQueue() { const url = `${this.url()}/queue`; return await this.makeRequest(url); } async addToQueue(trackIDs: string[]) { const url = `${this.url()}/queue`; return await this.makeRequest(url, jsonRequestParams("POST", { ids: trackIDs })); } async updateQueue(trackIDs: string[]) { const url = `${this.url()}/queue`; return await this.makeRequest(url, jsonRequestParams("PUT", { ids: trackIDs })); } async removeFromQueue(trackIDs: string[]) { const url = `${this.url()}/queue`; return await this.makeRequest(url, jsonRequestParams("DELETE", { ids: trackIDs })); } async addPlaylist(name: string, trackIDs: string[], description?: string) { const url = `${this.url()}/playlist`; return await this.makeRequest(url, jsonRequestParams("POST", { name, description, trackIDs })); } async getPlaylists() { const url = `${this.url()}/playlists`; return await this.makeRequest(url); } async getPlaylist(id: string) { const url = `${this.url()}/playlist/` + id; return await this.makeRequest(url); } async editPlaylist(id: string, name: string, trackIDs: string[], description?: string) { const url = `${this.url()}/playlist/` + id; return await this.makeRequest(url, jsonRequestParams("PUT", { name, description, trackIDs })); } async deletePlaylist(id: string) { const url = `${this.url()}/playlist/` + id; return await this.makeRequest(url, jsonRequestParams("DELETE", {})); } async getStationInfo() { const url = `${this.url()}/station/info`; return await this.makeRequest(url); } async editStationInfo(info: StationInfo) { const url = `${this.url()}/station/info`; return await this.makeRequest(url, jsonRequestParams("PUT", info)); } private async makeRequest(url: string, params: RequestInit = {}): Promise { const resp = await fetch(url, params); if (!resp.ok) { const body: ResponseErr = await resp.json(); throw new Error(body.message); } return resp.json(); } } export const airstationAPI = new AirstationAPI(API_HOST, API_PREFIX); ================================================ FILE: web/studio/src/api/types.ts ================================================ export interface Track { id: string; name: string; path: string; duration: number; bitRate: number; } export interface TracksPage { tracks: Track[]; page: number; limit: number; total: number; } export interface PlaybackState { currentTrack: Track | null; currentTrackElapsed: number; isPlaying: boolean; updatedAt: number; } export interface ResponseErr { message: string; } export interface ResponseOK { message: string; } export interface Playlist { id: string; name: string; description?: string; tracks: Track[]; trackCount: number; } export interface StationInfo { name: string; description: string; faviconURL: string; logoURL: string; location: string; timezone: string; links: string; theme: string; } ================================================ FILE: web/studio/src/api/utils.ts ================================================ export const jsonRequestParams = (method: string, body: Record) => { return { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }; }; export const queryParams = (params: Record) => { removeEmptyFields(params); return new URLSearchParams(params).toString(); }; const removeEmptyFields = (obj: Record) => { for (const key in obj) { if (obj.hasOwnProperty(key) && [undefined, null, ""].includes(obj[key])) { delete obj[key]; } } }; ================================================ FILE: web/studio/src/components/AudioPlayer.tsx ================================================ import React, { useEffect, useRef } from "react"; import { useThrottledState } from "@mantine/hooks"; import { ActionIcon, Box, Checkbox, Flex, Group, Progress, Text, Tooltip, useMantineColorScheme } from "@mantine/core"; import { API_HOST } from "../api"; import { Track } from "../api/types"; import { formatTime } from "../utils/time"; import { IconPlayerPlayFilled } from "../icons"; import { IconPlayerStopFilled } from "../icons"; interface AudioPlayerProps { track: Track; isPlaying: boolean; selected: Set; setSelected: React.Dispatch>>; togglePlaying: () => void; } export const AudioPlayer: React.FC = ({ track, isPlaying, selected, setSelected, togglePlaying }) => { const audioRef = useRef(null); const [progress, setProgress] = useThrottledState(0, 500); const [cursorPos, setCursorPos] = useThrottledState(0, 100); const { colorScheme } = useMantineColorScheme(); const btnColor = colorScheme === "dark" ? "gray" : "black"; const handleProgressClick = (e: React.MouseEvent) => { if (audioRef.current) { const rect = e.currentTarget.getBoundingClientRect(); const clickPosition = (e.clientX - rect.left) / rect.width; const newTime = clickPosition * audioRef.current.duration; audioRef.current.currentTime = newTime; setProgress(clickPosition * 100); if (!isPlaying) togglePlaying(); } }; const handleTimeUpdate = () => { if (audioRef.current) { const currentTime = audioRef.current.currentTime; const duration = audioRef.current.duration; setProgress((currentTime / duration) * 100); } }; const handleAudioEnd = () => { if (audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; togglePlaying(); setProgress(0); } }; useEffect(() => { if (audioRef.current) { if (isPlaying) { audioRef.current.play(); } else { audioRef.current.pause(); } } }, [isPlaying]); return ( <>