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
================================================
Your own online radio station
🔍 Overview 💻 Demo ⚙️ Installation 🗺️ Roadmap 🚨 Bug report
## 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:
- 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:
- 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
================================================