Full Code of cheatsnake/airstation for AI

master 78c3e31e60f0 cached
142 files
278.3 KB
77.0k tokens
340 symbols
1 requests
Download .txt
Showing preview only (311K chars total). Download the full file or copy to clipboard to get everything.
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
================================================
<br>
<p align="center">
  <a href="https://github.com/cheatsnake/airstation">
    <img src="./docs/images/logo.png" alt="logo" height="128">
  </a>
</p>

<h2 align="center">Airstation</h2>
<p align="center">Your own online radio station</p>
<p align="center">
🔍 <a href="./docs/overview.md">Overview</a>
&nbsp; 💻 <a href="https://radio.yurace.pro/">Demo</a>
&nbsp; ⚙️ <a href="./docs/installation.md">Installation</a>
&nbsp; 🗺️ <a href="./docs/roadmap.md">Roadmap</a>
&nbsp; 🚨 <a href="https://github.com/cheatsnake/airstation/issues/new">Bug report</a>
</p>
<br />

Airstation is a self-hosted web app for streaming music over the Internet. It features a simple interface for uploading tracks and managing the playback queue, along with a minimalistic player for listeners. Under the hood, it streams music over HTTP using HLS, stores data in SQLite, and leverages FFmpeg for audio processing — all packaged in a compact Docker container for easy deployment.

<img src="./docs/images/screenshot01.png" alt="Web studio screenshot"/>
<img src="./docs/images/screenshot02.png" alt="Web studio mobile screenshot"/>
<img src="./docs/images/screenshot03.png" alt="Web player screenshot"/>

<p></p>
<div align="center">Made for fun</div>
<div align="center"><a href="./LICENSE">LICENSE</a> 2025 - Present</div

================================================
FILE: cmd/main.go
================================================
package main

import (
	"log/slog"
	"os"
	"os/signal"
	"path"
	"syscall"

	"github.com/cheatsnake/airstation/internal/config"
	"github.com/cheatsnake/airstation/internal/http"
	"github.com/cheatsnake/airstation/internal/logger"
	"github.com/cheatsnake/airstation/internal/pkg/fs"
	"github.com/cheatsnake/airstation/internal/storage"
	"github.com/cheatsnake/airstation/internal/storage/sqlite"
)

func main() {
	conf := config.Load()

	fs.DeleteDirIfExists(conf.TmpDir)
	fs.MustDir(conf.TmpDir)
	fs.MustDir(conf.TracksDir)
	fs.MustDir(conf.DBDir)

	stopSignal := make(chan os.Signal, 1)
	signal.Notify(stopSignal, os.Interrupt, syscall.SIGTERM)

	log := logger.New()
	store, err := sqlite.New(path.Join(conf.DBDir, conf.DBFile), log.WithGroup("storage"))
	if err != nil {
		log.Error("Failed connect to database: " + err.Error())
		os.Exit(1)
	}

	httpServer := http.NewServer(store, conf, log)
	go httpServer.Run()

	<-stopSignal
	shutdown(log, store)
}

func shutdown(log *slog.Logger, store storage.Storage) {
	println()
	log.Info("Shutting down the app...")

	err := store.Close()
	if err != nil {
		log.Error("Failed to close database connection: " + err.Error())
	}

	log.Info("App gracefully stopped")
}


================================================
FILE: docker-compose.yml
================================================
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        - AIRSTATION_PLAYER_TITLE=${AIRSTATION_PLAYER_TITLE}
    ports:
      - "7331:7331"
    volumes:
      - database:/app/storage
      - ./static:/app/static
    restart: unless-stopped
    env_file:
      - .env
    
volumes:
  database:

================================================
FILE: docs/installation.md
================================================
# Installation

To run Airstation on your machine, there are two ways: using [Docker](https://docs.docker.com/) (recommended) or building it yourself using the [Go](https://go.dev/) compiler for server, [Node.js](https://nodejs.org/) with [npm](https://www.npmjs.com/) for web clients and also have [FFmpeg](https://ffmpeg.org/) installed on your system.

## Docker

1.  Clone Airstation repository

    ```sh
    git clone https://github.com/cheatsnake/airstation.git
    ```

    ```sh
    cd ./airstation
    ```

2.  Setup environment variables

    Next you need an `.env` file with secret keys

    ```sh
    touch .env
    ```

    Inside this file you must define 2 variables:

    ```
    AIRSTATION_SECRET_KEY=
    AIRSTATION_JWT_SIGN=
    ```

    > `AIRSTATION_SECRET_KEY` - the secret key you need to log in to the station control panel <br> `AIRSTATION_JWT_SIGN` - the key to sign the JWT session

    > Use [random string generator](https://it-tools.tech/token-generator?length=20) with a length of at least 10 characters for these variables!

3.  Build a docker image and start a new container

    ```sh
    docker compose up -d
    ```

And finally you can see:

- Control panel on [http://localhost:7331/studio/](http://localhost:7331/studio/) (extra slash matters!)
- Radio player on [http://localhost:7331](http://localhost:7331)

To stop the container, just type:

```sh
docker compose down
```

### Docker Compose

You can get pre-built image from [Docker Hub](https://hub.docker.com/r/cheatsnake/airstation) and run it quickly with custom `docker-compose.yml` file as shown bellow:

```yml
# docker-compose.yml
services:
  airstation:
    image: cheatsnake/airstation:latest
    ports:
      - "7331:7331"
    volumes:
      - airstation-data:/app/storage
      - ./static:/app/static
    restart: unless-stopped
    environment:
      AIRSTATION_SECRET_KEY: ${AIRSTATION_SECRET_KEY:-PASTE_YOUR_OWN_KEY}
      AIRSTATION_JWT_SIGN: ${AIRSTATION_JWT_SIGN:-PASTE_RANDOM_STRING}
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:7331/"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s

volumes:
  airstation-data:
```

> Don't forget to modify environment variables inside this file or via your own `.env` file in the same directory as the `docker-compose.yml`

## Build from source

1. Follow steps 1 and 2 from the previous section

2. Install dependencies

```sh
npm ci --prefix ./web/player
```

```sh
npm ci --prefix ./web/studio
```

3. Build web clients

```sh
npm run build --prefix ./web/player
```

```sh
npm run build --prefix ./web/studio
```

4. Build server

```sh
go build ./cmd/main.go
```

5. Run app

```sh
./main
```

> Make sure you have [FFmpeg](https://ffmpeg.org/) installed on your system.

See the result on [http://localhost:7331](http://localhost:7331) and [http://localhost:7331/studio/](http://localhost:7331/studio/) (extra slash matters!)

## Development mode

To run the application in development mode, start each part of the application using the commands below:

```sh
npm run dev --prefix ./web/player
```

```sh
npm run dev --prefix ./web/studio
```

```sh
go run ./cmd/main.go
```


================================================
FILE: docs/overview.md
================================================
# Overview

Airstation allows you to organize your own internet radio station where your audio tracks will be played. The application was created with the purpose of being extremely simple and affordable way to organize your own radio. In fact, I don't even know if it makes sense to describe any user documentation, because all the applications can be presented in two screenshots.

Logically, the frontend part of the application can be divided into 2 parts. The first is the control panel where the radio station is controlled. The second is a minimalistic radio player for listeners.

The backend is organized simply. Track metadata and playback history are stored in an [SQLite](https://en.wikipedia.org/wiki/SQLite) database, while the audio files are saved in a static folder on the server. All tracks are processed using [FFmpeg](https://en.wikipedia.org/wiki/FFmpeg) to standardize their format and generate [HLS](https://en.wikipedia.org/wiki/HTTP_Live_Streaming) playlists for streaming.

<img src="./images/tech-stack.png" alt="Technology stack"/>

## Main features

- Permanent storage of tracks in the library
- Ability to listen to added tracks
- Ability to delete tracks from the library
- Search for tracks in the library
- Sort tracks by date added, name, duration.
- Creating a track queue
- Changing the current track queue
- Cyclic queue mode
- Possibility to randomly mix the queue
- Possibility to temporarily stop the radio station
- Playback history
- Listener counter
- Playlists mechanism
- Player page customization

## Deep under the hood

The following will describe in more detail how the application works. To better understand how streaming happens, it is worth considering the path an audio file takes before users hear it. Each track added to the station and played goes through several lifecycle stages:

<img src="./images/audio-pipeline.png" alt="Audio pipeline"/>

- First, as the station owner, you upload the track to the server. Typically, these are `mp3` or `aac` files with varying bitrates.
- Next, all files are transcoded into a unified codec and bitrate, then stored permanently on the server.
- When it's time to play the track, it is converted into an `m3u8` playlist — essentially, the audio file is split into small chunks for progressive streaming to the station's listeners.

To ensure all listeners are at the same point in track playback, a special playback state object is used. While the station is running, the following lifecycle occurs:

<img src="./images/playback-lifecycle.png" alt="Playback lifecycle"/>

- The object selects the first track in your queue and generates a temporary HLS playlist by splitting the track into small chunks (typically 5 seconds each).
- As soon as the track enters the playback state, playback time starts counting.
- Based on the elapsed time, the corresponding chunks are selected from the playlist (usually at least 3 chunks to provide a buffer on the listener's side).
- Each listener periodically requests the current chunks to maintain uninterrupted playback.

## Who is this app suitable for?

Anyone can actually have their own radio station - we all listen to music. And it's really great to share your musical flavor with friends or your own audience. All you need is a [VPS server](https://en.wikipedia.org/wiki/Virtual_private_server) on which you can deploy your station. In fact, it costs pennies and you don't need to become a system administrator or Linux guru. One [YouTube video tutorial](https://www.youtube.com/results?search_query=how+to+vps) will open up a new world of [self-hosted solutions](https://github.com/awesome-selfhosted/awesome-selfhosted).


================================================
FILE: docs/roadmap.md
================================================
# Roadmap

Here you can find a list of future updates.

## 🚧 In Progress

- [ ] New music visualizers for player page

---

## 📝 Planned Features

- [ ] Tags for tracks (as a grouping mechanism)
- [ ] Ability to send voice messages recorded through the microphone
- [ ] Scheduling mechanism for tracks/playlists (by [hjdx2009](https://github.com/cheatsnake/airstation/issues/7#issue-3059402373))

---

## 🌟 Long-Term Goals

- [ ] Crossfade effect between tracks (by [rursache](https://github.com/cheatsnake/airstation/issues/5#issuecomment-2873728112))
- [ ] Integration with online music services for downloading tracks directly to the library
- [ ] Built-in tunneling feature (to share your local server with others over the Internet)
- [ ] Integration with other music programs to synchronize track playback (by [swishkin](https://github.com/cheatsnake/airstation/issues/8#issue-3069650457))
---

## ✅ Done

- [x] Theming for player page (by [ptolemaea](https://github.com/cheatsnake/airstation/issues/21))
- [x] Custom station info (name, description, logo, favicon, links)
- [x] Playlists (ability to pre-create and select already created playlists for playback)

---

## 💡 Suggestions?

Have a feature request or idea? Feel free to open an [issue](https://github.com/cheatsnake/airstation/issues).


================================================
FILE: go.mod
================================================
module github.com/cheatsnake/airstation

go 1.26

require github.com/oklog/ulid/v2 v2.1.1

require github.com/rs/cors v1.11.1

require (
	github.com/golang-jwt/jwt/v5 v5.3.1
	github.com/joho/godotenv v1.5.1
	modernc.org/sqlite v1.48.1
)

require (
	github.com/dustin/go-humanize v1.0.1 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/ncruces/go-strftime v1.0.0 // indirect
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
	golang.org/x/sys v0.42.0 // indirect
	golang.org/x/tools v0.43.0 // indirect
	modernc.org/libc v1.70.0 // indirect
	modernc.org/mathutil v1.7.1 // indirect
	modernc.org/memory v1.11.0 // indirect
)


================================================
FILE: go.sum
================================================
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA=
modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=


================================================
FILE: internal/config/config.go
================================================
package config

import (
	"log"
	"os"
	"path/filepath"
	"strings"

	"github.com/joho/godotenv"
)

const minSecretLength = 10

type Config struct {
	DBDir        string
	DBFile       string
	TracksDir    string
	TmpDir       string
	PlayerDir    string
	StudioDir    string
	HTTPPort     string
	JWTSign      string
	SecretKey    string
	SecureCookie bool
}

func Load() *Config {
	_ = godotenv.Load() // For development

	return &Config{
		DBDir:        getEnv("AIRSTATION_DB_DIR", filepath.Join("storage")),
		DBFile:       getEnv("AIRSTATION_DB_FILE", "storage.db"),
		TracksDir:    getEnv("AIRSTATION_TRACKS_DIR", filepath.Join("static", "tracks")),
		TmpDir:       getEnv("AIRSTATION_TMP_DIR", filepath.Join("static", "tmp")),
		PlayerDir:    getEnv("AIRSTATION_PLAYER_DIR", filepath.Join("web", "player", "dist")),
		StudioDir:    getEnv("AIRSTATION_STUDIO_DIR", filepath.Join("web", "studio", "dist")),
		HTTPPort:     getEnv("AIRSTATION_HTTP_PORT", "7331"),
		JWTSign:      getSecret("AIRSTATION_JWT_SIGN"),
		SecretKey:    getSecret("AIRSTATION_SECRET_KEY"),
		SecureCookie: getEnvBool("AIRSTATION_SECURE_COOKIE", false),
	}
}

func getEnv(key, defaultValue string) string {
	if value := os.Getenv(key); value != "" {
		return value
	}
	return defaultValue
}

func getEnvBool(key string, defaultValue bool) bool {
	val := os.Getenv(key)
	if val == "" {
		return defaultValue
	}

	val = strings.ToLower(val)
	return val == "1" || val == "true" || val == "yes" || val == "on"
}

func getSecret(key string) string {
	secretKey := os.Getenv(key)

	if secretKey == "" {
		log.Fatal(key + " environment variable is not set")
	}

	if len(secretKey) < minSecretLength {
		log.Fatal(key + " is too short")
	}

	return secretKey
}


================================================
FILE: internal/http/consts.go
================================================
package http

const (
	eventPlay           = "play"
	eventPause          = "pause"
	eventNewTrack       = "new_track"
	eventLoadedTracks   = "loaded_tracks"
	eventCountListeners = "count_listeners"
	eventChangeTheme    = "change_theme"
)


================================================
FILE: internal/http/handlers.go
================================================
package http

import (
	"crypto/subtle"
	"errors"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
	"path/filepath"
	"slices"
	"time"

	"github.com/cheatsnake/airstation/internal/pkg/sse"
	"github.com/cheatsnake/airstation/internal/station"
	"github.com/cheatsnake/airstation/internal/track"
	"github.com/golang-jwt/jwt/v5"
)

const multipartChunkLimit = 64 * 1024 * 1024 // 64 MB
const copyBufferSize = 256 * 1024            // 256 KB

func (s *Server) handleHLSPlaylist(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "audio/mpegurl")

	if s.playbackState.IsPlaying {
		fmt.Fprint(w, s.playbackState.PlaylistStr)
	}
}

func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")

	eventChan := make(chan *sse.Event)
	s.eventsEmitter.Subscribe(eventChan)

	closeNotify := r.Context().Done()
	go func() {
		<-closeNotify
		s.eventsEmitter.Unsubscribe(eventChan)
		close(eventChan)
	}()

	// Send current number of listeners immediately
	countEvent := s.countListeners()
	fmt.Fprint(w, countEvent.Stringify())
	w.(http.Flusher).Flush()

	for {
		event, isOpen := <-eventChan
		if !isOpen {
			break
		}

		fmt.Fprint(w, event.Stringify())
		w.(http.Flusher).Flush()
	}
}

func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
	body, err := parseJSONBody[struct {
		Secret string `json:"secret"`
	}](r)
	if err != nil {
		jsonBadRequest(w, "Parsing request body failed.")
		return
	}

	isValidSecret := subtle.ConstantTimeCompare([]byte(body.Secret), []byte(s.config.SecretKey)) == 1
	if !isValidSecret {
		jsonForbidden(w, "Wrong secret, access denied.")
		return
	}

	expirationTime := time.Now().Add(7 * 24 * time.Hour)
	claims := jwt.MapClaims{
		"iss": "airstation",
		"exp": expirationTime.Unix(),
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tokenString, err := token.SignedString([]byte(s.config.JWTSign))
	if err != nil {
		s.logger.Debug("Failed to generate token: " + err.Error())
		jsonInternalError(w, "Failed to generate token.")
		return
	}

	http.SetCookie(w, &http.Cookie{
		Name:     "jwt",
		Value:    tokenString,
		Expires:  expirationTime,
		Path:     "/",
		HttpOnly: true,
		Secure:   s.config.SecureCookie,
		SameSite: http.SameSiteStrictMode,
	})

	s.logger.Info(fmt.Sprintf("New login succeed from %s with secureCookie=%v", r.Host, s.config.SecureCookie))

	jsonOK(w, "Login succeed.")
}

func (s *Server) handleTracks(w http.ResponseWriter, r *http.Request) {
	queries := r.URL.Query()
	page := parseIntQuery(queries, "page", 1)
	limit := parseIntQuery(queries, "limit", 20)
	search := queries.Get("search")
	sortBy := queries.Get("sort_by")
	sortOrder := queries.Get("sort_order")

	result, err := s.trackService.Tracks(page, limit, search, sortBy, sortOrder)
	if err != nil {
		jsonBadRequest(w, "Tracks retrieving failed: "+err.Error())
		return
	}

	jsonResponse(w, result)
}

func (s *Server) handleTracksUpload(w http.ResponseWriter, r *http.Request) {
	err := r.ParseMultipartForm(multipartChunkLimit)
	if err != nil {
		jsonBadRequest(w, "Failed to parse multipart form: "+err.Error())
		return
	}

	files := r.MultipartForm.File["tracks"]
	if len(files) == 0 {
		jsonBadRequest(w, "No files uploaded")
		return
	}

	for _, fileHeader := range files {
		_, err := s.saveFile(fileHeader)
		if err != nil {
			jsonBadRequest(w, err.Error())
			return
		}
	}

	go s.trackService.LoadTracksFromDisk(s.config.TracksDir)

	msg := fmt.Sprintf("%d track(s) uploaded successfully. They will be available in your library once processed.", len(files))
	jsonOK(w, msg)
}

func (s *Server) handleDeleteTracks(w http.ResponseWriter, r *http.Request) {
	body, err := parseJSONBody[track.BodyWithIDs](r)
	if err != nil {
		jsonBadRequest(w, "Parsing request body failed: "+err.Error())
		return
	}

	err = s.trackService.DeleteTracks(body.IDs)
	if err != nil {
		s.logger.Debug(err.Error())
		jsonBadRequest(w, "Deleting tracks failed")
		return
	}

	jsonOK(w, "Tracks deleted")
}

func (s *Server) handleQueue(w http.ResponseWriter, _ *http.Request) {
	queue, err := s.queueService.Queue()
	if err != nil {
		s.logger.Debug(err.Error())
		jsonBadRequest(w, "Queue retrieving failed: "+err.Error())
		return
	}

	jsonResponse(w, queue)
}

func (s *Server) handleAddToQueue(w http.ResponseWriter, r *http.Request) {
	body, err := parseJSONBody[track.BodyWithIDs](r)
	if err != nil {
		jsonBadRequest(w, "Parsing request body failed: "+err.Error())
		return
	}

	tracks, err := s.trackService.FindTracks(body.IDs)
	if err != nil {
		jsonBadRequest(w, "Adding tracks to queue failed: "+err.Error())
		return
	}

	err = s.queueService.AddToQueue(tracks)
	if err != nil {
		jsonBadRequest(w, "Adding tracks to queue failed: "+err.Error())
		return
	}

	err = s.playbackState.Reload()
	if err != nil {
		s.logger.Debug("Playback reload failed: " + err.Error())
	}

	jsonOK(w, "Tracks added")
}

func (s *Server) handleReorderQueue(w http.ResponseWriter, r *http.Request) {
	body, err := parseJSONBody[track.BodyWithIDs](r)
	if err != nil {
		jsonBadRequest(w, "Parsing request body failed: "+err.Error())
		return
	}

	err = s.queueService.ReorderQueue(body.IDs)
	if err != nil {
		jsonBadRequest(w, "Queue reordering failed: "+err.Error())
		return
	}

	err = s.playbackState.Reload()
	if err != nil {
		s.logger.Debug("Playback reload failed: " + err.Error())
	}

	jsonOK(w, "Queue reordered")
}

func (s *Server) handleRemoveFromQueue(w http.ResponseWriter, r *http.Request) {
	body, err := parseJSONBody[track.BodyWithIDs](r)
	if err != nil {
		jsonBadRequest(w, "Parsing request body failed: "+err.Error())
		return
	}

	if s.playbackState.CurrentTrack != nil {
		hasCurrent := slices.Contains(body.IDs, s.playbackState.CurrentTrack.ID)
		if hasCurrent {
			s.playbackState.Pause()
		}
	}

	err = s.queueService.RemoveFromQueue(body.IDs)
	if err != nil {
		jsonBadRequest(w, "Removing from queue failed: "+err.Error())
		return
	}

	err = s.playbackState.Reload()
	if err != nil {
		s.logger.Debug("Playback reload failed: " + err.Error())
	}

	jsonOK(w, "Tracks removed")
}

func (s *Server) handlePlaybackState(w http.ResponseWriter, _ *http.Request) {
	jsonResponse(w, s.playbackState)
}

func (s *Server) handlePausePlayback(w http.ResponseWriter, _ *http.Request) {
	s.playbackState.Pause()
	jsonResponse(w, s.playbackState)
}

func (s *Server) handlePlayPlayback(w http.ResponseWriter, _ *http.Request) {
	err := s.playbackState.Play()
	if err != nil {
		jsonBadRequest(w, "Playback failed to start: "+err.Error())
		return
	}

	jsonResponse(w, s.playbackState)
}

func (s *Server) handlePlaybackHistory(w http.ResponseWriter, r *http.Request) {
	queries := r.URL.Query()
	limit := parseIntQuery(queries, "limit", 50)
	history, err := s.playbackService.RecentPlaybackHistory(limit)
	if err != nil {
		s.logger.Debug(err.Error())
		jsonBadRequest(w, "Playback history retrieving failed")
		return
	}

	jsonResponse(w, history)
}

func (s *Server) handleAddPlaylist(w http.ResponseWriter, r *http.Request) {
	body, err := parseJSONBody[struct {
		Name        string   `json:"name"`
		Description string   `json:"description"`
		TrackIDs    []string `json:"trackIDs"`
	}](r)
	if err != nil {
		jsonBadRequest(w, "Parsing request body failed: "+err.Error())
		return
	}

	pl, err := s.playlistService.AddPlaylist(body.Name, body.Description, body.TrackIDs)
	if err != nil {
		jsonBadRequest(w, "Playlist creation failed: "+err.Error())
		return
	}

	jsonResponse(w, pl)
}

func (s *Server) handlePlaylists(w http.ResponseWriter, r *http.Request) {
	pls, err := s.playlistService.Playlists()
	if err != nil {
		jsonBadRequest(w, "Playlists retrieving failed: "+err.Error())
	}

	jsonResponse(w, pls)
}

func (s *Server) handlePlaylist(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")

	pl, err := s.playlistService.Playlist(id)
	if err != nil {
		jsonBadRequest(w, "Playlist retrieving failed: "+err.Error())
	}

	jsonResponse(w, pl)
}

func (s *Server) handleEditPlaylist(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")

	body, err := parseJSONBody[struct {
		Name        string   `json:"name"`
		Description string   `json:"description"`
		TrackIDs    []string `json:"trackIDs"`
	}](r)
	if err != nil {
		jsonBadRequest(w, "Parsing request body failed: "+err.Error())
		return
	}

	err = s.playlistService.EditPlaylist(id, body.Name, body.Description, body.TrackIDs)
	if err != nil {
		jsonBadRequest(w, "Playlist creation failed: "+err.Error())
		return
	}

	jsonOK(w, "Playlist updated")
}

func (s *Server) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")

	err := s.playlistService.DeletePlaylist(id)
	if err != nil {
		jsonBadRequest(w, "Playlist deletion failed: "+err.Error())
	}

	jsonOK(w, "Playlist deleted")
}

func (s *Server) handleStaticDir(prefix string, path string) http.Handler {
	return http.StripPrefix(prefix, http.FileServer(http.Dir(path)))
}

func (s *Server) handleStaticDirWithoutCache(prefix string, path string) http.Handler {
	fileHandler := http.StripPrefix(prefix, http.FileServer(http.Dir(path)))

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Cache-Control", "no-cache")
		fileHandler.ServeHTTP(w, r)
	})
}

func (s *Server) handleStationInfo(w http.ResponseWriter, _ *http.Request) {
	info, err := s.stationService.Info()
	if err != nil {
		jsonBadRequest(w, "Failed to get station info: "+err.Error())
		return
	}

	jsonResponse(w, info)
}

func (s *Server) handleEditStationInfo(w http.ResponseWriter, r *http.Request) {
	body, err := parseJSONBody[station.Info](r)
	if err != nil {
		jsonBadRequest(w, "Parsing request body failed: "+err.Error())
		return
	}

	info, err := s.stationService.EditInfo(body)
	if err != nil {
		jsonBadRequest(w, "Station info editing failed: "+err.Error())
		return
	}

	s.eventsEmitter.RegisterEvent(eventChangeTheme, " ")

	jsonResponse(w, info)
}

func (s *Server) saveFile(fileHeader *multipart.FileHeader) (string, error) {
	file, err := fileHeader.Open()
	if err != nil {
		msg := "Failed to open file: " + err.Error()
		s.logger.Debug(msg)
		return "", errors.New(msg)
	}

	fileName := filepath.Base(fileHeader.Filename)
	filePath := filepath.Join(s.config.TracksDir, fileName)
	dst, err := os.Create(filePath)
	if err != nil {
		msg := "Failed to create file on disk: " + err.Error()
		s.logger.Debug(msg)
		return "", errors.New(msg)
	}

	_, err = io.CopyBuffer(dst, file, make([]byte, copyBufferSize))
	if err != nil {
		msg := "Failed to save file: " + err.Error()
		s.logger.Debug(msg)
		return "", errors.New(msg)
	}

	file.Close()
	dst.Close()

	return filePath, nil
}


================================================
FILE: internal/http/messages.go
================================================
package http

import (
	"encoding/json"
	"net/http"
)

type Message struct {
	Message string `json:"message"`
}

func jsonResponse(w http.ResponseWriter, data interface{}) {
	w.Header().Set("Content-Type", "application/json")

	if err := json.NewEncoder(w).Encode(data); err != nil {
		http.Error(w, "JSON encoding failed", http.StatusInternalServerError)
	}
}

func jsonMessage(w http.ResponseWriter, code int, body string) {
	msg := Message{Message: body}

	w.WriteHeader(code)
	jsonResponse(w, msg)
}

func jsonOK(w http.ResponseWriter, body string) {
	jsonMessage(w, http.StatusOK, body)
}

func jsonBadRequest(w http.ResponseWriter, body string) {
	jsonMessage(w, http.StatusBadRequest, body)
}

func jsonUnauthorized(w http.ResponseWriter, body string) {
	jsonMessage(w, http.StatusUnauthorized, body)
}

func jsonForbidden(w http.ResponseWriter, body string) {
	jsonMessage(w, http.StatusForbidden, body)
}

func jsonInternalError(w http.ResponseWriter, body string) {
	jsonMessage(w, http.StatusInternalServerError, body)
}


================================================
FILE: internal/http/middlewares.go
================================================
package http

import (
	"fmt"
	"net/http"

	"github.com/golang-jwt/jwt/v5"
)

func (s *Server) jwtAuth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		cookie, err := r.Cookie("jwt")
		if err != nil {
			jsonUnauthorized(w, "Unauthorized, access denied.")
			return
		}

		token, err := jwt.Parse(cookie.Value, func(token *jwt.Token) (any, error) {
			if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
				return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])

			}
			return []byte(s.config.JWTSign), nil
		})

		if err != nil || !token.Valid {
			jsonUnauthorized(w, "Invalid token.")
			return
		}

		next.ServeHTTP(w, r)
	})
}


================================================
FILE: internal/http/parser.go
================================================
package http

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
)

func parseIntQuery(queries url.Values, key string, defaultValue int) int {
	queryValue := queries.Get(key)
	parsed, err := strconv.Atoi(queryValue)
	if err != nil {
		parsed = defaultValue
	}

	return parsed
}

func parseJSONBody[T any](r *http.Request) (*T, error) {
	rawBytes, err := io.ReadAll(r.Body)
	if err != nil {
		return nil, err
	}
	defer r.Body.Close()

	if len(rawBytes) == 0 {
		return nil, fmt.Errorf("request body is empty")
	}

	var jsonData T
	if err := json.Unmarshal(rawBytes, &jsonData); err != nil {
		return nil, err
	}

	return &jsonData, nil
}


================================================
FILE: internal/http/server.go
================================================
package http

import (
	"log/slog"
	"mime"
	"net/http"
	"strconv"
	"time"

	"github.com/cheatsnake/airstation/internal/config"
	"github.com/cheatsnake/airstation/internal/pkg/ffmpeg"
	"github.com/cheatsnake/airstation/internal/pkg/hls"
	"github.com/cheatsnake/airstation/internal/pkg/sse"
	"github.com/cheatsnake/airstation/internal/playback"
	"github.com/cheatsnake/airstation/internal/playlist"
	"github.com/cheatsnake/airstation/internal/queue"
	"github.com/cheatsnake/airstation/internal/station"
	"github.com/cheatsnake/airstation/internal/storage"
	"github.com/cheatsnake/airstation/internal/track"
	"github.com/rs/cors"
)

type Server struct {
	playbackState   *playback.State
	eventsEmitter   *sse.Emitter
	trackService    *track.Service
	queueService    *queue.Service
	playbackService *playback.Service
	playlistService *playlist.Service
	stationService  *station.Service
	config          *config.Config
	logger          *slog.Logger
	router          *http.ServeMux
}

func NewServer(store storage.Storage, conf *config.Config, logger *slog.Logger) *Server {
	ffmpegCLI := ffmpeg.NewCLI()
	ts := track.NewService(store, ffmpegCLI, logger.WithGroup("trackservice"))
	qs := queue.NewService(store)
	ps := playback.NewService(store)
	pls := playlist.NewService(store)
	ss := station.NewService(store)
	state := playback.NewState(ts, qs, ps, conf.TmpDir, logger.WithGroup("playback"))

	return &Server{
		playbackState:   state,
		eventsEmitter:   sse.NewEmitter(),
		trackService:    ts,
		queueService:    qs,
		playbackService: ps,
		playlistService: pls,
		stationService:  ss,
		config:          conf,
		logger:          logger.WithGroup("http"),
		router:          http.NewServeMux(),
	}
}

func (s *Server) Run() {
	s.registerMP2TMimeType()

	// Public handlers
	s.router.HandleFunc("GET /stream", s.handleHLSPlaylist)
	s.router.HandleFunc("GET /api/v1/events", s.handleEvents)
	s.router.HandleFunc("GET /api/v1/station/info", s.handleStationInfo)
	s.router.HandleFunc("POST /api/v1/login", s.handleLogin)
	s.router.Handle("GET /static/tmp/", s.handleStaticDirWithoutCache("/static/tmp", s.config.TmpDir))
	s.router.Handle("GET /api/v1/playback", http.HandlerFunc(s.handlePlaybackState))
	s.router.Handle("GET /api/v1/playback/history", http.HandlerFunc(s.handlePlaybackHistory))

	// Protected handlers
	s.router.Handle("POST /api/v1/tracks", s.jwtAuth(http.HandlerFunc(s.handleTracksUpload)))
	s.router.Handle("GET /api/v1/tracks", s.jwtAuth(http.HandlerFunc(s.handleTracks)))
	s.router.Handle("DELETE /api/v1/tracks", s.jwtAuth(http.HandlerFunc(s.handleDeleteTracks)))
	s.router.Handle("GET /api/v1/queue", s.jwtAuth(http.HandlerFunc(s.handleQueue)))
	s.router.Handle("POST /api/v1/queue", s.jwtAuth(http.HandlerFunc(s.handleAddToQueue)))
	s.router.Handle("PUT /api/v1/queue", s.jwtAuth(http.HandlerFunc(s.handleReorderQueue)))
	s.router.Handle("DELETE /api/v1/queue", s.jwtAuth(http.HandlerFunc(s.handleRemoveFromQueue)))
	s.router.Handle("POST /api/v1/playback/pause", s.jwtAuth(http.HandlerFunc(s.handlePausePlayback)))
	s.router.Handle("POST /api/v1/playback/play", s.jwtAuth(http.HandlerFunc(s.handlePlayPlayback)))
	s.router.Handle("POST /api/v1/playlist", s.jwtAuth(http.HandlerFunc(s.handleAddPlaylist)))
	s.router.Handle("GET /api/v1/playlists", s.jwtAuth(http.HandlerFunc(s.handlePlaylists)))
	s.router.Handle("GET /api/v1/playlist/{id}/", s.jwtAuth(http.HandlerFunc(s.handlePlaylist)))
	s.router.Handle("PUT /api/v1/playlist/{id}/", s.jwtAuth(http.HandlerFunc(s.handleEditPlaylist)))
	s.router.Handle("DELETE /api/v1/playlist/{id}/", s.jwtAuth(http.HandlerFunc(s.handleDeletePlaylist)))
	s.router.Handle("GET /static/tracks/", s.jwtAuth(s.handleStaticDir("/static/tracks", s.config.TracksDir)))
	s.router.Handle("PUT /api/v1/station/info", s.jwtAuth(http.HandlerFunc(s.handleEditStationInfo)))

	s.router.Handle("GET /studio/", s.handleStaticDir("/studio/", s.config.StudioDir))
	s.router.Handle("GET /", s.handleStaticDir("/", s.config.PlayerDir))

	s.listenEvents()

	err := s.playbackState.Play()
	if err != nil {
		s.logger.Warn("Auto start playing failed: " + err.Error())
	}

	go s.playbackState.Run()
	go s.trackService.LoadTracksFromDisk(s.config.TracksDir)
	s.playbackService.DeleteOldPlaybackHistory()

	s.logger.Info("Server starts on http://localhost:" + s.config.HTTPPort)
	err = http.ListenAndServe(":"+s.config.HTTPPort, cors.Default().Handler(s.router))
	if err != nil {
		s.logger.Error("Listen and serve failed", slog.String("info", err.Error()))
	}
}

func (s *Server) registerMP2TMimeType() {
	err := mime.AddExtensionType(hls.SegmentExtension, "video/mp2t")
	if err != nil {
		s.logger.Error("MP2T mime type registration failed", slog.String("info", err.Error()))
	}
}

func (s *Server) countListeners() *sse.Event {
	count := s.eventsEmitter.CountSubscribers()
	return sse.NewEvent(eventCountListeners, strconv.Itoa(count))
}

func (s *Server) listenEvents() {
	countConnectionTicker := time.Tick(5 * time.Second)

	// TODO: add context for gracefull shutdown

	go func() {
		for range countConnectionTicker {
			event := s.countListeners()
			s.eventsEmitter.RegisterEvent(event.Name, event.Data)
		}
	}()

	go func() {
		for {
			select {
			case <-s.playbackState.PlayNotify:
				s.eventsEmitter.RegisterEvent(eventPlay, s.playbackState.CurrentTrack.Name)
			case <-s.playbackState.PauseNotify:
				s.eventsEmitter.RegisterEvent(eventPause, " ")
			case trackName := <-s.playbackState.NewTrackNotify:
				s.eventsEmitter.RegisterEvent(eventNewTrack, trackName)
			case loadedTracks := <-s.trackService.LoadedTracksNotify:
				s.eventsEmitter.RegisterEvent(eventLoadedTracks, strconv.Itoa(loadedTracks))
			}
		}
	}()
}


================================================
FILE: internal/logger/logger.go
================================================
package logger

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"os"
	"time"
)

type logEntry struct {
	Time    string         `json:"time"`
	Level   string         `json:"level"`
	Group   string         `json:"group,omitempty"`
	Message string         `json:"message"`
	Details map[string]any `json:"details,omitempty"`
}

type customJSONHandler struct {
	output io.Writer
	opts   *slog.HandlerOptions
	group  string
	attrs  []slog.Attr
}

func (h *customJSONHandler) Enabled(ctx context.Context, level slog.Level) bool {
	if h.opts != nil && h.opts.Level != nil {
		return level >= h.opts.Level.Level()
	}
	return level >= slog.LevelInfo
}

func (h *customJSONHandler) Handle(ctx context.Context, r slog.Record) error {
	details := make(map[string]any)

	for _, attr := range h.attrs {
		details[attr.Key] = attr.Value.Any()
	}

	r.Attrs(func(attr slog.Attr) bool {
		details[attr.Key] = attr.Value.Any()
		return true
	})

	entry := logEntry{
		Time:    r.Time.Format(time.DateTime),
		Level:   r.Level.String(),
		Message: r.Message,
		Group:   h.group,
	}

	if len(details) > 0 {
		entry.Details = details
	}

	var buf bytes.Buffer
	buf.WriteString("{")
	fmt.Fprintf(&buf, `"time":"%s"`, entry.Time)
	fmt.Fprintf(&buf, `,"level":"%s"`, entry.Level)
	if entry.Group != "" {
		fmt.Fprintf(&buf, `,"group":"%s"`, entry.Group)
	}
	fmt.Fprintf(&buf, `,"message":"%s"`, jsonEscape(entry.Message))

	if len(details) > 0 {
		buf.WriteString(`,"details":`)
		detailsJSON, err := json.Marshal(details)
		if err != nil {
			return err
		}
		buf.Write(detailsJSON)
	}

	buf.WriteString("}\n")

	_, err := h.output.Write(buf.Bytes())
	return err
}

func jsonEscape(s string) string {
	b, _ := json.Marshal(s)
	return string(b[1 : len(b)-1])
}

func (h *customJSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	newAttrs := make([]slog.Attr, len(h.attrs)+len(attrs))
	copy(newAttrs, h.attrs)
	copy(newAttrs[len(h.attrs):], attrs)

	return &customJSONHandler{
		output: h.output,
		opts:   h.opts,
		group:  h.group,
		attrs:  newAttrs,
	}
}

func (h *customJSONHandler) WithGroup(name string) slog.Handler {
	return &customJSONHandler{
		output: h.output,
		opts:   h.opts,
		group:  name,
		attrs:  h.attrs,
	}
}

func newCustomJSONHandler(w io.Writer, opts *slog.HandlerOptions) *customJSONHandler {
	return &customJSONHandler{
		output: w,
		opts:   opts,
		attrs:  []slog.Attr{},
	}
}

func New() *slog.Logger {
	handler := newCustomJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelDebug,
	})

	return slog.New(handler)
}


================================================
FILE: internal/pkg/ffmpeg/const.go
================================================
package ffmpeg

// Constants defining the executable names.
const (
	ffmpegBin  = "ffmpeg"
	ffprobeBin = "ffprobe"
)


================================================
FILE: internal/pkg/ffmpeg/ffmpeg.go
================================================
// Package ffmpeg provides a CLI wrapper for executing FFmpeg and FFprobe commands,
// enabling audio processing functionalities.
package ffmpeg

import (
	"bytes"
	"encoding/json"
	"fmt"
	"os/exec"
	"path/filepath"
	"strconv"

	"github.com/cheatsnake/airstation/internal/pkg/fs"
	"github.com/cheatsnake/airstation/internal/pkg/ulid"
)

// CLI represents a command-line interface for interacting with FFmpeg and FFprobe.
type CLI struct{}

// NewCLI creates and returns a new instance of CLI.
func NewCLI() *CLI {
	return &CLI{}
}

// MakeHLSPlaylist converts an audio track into an HLS (HTTP Live Streaming) playlist with segmented files.
// It generates a playlist (.m3u8) and segment files (.ts) in the specified output directory.
//
// Parameters:
//   - trackPath: The path to the source audio file to be converted into HLS format.
//   - outDir: The directory where the HLS playlist and segments will be stored.
//   - segName: The base name for the segment files, which will be suffixed with an index.
//   - segDuration: The duration (in seconds) of each segment.
//
// Returns:
//   - An error if the input file does not exist, or if the HLS generation process fails.
func (cli *CLI) MakeHLSPlaylist(trackPath, outDir, segName string, segDuration int) error {
	if err := fs.FileExists(trackPath); err != nil {
		return err
	}

	hlsTime := strconv.Itoa(segDuration)
	hlsSegName := fmt.Sprintf("%s/%s", outDir, segName) + "%d.ts"
	hlsPlName := fmt.Sprintf("%s/%s", outDir, segName) + ".m3u8"

	cmd := exec.Command(
		ffmpegBin,
		"-i", trackPath,
		"-codec:", "copy",
		"-start_number", "0",
		"-hls_time", hlsTime,
		"-hls_playlist_type", "event",
		"-hls_segment_filename", hlsSegName,
		hlsPlName,
	)

	var errBuf bytes.Buffer
	cmd.Stderr = &errBuf

	err := cmd.Run()
	if err != nil {
		return fmt.Errorf("hls playlist generation failed: %v\n%s", err, errBuf.String())
	}

	return nil
}

// AudioMetadata extracts and returns metadata information from the specified audio file.
// It uses ffprobe to retrieve details such as duration, bit rate, codec name, sample rate, and channel count.
//
// Parameters:
//   - filePath: The path to the audio file whose metadata is to be retrieved.
//
// Returns:
//   - AudioMetadata: A struct containing the extracted metadata (duration, bit rate, codec, sample rate, and channels).
//   - An error if the file does not exist, ffprobe execution fails, or metadata parsing encounters an issue.
func (cli *CLI) AudioMetadata(filePath string) (AudioMetadata, error) {
	metadata := AudioMetadata{}

	if err := fs.FileExists(filePath); err != nil {
		return metadata, err
	}

	cmd := exec.Command(
		ffprobeBin,
		"-i", filePath,
		"-v", "error",
		"-show_entries", "format=duration,bit_rate:stream=codec_name,sample_rate,channels:format_tags=title:stream_tags=title",
		"-of", "json",
	)

	var outBuf bytes.Buffer
	cmd.Stdout = &outBuf
	cmd.Stderr = &outBuf

	err := cmd.Run()
	if err != nil {
		return metadata, fmt.Errorf("metadata retrieve failed: %v\n%s", err, outBuf.String())
	}

	var rawMetadata rawAudioMetadata

	if err = json.Unmarshal(outBuf.Bytes(), &rawMetadata); err != nil {
		return metadata, fmt.Errorf("parsing metadata retrieve failed: %v", err)
	}

	name := rawMetadata.Format.Tags.Title
	duration, err := strconv.ParseFloat(rawMetadata.Format.Duration, 64)
	if err != nil {
		return metadata, fmt.Errorf("parsing metadata duration failed: %v", err)
	}

	bitRate, err := strconv.Atoi(rawMetadata.Format.BitRate)
	if err != nil {
		return metadata, fmt.Errorf("parsing metadata bitrate failed: %v", err)
	}

	if len(rawMetadata.Streams) == 0 {
		return metadata, fmt.Errorf("couldn't extract all metadata")
	}

	channels := rawMetadata.Streams[0].Channels
	codecName := rawMetadata.Streams[0].CodecName
	sampleRate, err := strconv.Atoi(rawMetadata.Streams[0].SampleRate)
	if err != nil {
		return metadata, fmt.Errorf("parsing metadata sample rate failed: %v", err)
	}

	metadata.Name = name
	metadata.Duration = duration
	metadata.BitRate = int(bitRate / 1000)
	metadata.ChannelCount = channels
	metadata.CodecName = codecName
	metadata.SampleRate = sampleRate

	return metadata, nil
}

// PadAudio appends a period of silence to the given audio file, extending its duration by padDuration seconds.
// It generates a silence file based on the provided audio metadata and concatenates it with the original file.
//
// Parameters:
//   - filePath: The path to the audio file to be padded.
//   - padDuration: The duration of silence (in seconds) to be added at the end of the audio.
//   - meta: The metadata of the original audio file, including codec, bit rate, sample rate, and channel count.
//
// Returns:
//   - An error if the file does not exist, silence generation fails, padding fails, or file operations encounter an issue.
func (cli *CLI) PadAudio(filePath string, padDuration float64, meta AudioMetadata) error {
	if err := fs.FileExists(filePath); err != nil {
		return err
	}

	dir, name := filepath.Split(filePath)
	tmpFilePath := filepath.Join(dir, "xtmp-"+name)
	silenceFile := filepath.Join(dir, ulid.New()+"."+meta.CodecName)

	if err := cli.generateSilence(padDuration, meta.BitRate, meta.SampleRate, meta.ChannelCount, silenceFile); err != nil {
		return err
	}

	cmd := exec.Command(
		ffmpegBin,
		"-i", fmt.Sprintf("concat:%s|%s", filePath, silenceFile),
		"-c", "copy",
		tmpFilePath,
		"-y",
	)

	output, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("padding audio failed: %v\nOutput: %s", err, string(output))
	}

	err = fs.RenameFile(tmpFilePath, filePath)
	if err != nil {
		return err
	}

	go fs.DeleteFile(tmpFilePath)
	go fs.DeleteFile(silenceFile)

	return nil
}

// TrimAudio trims the audio file at the specified filePath to the given totalDuration.
// It creates a temporary file, processes the trimming using ffmpeg, and replaces the original file.
//
// Parameters:
//   - filePath: The path to the audio file to be trimmed.
//   - totalDuration: The desired duration of the trimmed audio in seconds.
//
// Returns:
//   - An error if the file does not exist, the trimming process fails, or file operations encounter an issue.
func (cli *CLI) TrimAudio(filePath string, totalDuration float64) error {
	if err := fs.FileExists(filePath); err != nil {
		return err
	}

	dir, name := filepath.Split(filePath)
	tmpFilePath := filepath.Join(dir, "xtmp-"+name)

	cmd := exec.Command(
		ffmpegBin,
		"-i", filePath,
		"-t", strconv.FormatFloat(totalDuration, 'f', 1, 64),
		"-c:a", "copy",
		tmpFilePath,
		"-y",
	)

	output, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("triming audio failed: %v\nOutput: %s", err, string(output))
	}

	err = fs.RenameFile(tmpFilePath, filePath)
	if err != nil {
		return err
	}

	fs.DeleteFile(tmpFilePath)

	return nil
}

// ConvertAudioToAAC converts an audio file to AAC format.
//
// Parameters:
//   - inputPath:  Path to the input audio file (supports various formats like MP3, WAV, etc.)
//   - outputPath: Destination path for the converted AAC file (should end with .aac or .m4a)
//   - bitRate:    Audio bitrate in kbps (e.g., 128 for 128kbps)
//
// Returns:
//   - error: Returns nil on success, or an error if conversion fails. The error includes
//     FFmpeg's output when available for debugging purposes.
func (cli *CLI) ConvertAudioToAAC(inputPath, outputPath string, bitRate int) error {
	cmd := exec.Command(
		ffmpegBin,
		"-i", inputPath,
		"-vn", // Ignore video streams
		"-c:a", "aac",
		"-b:a", strconv.Itoa(bitRate)+"k",
		outputPath,
		"-y",
	)

	output, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("triming audio failed: %v\nOutput: %s", err, string(output))
	}

	return nil
}

// generateSilence generates a silent audio file with the specified duration, bitrate, sample rate,
// and number of channels. The resulting audio file is saved to the provided file path.
func (cli *CLI) generateSilence(duration float64, bitRate, sampleRate, channelCount int, filePath string) error {
	layout := "stereo"
	if channelCount == 1 {
		layout = "mono"
	}

	cmd := exec.Command(
		ffmpegBin,
		"-f", "lavfi",
		"-i", "anullsrc=r="+strconv.Itoa(sampleRate)+":cl="+layout,
		"-t", strconv.FormatFloat(duration, 'f', 1, 64),
		"-b:a", strconv.Itoa(bitRate)+"k",
		"-ar", strconv.Itoa(sampleRate),
		"-ac", strconv.Itoa(channelCount),
		filePath,
		"-y",
	)

	output, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("generating silence audio failed: %v\nOutput: %s", err, string(output))
	}

	return nil
}


================================================
FILE: internal/pkg/ffmpeg/types.go
================================================
package ffmpeg

// AudioMetadata holds metadata information about an audio file.
type AudioMetadata struct {
	Name         string  // The name of the audio
	Duration     float64 // The total duration of the audio file in seconds.
	BitRate      int     // The bit rate of the audio file in kbps (kilobits per second).
	CodecName    string  // The name of the codec used for encoding the audio.
	SampleRate   int     // The sample rate of the audio file in Hz (hertz).
	ChannelCount int     // The number of audio channels (e.g., 1 for mono, 2 for stereo).
}

type rawAudioMetadata struct {
	Format struct {
		Duration string `json:"duration"`
		BitRate  string `json:"bit_rate"`
		Tags     struct {
			Title string `json:"title"`
		} `json:"tags"`
	} `json:"format"`
	Streams []struct {
		CodecName  string `json:"codec_name"`
		SampleRate string `json:"sample_rate"`
		Channels   int    `json:"channels"`
	} `json:"streams"`
}


================================================
FILE: internal/pkg/fs/fs.go
================================================
package fs

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

func MustDir(dirPath string) {
	err := os.MkdirAll(dirPath, 0755)
	if err != nil {
		panic(err)
	}
}

func FileExists(filePath string) error {
	_, err := os.Stat(filePath)
	if os.IsNotExist(err) {
		return fmt.Errorf("file does not exist: %v", filePath)
	}

	if err != nil {
		return fmt.Errorf("retrieving file info failed: %v", err)
	}

	return nil
}

func DeleteFile(filePath string) error {
	err := os.Remove(filePath)
	if err != nil {
		return fmt.Errorf("deleting file failed: %v", err)
	}

	return nil
}

func DeleteDirIfExists(path string) error {
	_, err := os.Stat(path)
	if os.IsNotExist(err) {
		return nil
	} else if err != nil {
		return fmt.Errorf("error checking directory: %v", err)
	}

	err = os.RemoveAll(path)
	if err != nil {
		return fmt.Errorf("failed to delete directory: %v", err)
	}

	return nil
}

func RenameFile(oldPath, newPath string) error {
	err := os.Rename(oldPath, newPath)
	if err != nil {
		return fmt.Errorf("renaming file failed: %v", err)
	}

	return nil
}

func ListFilesFromDir(dirPath, fileExt string) ([]string, error) {
	var filenames []string

	fileInfo, err := os.Stat(dirPath)
	if err != nil {
		return nil, fmt.Errorf("failed to access directory: %v", err)
	}
	if !fileInfo.IsDir() {
		return nil, fmt.Errorf("path is not a directory: %s", dirPath)
	}

	err = filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		if !info.IsDir() {
			ext := strings.ToLower(filepath.Ext(path))
			if strings.Contains(ext, fileExt) {
				relPath, err := filepath.Rel(dirPath, path)
				if err != nil {
					return err
				}
				filenames = append(filenames, relPath)
			}
		}
		return nil
	})

	return filenames, err
}


================================================
FILE: internal/pkg/hls/const.go
================================================
package hls

const SegmentExtension = ".ts"
const timeFormat = "2006-01-02T15:04:05.000Z"

const (
	DefaultMaxSegmentDuration = 5
	DefaultLiveSegmentsAmount = 3
)


================================================
FILE: internal/pkg/hls/playlist.go
================================================
// Package hls provides functionality for handling HTTP Live Streaming (HLS) playlists and segments.
package hls

import (
	"math"
	"strconv"
	"time"
)

// Playlist represents an HLS playlist structure.
type Playlist struct {
	LiveSegmentsAmount int // The number of live segments in the playlist.
	MaxSegmentDuration int // The maximum duration (in seconds) of a segment in the playlist.

	mediaSequence        int64
	disconSequence       int64
	lastDisconUpdate     time.Time
	currentTrackSegments []*Segment
	nextTrackSegments    []*Segment
	currentSegmentPath   string
}

// NewPlaylist creates and returns a new Playlist instance with the provided current and next track segments.
// It initializes the playlist with default values for live segments amount, max segment duration, media sequence,
// discontinuity sequence, and last discontinuity update time.
//
// Parameters:
//   - cur: The list of segments for the current track.
//   - next: The list of segments for the next track.
//
// Returns:
//   - A pointer to the newly created Playlist instance.
func NewPlaylist(cur, next []*Segment) *Playlist {
	return &Playlist{
		LiveSegmentsAmount: DefaultLiveSegmentsAmount,
		MaxSegmentDuration: DefaultMaxSegmentDuration,

		mediaSequence:    0,
		disconSequence:   0,
		lastDisconUpdate: time.Now(),

		currentTrackSegments: cur,
		nextTrackSegments:    next,

		currentSegmentPath: "",
	}
}

// Generate constructs and returns the HLS playlist as a string based on the elapsed time.
// It updates the discontinuity sequence, calculates the starting segment index, collects live segments,
// and formats them into the HLS playlist format.
//
// Parameters:
//   - elapsedTime: The elapsed time in seconds used to determine the current segment index.
//
// Returns:
//   - A string representing the generated HLS playlist.
func (p *Playlist) Generate(elapsedTime float64) string {
	offset := math.Mod(elapsedTime, float64(p.MaxSegmentDuration))
	liveSegments := p.currentSegments(elapsedTime)
	prevSegmentPath := p.currentSegmentPath

	if len(liveSegments) > 0 {
		p.currentSegmentPath = liveSegments[0].Path
	}

	p.UpdateDisconSequence(elapsedTime)
	if prevSegmentPath != p.currentSegmentPath {
		p.mediaSequence++
	}

	playlist := hlsHeader(p.MaxSegmentDuration, p.mediaSequence, p.disconSequence, offset)
	for _, seg := range liveSegments {
		playlist += hlsSegment(seg.Duration, seg.Path, seg.IsFirst)
	}

	return playlist
}

// Next updates the playlist by moving the next track segments to the current track segments
// and assigning the provided segments as the new next track segments.
//
// Parameters:
//   - next: The new list of segments to be set as the next track segments.
func (p *Playlist) Next(next []*Segment) {
	p.currentTrackSegments = p.nextTrackSegments
	p.nextTrackSegments = next
}

// ChangeNext replays segments for the next track.
//
// Parameters:
//   - next: The new list of segments to be set as the next track segments.
func (p *Playlist) ChangeNext(next []*Segment) {
	p.nextTrackSegments = next
}

// AddSegments appends the provided segments to the next track segments list.
//
// Parameters:
//   - segments: The list of segments to append to the next track segments.
func (p *Playlist) AddSegments(segments []*Segment) {
	p.nextTrackSegments = append(p.nextTrackSegments, segments...)
}

// SetMediaSequence set a new sequence number for mediaSequence.
func (p *Playlist) SetMediaSequence(sequence int64) {
	p.mediaSequence = sequence
}

// UpdateDisconSequence updates the discontinuity sequence if a discontinuity is detected.
//
// Parameters:
//   - elapsedTime: The elapsed time in seconds used to calculate the current segment index.
func (p *Playlist) UpdateDisconSequence(elapsedTime float64) {
	elapsedFromLastUpdate := time.Until(p.lastDisconUpdate).Seconds()
	if math.Abs(elapsedFromLastUpdate) < float64(p.MaxSegmentDuration) {
		return
	}

	index := p.calcCurrentSegmentIndex(elapsedTime)

	// if the current track segment is the second and it is not the very first track,
	// there was a discontinuty, so we increment the discontinuty counter
	if index == 1 && p.mediaSequence > 1 {
		p.disconSequence++
		p.lastDisconUpdate = time.Now()
	}
}

func (p *Playlist) FirstNextTrackSegment() *Segment {
	if len(p.nextTrackSegments) > 0 {
		return p.nextTrackSegments[0]
	}

	return nil
}

// currentSegments gathers enough segments from current and next tracks to meet liveSegmentsAmount
func (p *Playlist) currentSegments(elapsedTime float64) []*Segment {
	startIndex := p.calcCurrentSegmentIndex(elapsedTime)
	liveSegments := make([]*Segment, 0, p.LiveSegmentsAmount)

	if startIndex < len(p.currentTrackSegments) {
		endIndex := startIndex + p.LiveSegmentsAmount
		if endIndex >= len(p.currentTrackSegments) {
			endIndex = len(p.currentTrackSegments)
		}

		liveSegments = append(liveSegments, p.currentTrackSegments[startIndex:endIndex]...)
	}

	if len(liveSegments) < p.LiveSegmentsAmount {
		required := p.LiveSegmentsAmount - len(liveSegments)
		liveSegments = append(liveSegments, p.nextTrackSegments[:min(len(p.nextTrackSegments), required)]...)
	}

	return liveSegments
}

func (p *Playlist) calcCurrentSegmentIndex(elapsedTime float64) int {
	return int(math.Floor(elapsedTime / float64(p.MaxSegmentDuration)))
}

// hlsHeader generates the header string for an HLS playlist with the specified target duration.
func hlsHeader(dur int, mediaSeq, disconSeq int64, offset float64) string {
	currentTime := time.Now().UTC().Round(time.Millisecond).Format(timeFormat)
	return "#EXTM3U\n" +
		"#EXT-X-VERSION:6\n" +
		"#EXT-X-PROGRAM-DATE-TIME:" + currentTime + "\n" +
		"#EXT-X-TARGETDURATION:" + strconv.Itoa(dur) + "\n" +
		"#EXT-X-MEDIA-SEQUENCE:" + strconv.FormatInt(mediaSeq, 10) + "\n" +
		"#EXT-X-DISCONTINUITY-SEQUENCE:" + strconv.FormatInt(disconSeq, 10) + "\n" +
		"#EXT-X-START:TIME-OFFSET=" + strconv.FormatFloat(offset, 'f', 2, 64) + "\n"
}

// hlsSegment generates an HLS segment entry with the specified duration and path.
func hlsSegment(dur float64, path string, isDiscon bool) string {
	disconTag := ""

	if isDiscon {
		disconTag = "#EXT-X-DISCONTINUITY\n"
	}

	duration := strconv.FormatFloat(dur, 'f', 2, 64)
	return disconTag +
		"#EXTINF:" + duration + ",\n" +
		path + "\n"
}


================================================
FILE: internal/pkg/hls/playlist_test.go
================================================
package hls

import (
	"reflect"
	"strings"
	"testing"
)

func TestNewPlaylist(t *testing.T) {
	current := []*Segment{{Duration: 10.5, Path: "segment1.ts"}}
	next := []*Segment{{Duration: 9.0, Path: "segment2.ts"}}
	playlist := NewPlaylist(current, next)

	if playlist.MaxSegmentDuration != DefaultMaxSegmentDuration {
		t.Errorf("Expected maxSegmentDuration to be %d, got %d", DefaultMaxSegmentDuration, playlist.MaxSegmentDuration)
	}

	if playlist.LiveSegmentsAmount != DefaultLiveSegmentsAmount {
		t.Errorf("Expected liveSegmentsAmount to be %d, got %d", DefaultLiveSegmentsAmount, playlist.LiveSegmentsAmount)
	}

	if len(playlist.currentTrackSegments) != len(current) {
		t.Errorf("Expected %d segment in currentTrackSegments, got %d", len(current), len(playlist.currentTrackSegments))
	}

	if len(playlist.nextTrackSegments) != len(next) {
		t.Errorf("Expected %d segment in nextTrackSegments, got %d", len(next), len(playlist.nextTrackSegments))
	}
}

func TestGenerate(t *testing.T) {
	cases := []struct {
		name          string
		current       []*Segment
		next          []*Segment
		elapsedTime   float64
		expectedPaths []string
		unexpected    []string
	}{
		{
			name: "full from current track",
			current: []*Segment{
				{Duration: 5.0, Path: "segment1.ts"},
				{Duration: 5.0, Path: "segment2.ts"},
				{Duration: 5.0, Path: "segment3.ts"},
			},
			next: []*Segment{
				{Duration: 5.0, Path: "segment4.ts"},
			},
			elapsedTime:   0.0,
			expectedPaths: []string{"segment1.ts", "segment2.ts", "segment3.ts"},
			unexpected:    []string{"segment4.ts"},
		},
		{
			name: "partial from current and next track",
			current: []*Segment{
				{Duration: 5.0, Path: "segment1.ts"},
				{Duration: 5.0, Path: "segment2.ts"},
			},
			next: []*Segment{
				{Duration: 5.0, Path: "segment3.ts"},
				{Duration: 5.0, Path: "segment4.ts"},
			},
			elapsedTime:   5.0,
			expectedPaths: []string{"segment2.ts", "segment3.ts", "segment4.ts"},
			unexpected:    []string{"segment1.ts"},
		},
		{
			name: "full from next track",
			current: []*Segment{
				{Duration: 5.0, Path: "segment1.ts"},
			},
			next: []*Segment{
				{Duration: 5.0, Path: "segment2.ts"},
				{Duration: 5.0, Path: "segment3.ts"},
				{Duration: 5.0, Path: "segment4.ts"},
			},
			elapsedTime:   20.0,
			expectedPaths: []string{"segment2.ts", "segment3.ts", "segment4.ts"},
			unexpected:    []string{"segment1.ts"},
		},
		{
			name: "not enough segments",
			current: []*Segment{
				{Duration: 5.0, Path: "segment1.ts"},
			},
			next: []*Segment{
				{Duration: 5.0, Path: "segment2.ts"},
			},
			elapsedTime:   0.0,
			expectedPaths: []string{"segment1.ts", "segment2.ts"},
			unexpected:    []string{"segment3.ts"},
		},
		{
			name:          "empty tracks",
			current:       []*Segment{},
			next:          []*Segment{},
			elapsedTime:   0.0,
			expectedPaths: []string{},
			unexpected:    []string{"segment1.ts"},
		},
		{
			name: "start index beyond current track",
			current: []*Segment{
				{Duration: 5.0, Path: "segment1.ts"},
			},
			next: []*Segment{
				{Duration: 5.0, Path: "segment2.ts"},
				{Duration: 5.0, Path: "segment3.ts"}},
			elapsedTime:   20.0,
			expectedPaths: []string{"segment2.ts", "segment3.ts"},
			unexpected:    []string{"segment1.ts"},
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			playlist := NewPlaylist(c.current, c.next)
			got := playlist.Generate(c.elapsedTime)

			for _, expected := range c.expectedPaths {
				if !strings.Contains(got, expected) {
					t.Errorf("Expected segment %s not found in playlist: %s", expected, got)
				}
			}

			for _, path := range c.unexpected {
				if strings.Contains(got, path) {
					t.Errorf("Unexpected segment %s found in playlist: %s", path, got)
				}
			}

			if !strings.HasPrefix(got, "#EXTM3U") {
				t.Errorf("Playlist header is missing: %s", got)
			}
		})
	}
}

func TestNext(t *testing.T) {
	current := []*Segment{
		{Duration: 5.0, Path: "segment1.ts"},
		{Duration: 5.0, Path: "segment2.ts"},
	}
	next := []*Segment{
		{Duration: 5.0, Path: "segment3.ts"},
	}

	playlist := NewPlaylist(current, next)

	playlist.Next([]*Segment{{Duration: 8.0, Path: "segment4.ts"}})

	if len(playlist.currentTrackSegments) != 1 || playlist.currentTrackSegments[0].Path != "segment3.ts" {
		t.Errorf("Expected currentTrackSegments to contain nextTrackSegments, got: %v", playlist.currentTrackSegments)
	}

	if len(playlist.nextTrackSegments) != 1 || playlist.nextTrackSegments[0].Path != "segment4.ts" {
		t.Errorf("Expected nextTrackSegments to be updated, got: %v", playlist.nextTrackSegments)
	}
}

func TestAddSegments(t *testing.T) {
	current := []*Segment{{Duration: 5.0, Path: "segment1.ts"}}
	next := []*Segment{{Duration: 5.0, Path: "segment2.ts"}}
	playlist := NewPlaylist(current, next)

	newSegments := []*Segment{
		{Duration: 8.0, Path: "segment3.ts"},
		{Duration: 7.5, Path: "segment4.ts"},
	}
	playlist.AddSegments(newSegments)

	if len(playlist.nextTrackSegments) != 3 {
		t.Errorf("Expected nextTrackSegments to contain 3 segments, got: %d", len(playlist.nextTrackSegments))
	}

	if playlist.nextTrackSegments[2].Path != "segment4.ts" {
		t.Errorf("Expected last segment to be segment4.ts, got: %s", playlist.nextTrackSegments[2].Path)
	}
}

func TestCollectLiveSegments(t *testing.T) {
	t.Run("full from current track", func(t *testing.T) {
		current := []*Segment{
			{Duration: 5.0, Path: "segment1.ts"},
			{Duration: 5.0, Path: "segment2.ts"},
			{Duration: 5.0, Path: "segment3.ts"},
		}
		next := []*Segment{
			{Duration: 5.0, Path: "segment4.ts"},
		}

		playlist := NewPlaylist(current, next)

		got := playlist.currentSegments(0)
		expected := current

		if !reflect.DeepEqual(got, expected) {
			t.Errorf("Expected %v, got %v", expected, got)
		}
	})

	t.Run("partitial from current track", func(t *testing.T) {
		current := []*Segment{
			{Duration: 5.0, Path: "segment1.ts"},
			{Duration: 5.0, Path: "segment2.ts"},
			{Duration: 5.0, Path: "segment3.ts"},
		}
		next := []*Segment{
			{Duration: 5.0, Path: "segment4.ts"},
		}
		playlist := NewPlaylist(current, next)

		got := playlist.currentSegments(0)
		expected := []*Segment{
			{Duration: 5.0, Path: "segment1.ts"},
			{Duration: 5.0, Path: "segment2.ts"},
			{Duration: 5.0, Path: "segment3.ts"},
		}

		if !reflect.DeepEqual(got, expected) {
			t.Errorf("Expected %v, got %v", expected, got)
		}
	})

	t.Run("partial from current and next track", func(t *testing.T) {
		current := []*Segment{
			{Duration: 5.0, Path: "segment1.ts"},
			{Duration: 5.0, Path: "segment2.ts"},
		}
		next := []*Segment{
			{Duration: 5.0, Path: "segment3.ts"},
			{Duration: 5.0, Path: "segment4.ts"},
		}
		playlist := NewPlaylist(current, next)

		got := playlist.currentSegments(1)
		expected := []*Segment{
			{Duration: 5.0, Path: "segment1.ts"},
			{Duration: 5.0, Path: "segment2.ts"},
			{Duration: 5.0, Path: "segment3.ts"},
		}

		if !reflect.DeepEqual(got, expected) {
			t.Errorf("Expected %v, got %v", expected, got)
		}
	})

	t.Run("full from next track", func(t *testing.T) {
		current := []*Segment{
			{Duration: 5.0, Path: "segment1.ts"},
		}
		next := []*Segment{
			{Duration: 5.0, Path: "segment2.ts"},
			{Duration: 5.0, Path: "segment3.ts"},
			{Duration: 5.0, Path: "segment4.ts"},
		}
		playlist := NewPlaylist(current, next)

		got := playlist.currentSegments(DefaultMaxSegmentDuration)
		expected := next

		if !reflect.DeepEqual(got, expected) {
			t.Errorf("Expected %v, got %v", expected, got)
		}
	})

	t.Run("not enough segments", func(t *testing.T) {
		current := []*Segment{
			{Duration: 5.0, Path: "segment1.ts"},
		}
		next := []*Segment{
			{Duration: 5.0, Path: "segment2.ts"},
		}
		playlist := NewPlaylist(current, next)

		got := playlist.currentSegments(0)
		expected := []*Segment{
			{Duration: 5.0, Path: "segment1.ts"},
			{Duration: 5.0, Path: "segment2.ts"},
		}

		if !reflect.DeepEqual(got, expected) {
			t.Errorf("Expected %v, got %v", expected, got)
		}
	})

	t.Run("start index beyond current track", func(t *testing.T) {
		current := []*Segment{
			{Duration: 5.0, Path: "segment1.ts"},
		}
		next := []*Segment{
			{Duration: 5.0, Path: "segment2.ts"},
			{Duration: 5.0, Path: "segment3.ts"},
		}
		playlist := NewPlaylist(current, next)

		got := playlist.currentSegments(DefaultMaxSegmentDuration)
		expected := next

		if !reflect.DeepEqual(got, expected) {
			t.Errorf("Expected %v, got %v", expected, got)
		}
	})

	t.Run("empty tracks", func(t *testing.T) {
		current := []*Segment{}
		next := []*Segment{}
		playlist := NewPlaylist(current, next)

		got := playlist.currentSegments(0)
		expected := []*Segment{}

		if !reflect.DeepEqual(got, expected) {
			t.Errorf("Expected %v, got %v", expected, got)
		}
	})
}

func TestHlsHeader(t *testing.T) {
	cases := []struct {
		name      string
		dur       int
		mediaSeq  int64
		disconSeq int64
		offset    float64
		expected  []string
	}{
		{
			name:      "Basic case",
			dur:       10,
			mediaSeq:  1,
			disconSeq: 0,
			offset:    0.0,
			expected: []string{
				"#EXTM3U\n",
				"#EXT-X-VERSION:6\n",
				"#EXT-X-TARGETDURATION:10\n",
				"#EXT-X-MEDIA-SEQUENCE:1\n",
				"#EXT-X-DISCONTINUITY-SEQUENCE:0\n",
				"#EXT-X-START:TIME-OFFSET=0.00\n",
			},
		},
		{
			name:      "Non-zero discontinuity sequence",
			dur:       15,
			mediaSeq:  5,
			disconSeq: 2,
			offset:    5.5,
			expected: []string{
				"#EXTM3U\n",
				"#EXT-X-VERSION:6\n",
				"#EXT-X-TARGETDURATION:15\n",
				"#EXT-X-MEDIA-SEQUENCE:5\n",
				"#EXT-X-DISCONTINUITY-SEQUENCE:2\n",
				"#EXT-X-START:TIME-OFFSET=5.50\n",
			},
		},
		{
			name:      "Zero values",
			dur:       0,
			mediaSeq:  0,
			disconSeq: 0,
			offset:    0.0,
			expected: []string{
				"#EXTM3U\n",
				"#EXT-X-VERSION:6\n",
				"#EXT-X-TARGETDURATION:0\n",
				"#EXT-X-MEDIA-SEQUENCE:0\n",
				"#EXT-X-DISCONTINUITY-SEQUENCE:0\n",
				"#EXT-X-START:TIME-OFFSET=0.00\n",
			},
		},
		{
			name:      "Large values",
			dur:       999,
			mediaSeq:  123456789,
			disconSeq: 987654321,
			offset:    123.45,
			expected: []string{
				"#EXTM3U\n",
				"#EXT-X-VERSION:6\n",
				"#EXT-X-TARGETDURATION:999\n",
				"#EXT-X-MEDIA-SEQUENCE:123456789\n",
				"#EXT-X-DISCONTINUITY-SEQUENCE:987654321\n",
				"#EXT-X-START:TIME-OFFSET=123.45\n",
			},
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := hlsHeader(c.dur, c.mediaSeq, c.disconSeq, c.offset)

			for _, expect := range c.expected {
				if !strings.Contains(got, expect) {
					t.Errorf("hlsHeader(%d, %d, %d, %.2f) = %q; want %q", c.dur, c.mediaSeq, c.disconSeq, c.offset, got, c.expected)
				}
			}

		})
	}
}

func TestHlsSegment(t *testing.T) {
	cases := []struct {
		name     string
		dur      float64
		path     string
		isDiscon bool
		expected string
	}{
		{
			name:     "Basic segment without discontinuity",
			dur:      5.5,
			path:     "segment0.ts",
			isDiscon: false,
			expected: "#EXTINF:5.50,\nsegment0.ts\n",
		},
		{
			name:     "Segment with discontinuity",
			dur:      8.333,
			path:     "segment1.ts",
			isDiscon: true,
			expected: "#EXT-X-DISCONTINUITY\n#EXTINF:8.33,\nsegment1.ts\n",
		},
		{
			name:     "Zero duration segment without discontinuity",
			dur:      0,
			path:     "segment2.ts",
			isDiscon: false,
			expected: "#EXTINF:0.00,\nsegment2.ts\n",
		},
		{
			name:     "Large duration segment with discontinuity",
			dur:      1234.56789,
			path:     "segment3.ts",
			isDiscon: true,
			expected: "#EXT-X-DISCONTINUITY\n#EXTINF:1234.57,\nsegment3.ts\n",
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			result := hlsSegment(c.dur, c.path, c.isDiscon)
			if result != c.expected {
				t.Errorf("hlsSegment(%f, %q, %v) = %q; want %q", c.dur, c.path, c.isDiscon, result, c.expected)
			}
		})
	}
}


================================================
FILE: internal/pkg/hls/segment.go
================================================
package hls

import (
	"math"
	"path/filepath"
	"strconv"
)

// Segment represents a single segment in an HLS playlist.
type Segment struct {
	Duration float64 // The length of the segment in seconds.
	Path     string  // The file path or URL of the segment.
	IsFirst  bool    // A flag indicating whether this segment is the first segment in the track.
}

// NewSegment creates and returns a new Segment instance with the provided duration, path, and first segment flag.
//
// Parameters:
//   - duration: The duration of the segment in seconds.
//   - path: The file path or URL of the segment.
//   - isFirst: A boolean flag indicating whether this segment is the first segment in the track.
//
// Returns:
//   - A pointer to the newly created Segment instance.
func NewSegment(duration float64, path string, isFirst bool) *Segment {
	return &Segment{
		Duration: duration,
		Path:     path,
		IsFirst:  isFirst,
	}
}

// GenerateSegments creates a list of Segment instances for a given track based on its duration and segment duration.
// It divides the track into segments of the specified duration and generates metadata for each segment.
//
// Parameters:
//   - trackDuration: The total duration of the track in seconds.
//   - segmentDuration: The desired duration of each segment in seconds.
//   - trackID: The unique identifier for the track, used to generate segment names.
//   - outDir: The output directory where the segments will be stored.
//
// Returns:
//   - A slice of pointers to Segment instances representing the generated segments.
func GenerateSegments(trackDuration float64, segmentDuration int, trackID, outDir string) []*Segment {
	if trackDuration <= 0 || segmentDuration <= 0 {
		return []*Segment{}
	}

	// Calculate total possible number of segments (rounded up)
	totalSegments := int(math.Round(trackDuration / float64(segmentDuration)))
	segments := make([]*Segment, 0, totalSegments)

	remaining := trackDuration
	index := 0

	// Generate segments until the entire track is covered
	for remaining > 0 {
		segName := trackID + strconv.Itoa(index) + SegmentExtension
		segPath := filepath.Join(outDir, segName)

		// Use the smaller of the remaining or full segment duration
		duration := math.Min(remaining, float64(segmentDuration))
		isFirst := index == 0
		segments = append(segments, NewSegment(duration, segPath, isFirst))

		remaining -= duration
		index++
	}

	return segments
}


================================================
FILE: internal/pkg/hls/segment_test.go
================================================
package hls

import (
	"testing"
)

func TestNewSegment(t *testing.T) {
	cases := []struct {
		name     string
		duration float64
		path     string
		isFirst  bool
		expected *Segment
	}{
		{
			name:     "Basic segment",
			duration: 5.5,
			path:     "segment0.ts",
			isFirst:  false,
			expected: &Segment{
				Duration: 5.5,
				Path:     "segment0.ts",
				IsFirst:  false,
			},
		},
		{
			name:     "First segment",
			duration: 8.333,
			path:     "segment1.ts",
			isFirst:  true,
			expected: &Segment{
				Duration: 8.333,
				Path:     "segment1.ts",
				IsFirst:  true,
			},
		},
		{
			name:     "Zero duration",
			duration: 0,
			path:     "segment2.ts",
			isFirst:  false,
			expected: &Segment{
				Duration: 0,
				Path:     "segment2.ts",
				IsFirst:  false,
			},
		},
		{
			name:     "Empty path",
			duration: 10.0,
			path:     "",
			isFirst:  false,
			expected: &Segment{
				Duration: 10.0,
				Path:     "",
				IsFirst:  false,
			},
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := NewSegment(c.duration, c.path, c.isFirst)

			if got.Duration != c.expected.Duration {
				t.Errorf("Duration = %f; want %f", got.Duration, c.expected.Duration)
			}
			if got.Path != c.expected.Path {
				t.Errorf("Path = %q; want %q", got.Path, c.expected.Path)
			}
			if got.IsFirst != c.expected.IsFirst {
				t.Errorf("IsFirst = %v; want %v", got.IsFirst, c.expected.IsFirst)
			}
		})
	}
}

func TestGenerateSegments(t *testing.T) {
	cases := []struct {
		name             string
		trackDuration    float64
		segmentDuration  int
		trackID          string
		outDir           string
		expectedSegments []*Segment
	}{
		{
			name:            "Basic case",
			trackDuration:   10.0,
			segmentDuration: 3,
			trackID:         "track1",
			outDir:          "/out",
			expectedSegments: []*Segment{
				{Duration: 3.0, Path: "/out/track10.ts", IsFirst: true},
				{Duration: 3.0, Path: "/out/track11.ts", IsFirst: false},
				{Duration: 3.0, Path: "/out/track12.ts", IsFirst: false},
				{Duration: 1.0, Path: "/out/track13.ts", IsFirst: false},
			},
		},
		{
			name:             "Zero track duration",
			trackDuration:    0,
			segmentDuration:  3,
			trackID:          "track2",
			outDir:           "/out",
			expectedSegments: nil,
		},
		{
			name:             "Zero segment duration",
			trackDuration:    10.0,
			segmentDuration:  0,
			trackID:          "track3",
			outDir:           "/out",
			expectedSegments: nil,
		},
		{
			name:            "Exact division of track duration",
			trackDuration:   9.0,
			segmentDuration: 3,
			trackID:         "track4",
			outDir:          "/out",
			expectedSegments: []*Segment{
				{Duration: 3.0, Path: "/out/track40.ts", IsFirst: true},
				{Duration: 3.0, Path: "/out/track41.ts", IsFirst: false},
				{Duration: 3.0, Path: "/out/track42.ts", IsFirst: false},
			},
		},
		{
			name:            "Large track duration",
			trackDuration:   25.0,
			segmentDuration: 10,
			trackID:         "track5",
			outDir:          "/out",
			expectedSegments: []*Segment{
				{Duration: 10.0, Path: "/out/track50.ts", IsFirst: true},
				{Duration: 10.0, Path: "/out/track51.ts", IsFirst: false},
				{Duration: 5.0, Path: "/out/track52.ts", IsFirst: false},
			},
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := GenerateSegments(c.trackDuration, c.segmentDuration, c.trackID, c.outDir)

			if len(got) != len(c.expectedSegments) {
				t.Fatalf("Expected %d segments, got %d", len(c.expectedSegments), len(got))
			}

			for i, gotSegment := range got {
				if gotSegment.Duration != c.expectedSegments[i].Duration {
					t.Errorf("Segment %d: expected duration %f, got %f", i, c.expectedSegments[i].Duration, gotSegment.Duration)
				}
				if gotSegment.Path != c.expectedSegments[i].Path {
					t.Errorf("Segment %d: expected path %q, got %q", i, c.expectedSegments[i].Path, gotSegment.Path)
				}
				if gotSegment.IsFirst != c.expectedSegments[i].IsFirst {
					t.Errorf("Segment %d: expected IsFirst %v, got %v", i, c.expectedSegments[i].IsFirst, gotSegment.IsFirst)
				}
			}
		})
	}
}


================================================
FILE: internal/pkg/sql/sql.go
================================================
package sql

import (
	"database/sql"
	"fmt"
	"strings"
)

func BuildInClause(column string, num int) string {
	if num == 0 {
		return fmt.Sprintf("%s IN ()", column) // Edge case: Empty IN clause (should be avoided in real queries)
	}

	placeholders := strings.Repeat("?,", num)
	placeholders = placeholders[:len(placeholders)-1] // Remove trailing comma
	return fmt.Sprintf("%s IN (%s)", column, placeholders)
}

func ColumnExists(db *sql.DB, tableName, columnName string) (bool, error) {
	query := `
        SELECT COUNT(*) > 0
        FROM pragma_table_info(?)
        WHERE name = ?
    `

	var exists bool
	err := db.QueryRow(query, tableName, columnName).Scan(&exists)
	if err != nil {
		return false, fmt.Errorf("failed to check column existence: %w", err)
	}

	return exists, nil
}

func TableExists(db *sql.DB, tableName string) (bool, error) {
	query := `
        SELECT COUNT(*) > 0
        FROM sqlite_master
        WHERE type = 'table' AND name = ?
    `

	var exists bool
	err := db.QueryRow(query, tableName).Scan(&exists)
	if err != nil {
		return false, fmt.Errorf("failed to check table existence: %w", err)
	}

	return exists, nil
}


================================================
FILE: internal/pkg/sse/emitter.go
================================================
package sse

import (
	"sync"
)

// Emitter manages a set of subscribers and broadcasts events to them.
type Emitter struct {
	subscribers sync.Map // A thread-safe map storing subscriber channels.
}

// NewEmitter creates and returns a new Emitter instance.
//
// Returns:
//   - A pointer to a new Emitter.
func NewEmitter() *Emitter {
	return &Emitter{}
}

// RegisterEvent broadcasts an event with the specified name and data
// to all currently subscribed channels.
//
// Parameters:
//   - name: The event name/type.
//   - data: The string payload of the event.
func (ee *Emitter) RegisterEvent(name, data string) {
	event := NewEvent(name, data)
	ee.subscribers.Range(func(key, value any) bool {
		eventChan := key.(chan *Event)
		eventChan <- event
		return true
	})
}

// CountSubscribers returns the number of currently active subscribers.
//
// Returns:
//   - An integer count of subscriber channels.
func (ee *Emitter) CountSubscribers() int {
	count := 0
	ee.subscribers.Range(func(key, value any) bool {
		count++
		return true
	})
	return count
}

// Subscribe adds a new subscriber channel to receive events.
//
// Parameters:
//   - eventChan: A channel to which events will be sent.
func (ee *Emitter) Subscribe(eventChan chan *Event) {
	ee.subscribers.Store(eventChan, true)
}

// Unsubscribe removes a previously added subscriber channel.
//
// Parameters:
//   - eventChan: The channel to remove from the list of subscribers.
func (ee *Emitter) Unsubscribe(eventChan chan *Event) {
	ee.subscribers.Delete(eventChan)
}


================================================
FILE: internal/pkg/sse/event.go
================================================
// Package events provides a lightweight structure and utilities for creating
// and formatting Server-Sent Events (SSE) to be sent over HTTP connections.
package sse

import (
	"fmt"
	"strings"
)

// Event represents a Server-Sent Event (SSE) with a name and data payload.
type Event struct {
	Name string // The name/type of the event (used as "event:" in SSE format).
	Data string // The data payload of the event (used as "data:" in SSE format).
}

// NewEvent creates a new Event instance with the given name and data.
//
// Parameters:
//   - name: The name/type of the event.
//   - data: The string data payload associated with the event.
//
// Returns:
//   - A pointer to the newly created Event.
func NewEvent(name, data string) *Event {
	return &Event{
		Name: name,
		Data: data,
	}
}

// Stringify converts the Event into a string formatted for Server-Sent Events (SSE).
// The format includes the "event" and "data" fields as per SSE specification,
// followed by a double newline to indicate the end of the event.
//
// Returns:
//   - A string representation of the event in SSE format.
func (e *Event) Stringify() string {
	var builder strings.Builder

	if e.Name != "" {
		builder.WriteString(fmt.Sprintf("event: %s\n", e.Name))
	}

	if e.Data != "" {
		builder.WriteString(fmt.Sprintf("data: %s\n", e.Data))
	}

	builder.WriteString("\n")
	return builder.String()
}


================================================
FILE: internal/pkg/ulid/ulid.go
================================================
package ulid

import (
	"crypto/rand"
	"fmt"
	"strings"
	"sync"
	"time"

	"github.com/oklog/ulid/v2"
)

const Length = ulid.EncodedSize

var (
	once     sync.Once
	instance *generator
)

type generator struct {
	timestamp uint64
	entropy   *ulid.MonotonicEntropy
}

func New() string {
	u := initGenerator()

	return strings.ToLower(ulid.MustNew(u.timestamp, u.entropy).String())
}

func Verify(s string) error {
	_, err := ulid.Parse(s)
	if err != nil {
		return fmt.Errorf("id %s is not allowed, must satisfy the ulid format", s)
	}
	return nil
}

func initGenerator() *generator {
	once.Do(func() {
		instance = &generator{
			timestamp: ulid.Timestamp(time.Now()),
			entropy:   ulid.Monotonic(rand.Reader, 0),
		}
	})

	return instance
}


================================================
FILE: internal/playback/service.go
================================================
package playback

import (
	"log/slog"
	"time"
)

type Service struct {
	store Store
	log   *slog.Logger
}

func NewService(store Store) *Service {
	return &Service{
		store: store,
	}
}

// AddPlaybackHistory logs a playback event for a given track.
//
// Parameters:
//   - trackName: The name of the track that was played.
func (s *Service) AddPlaybackHistory(trackName string) {
	err := s.store.AddPlaybackHistory(time.Now().Unix(), trackName)
	if err != nil {
		s.log.Error("Failed to add playback history: " + err.Error())
	}
}

// RecentPlaybackHistory retrieves the most recent playback history records.
//
// Parameters:
//   - limit: The maximum number of history entries to retrieve.
//
// Returns:
//   - A slice of PlaybackHistory pointers, or an error.
func (s *Service) RecentPlaybackHistory(limit int) ([]*History, error) {
	history, err := s.store.RecentPlaybackHistory(limit)
	return history, err
}

// DeleteOldPlaybackHistory removes outdated playback history entries from the store.
func (s *Service) DeleteOldPlaybackHistory() {
	_, err := s.store.DeleteOldPlaybackHistory()
	if err != nil {
		s.log.Warn("Failed to delete old playback history: " + err.Error())
	}
}


================================================
FILE: internal/playback/state.go
================================================
// Package playback manages audio playback state, track transitions, and HLS playlist generation.
// It coordinates the timing and sequencing of audio tracks, maintaining synchronized state
// for streaming playback, including current position, play/pause control, and playlist updates.
// This package interacts with the track service to load tracks, generate segments, and handle
// queue changes in a thread-safe manner.
package playback

import (
	"errors"
	"log/slog"
	"strings"
	"sync"
	"time"

	"github.com/cheatsnake/airstation/internal/pkg/hls"
	"github.com/cheatsnake/airstation/internal/queue"
	"github.com/cheatsnake/airstation/internal/track"
)

// State represents the current playback state of the application, including the currently playing track,
// elapsed playback time, playlist management, and synchronization tools for safe concurrent access.
type State struct {
	CurrentTrack        *track.Track `json:"currentTrack"`        // The currently playing track
	CurrentTrackElapsed float64      `json:"currentTrackElapsed"` // Seconds elapsed since the current track started playing
	IsPlaying           bool         `json:"isPlaying"`           // Whether a track is currently playing
	UpdatedAt           int64        `json:"updatedAt"`           // Unix timestamp of the last state update

	NewTrackNotify chan string `json:"-"` // Channel to notify when a new track starts playing
	PlayNotify     chan bool   `json:"-"` // Channel to notify when playback starts
	PauseNotify    chan bool   `json:"-"` // Channel to notify when playback is paused

	PlaylistStr string        `json:"-"` // Current HLS playlist as a string
	playlist    *hls.Playlist // Internal representation of the HLS playlist
	playlistDir string        // Directory where HLS playlist segments are stored

	refreshCount    int64   // Number of state refresh cycles completed
	refreshInterval float64 // Time interval (in seconds) between state updates

	trackService    *track.Service
	queueService    *queue.Service
	playbackService *Service

	log   *slog.Logger
	mutex sync.Mutex
}

// NewState creates and initializes a new playback State instance.
func NewState(ts *track.Service, qs *queue.Service, ps *Service, tmpDir string, log *slog.Logger) *State {
	return &State{
		CurrentTrack:        nil,
		CurrentTrackElapsed: 0,
		IsPlaying:           false,
		UpdatedAt:           time.Now().Unix(),

		NewTrackNotify: make(chan string),
		PlayNotify:     make(chan bool),
		PauseNotify:    make(chan bool),

		trackService:    ts,
		queueService:    qs,
		playbackService: ps,

		refreshCount:    0,
		playlistDir:     tmpDir,
		refreshInterval: 1,

		log: log,
	}
}

// Run starts the state update loop which refreshes playback progress and switches tracks when needed.
func (s *State) Run() {
	ticker := time.NewTicker(time.Duration(s.refreshInterval) * time.Second)
	defer ticker.Stop()

	for range ticker.C {
		if !s.IsPlaying {
			continue
		}

		s.mutex.Lock()
		s.CurrentTrackElapsed += s.refreshInterval
		s.refreshCount++

		if s.CurrentTrackElapsed >= s.CurrentTrack.Duration {
			err := s.loadNextTrack()
			if err != nil {
				s.log.Error(err.Error())
			}

			go s.queueService.CleanupHLSPlaylists(s.playlistDir)
			go s.playbackService.AddPlaybackHistory(s.CurrentTrack.Name)
		}

		s.PlaylistStr = s.playlist.Generate(s.CurrentTrackElapsed)
		s.UpdatedAt = time.Now().Unix()
		s.mutex.Unlock()
	}
}

// Play starts playback by loading the current and next tracks into the HLS playlist.
func (s *State) Play() error {
	current, next, err := s.queueService.CurrentAndNextTrack()
	if err != nil {
		return err
	}

	if current == nil {
		return errors.New("playback queue is empty")
	}

	err = s.initHLSPlaylist(current, next)
	if err != nil {
		return err
	}

	s.mutex.Lock()
	s.CurrentTrack = current
	s.PlaylistStr = s.playlist.Generate(s.CurrentTrackElapsed)
	s.UpdatedAt = time.Now().Unix()
	s.IsPlaying = true
	s.mutex.Unlock()

	s.PlayNotify <- true
	go s.playbackService.AddPlaybackHistory(current.Name)

	return nil
}

// Pause stops playback, clears current playback state and playlist.
func (s *State) Pause() {
	s.mutex.Lock()
	s.CurrentTrack = nil
	s.CurrentTrackElapsed = 0
	s.playlist = nil
	s.PlaylistStr = ""
	s.IsPlaying = false
	s.UpdatedAt = time.Now().Unix()
	s.mutex.Unlock()

	s.PauseNotify <- false
}

// Reload refreshes the current playlist based on updated queue state, used after queue changes.
func (s *State) Reload() error {
	if !s.IsPlaying {
		return nil
	}

	current, next, err := s.queueService.CurrentAndNextTrack()
	if err != nil {
		return err
	}

	isCurrentTrackChanged := current != nil && s.CurrentTrack.ID != current.ID
	if isCurrentTrackChanged { // Restart if current track changed
		s.Pause()
		err = s.Play()
		if err != nil {
			return err
		}
	}

	segment := s.playlist.FirstNextTrackSegment()
	isNextTrackChanged := segment != nil && !strings.Contains(segment.Path, next.ID)
	if isNextTrackChanged { // Change segments for next track if it changed
		nextSeg, err := s.makeHLSSegments(next, s.playlistDir)
		if err != nil {
			return err
		}
		s.mutex.Lock()
		s.playlist.ChangeNext(nextSeg)
		s.mutex.Unlock()
	}

	return nil
}

// initHLSPlaylist prepares HLS segments for the current and next tracks, initializing a new playlist.
func (s *State) initHLSPlaylist(current, next *track.Track) error {
	currentSeg, err := s.makeHLSSegments(current, s.playlistDir)
	if err != nil {
		return err
	}

	nextSeg, err := s.makeHLSSegments(next, s.playlistDir)
	if err != nil {
		return err
	}

	s.mutex.Lock()
	s.playlist = hls.NewPlaylist(currentSeg, nextSeg)
	s.UpdatedAt = time.Now().Unix()
	s.mutex.Unlock()

	return nil
}

// loadNextTrack advances the queue, resets elapsed time, and updates playlist with next segments.
func (s *State) loadNextTrack() error {
	s.CurrentTrackElapsed = 0
	err := s.queueService.SpinQueue()
	if err != nil {
		return err
	}

	current, next, err := s.queueService.CurrentAndNextTrack()
	if err != nil {
		return err
	}

	s.CurrentTrack = current
	nextTrackSegments, err := s.makeHLSSegments(next, s.playlistDir)
	if err != nil {
		return err
	}

	s.NewTrackNotify <- current.Name
	s.playlist.Next(nextTrackSegments)
	return nil
}

// makeHLSSegments generates HLS segments for a given track.
func (s *State) makeHLSSegments(track *track.Track, dir string) ([]*hls.Segment, error) {
	if track == nil {
		return []*hls.Segment{}, nil
	}

	err := s.trackService.MakeHLSPlaylist(track.Path, dir, track.ID, hls.DefaultMaxSegmentDuration)
	if err != nil {
		return nil, err
	}

	segments := hls.GenerateSegments(
		track.Duration,
		hls.DefaultMaxSegmentDuration,
		track.ID,
		dir,
	)

	return segments, nil
}


================================================
FILE: internal/playback/types.go
================================================
package playback

type History struct {
	ID        int    `json:"id"`
	PlayedAt  int64  `json:"playedAt"`
	TrackName string `json:"trackName"`
}

type Store interface {
	AddPlaybackHistory(playedAt int64, trackName string) error
	RecentPlaybackHistory(limit int) ([]*History, error)
	DeleteOldPlaybackHistory() (int64, error)
}


================================================
FILE: internal/playlist/consts.go
================================================
package playlist

const (
	minNameLen  = 3
	maxNameLen  = 128
	maxDescrLen = 4096
	maxTracks   = 100
)


================================================
FILE: internal/playlist/service.go
================================================
package playlist

import (
	"errors"
	"fmt"
)

type Service struct {
	store Store
}

func NewService(store Store) *Service {
	return &Service{
		store: store,
	}
}

func (s *Service) AddPlaylist(name, description string, trackIDs []string) (*Playlist, error) {
	err := validateName(name)
	if err != nil {
		return nil, err
	}

	err = validateDescr(description)
	if err != nil {
		return nil, err
	}

	err = validateTracks(trackIDs)
	if err != nil {
		return nil, err
	}

	isExists, err := s.store.IsPlaylistExists(name)
	if err != nil {
		return nil, err
	}
	if isExists {
		return nil, fmt.Errorf("playlist with this name already exists")
	}

	pl, err := s.store.AddPlaylist(name, description, trackIDs)
	return pl, err
}

func (s *Service) Playlists() ([]*Playlist, error) {
	pls, err := s.store.Playlists()
	return pls, err
}

func (s *Service) Playlist(id string) (*Playlist, error) {
	pl, err := s.store.Playlist(id)
	return pl, err
}

func (s *Service) EditPlaylist(id, name, description string, trackIDs []string) error {
	err := validateName(name)
	if err != nil {
		return err
	}

	err = validateDescr(description)
	if err != nil {
		return err
	}

	err = validateTracks(trackIDs)
	if err != nil {
		return err
	}

	err = s.store.EditPlaylist(id, name, description, trackIDs)
	return err
}

func (s *Service) DeletePlaylist(id string) error {
	err := s.store.DeletePlaylist(id)
	return err
}

func validateName(name string) error {
	if len(name) < minNameLen {
		return fmt.Errorf("name must be at least %d characters", minNameLen)
	}
	if len(name) > maxNameLen {
		return fmt.Errorf("name must be at most %d characters", maxNameLen)
	}
	return nil
}

func validateDescr(descr string) error {
	if len(descr) > maxDescrLen {
		return fmt.Errorf("description must be at most %d characters", maxDescrLen)
	}
	return nil
}

func validateTracks(trackIDs []string) error {
	if len(trackIDs) > maxTracks {
		return fmt.Errorf("playlist cannot have more than %d tracks", maxTracks)
	}

	seen := make(map[string]struct{}, len(trackIDs))
	for _, id := range trackIDs {
		if id == "" {
			return errors.New("track ID cannot be empty")
		}
		if _, exists := seen[id]; exists {
			return fmt.Errorf("duplicate track ID found: %s", id)
		}
		seen[id] = struct{}{}
	}
	return nil
}


================================================
FILE: internal/playlist/types.go
================================================
package playlist

import "github.com/cheatsnake/airstation/internal/track"

type Playlist struct {
	ID          string         `json:"id"`
	Name        string         `json:"name"`
	Description string         `json:"description"`
	Tracks      []*track.Track `json:"tracks"`
	TrackCount  int            `json:"trackCount"`
}

type Store interface {
	AddPlaylist(name, description string, trackIDs []string) (*Playlist, error)
	Playlists() ([]*Playlist, error)
	Playlist(id string) (*Playlist, error)
	IsPlaylistExists(name string) (bool, error)
	EditPlaylist(id, name, description string, trackIDs []string) error
	DeletePlaylist(id string) error
}


================================================
FILE: internal/queue/service.go
================================================
package queue

import (
	"path"
	"strings"
	"time"

	"github.com/cheatsnake/airstation/internal/pkg/fs"
	"github.com/cheatsnake/airstation/internal/pkg/hls"
	"github.com/cheatsnake/airstation/internal/track"
)

type Service struct {
	store Store
}

func NewService(store Store) *Service {
	return &Service{
		store: store,
	}
}

// Queue retrieves the current playback queue.
//
// Returns:
//   - A slice of Track pointers or an error.
func (s *Service) Queue() ([]*track.Track, error) {
	q, err := s.store.Queue()
	return q, err
}

// AddToQueue adds one or more tracks to the playback queue.
//
// Parameters:
//   - tracks: A slice of Track pointers to add.
//
// Returns:
//   - An error if the operation fails.
func (s *Service) AddToQueue(tracks []*track.Track) error {
	err := s.store.AddToQueue(tracks)
	return err
}

// ReorderQueue updates the order of tracks in the playback queue.
//
// Parameters:
//   - ids: A slice of strings contains track IDs.
//
// Returns:
//   - An error if reordering fails.
func (s *Service) ReorderQueue(ids []string) error {
	err := s.store.ReorderQueue(ids)
	return err
}

// RemoveFromQueue removes specific tracks from the playback queue.
//
// Parameters:
//   - ids: A slice of strings contains track IDs.
//
// Returns:
//   - An error if removal fails.
func (s *Service) RemoveFromQueue(ids []string) error {
	err := s.store.RemoveFromQueue(ids)
	return err
}

// SpinQueue rotates the playback queue, moving the current track to the end.
//
// Returns:
//   - An error if the operation fails.
func (s *Service) SpinQueue() error {
	err := s.store.SpinQueue()
	return err
}

// CurrentAndNextTrack retrieves the currently playing track and the next track in the queue.
//
// Returns:
//   - Pointers to the current and next tracks, and an error if retrieval fails.
func (s *Service) CurrentAndNextTrack() (*track.Track, *track.Track, error) {
	current, next, err := s.store.CurrentAndNextTrack()
	return current, next, err
}

// CleanupHLSPlaylists removes old HLS playlist files that are no longer needed.
//
// Parameters:
//   - dirPath: Directory containing the HLS playlist files.
//
// Returns:
//   - An error if file cleanup fails.
func (s *Service) CleanupHLSPlaylists(dirPath string) error {
	// waiting for all the listeners to listen to the last segments of ended track
	time.Sleep(hls.DefaultMaxSegmentDuration * 2 * time.Second)
	current, next, err := s.store.CurrentAndNextTrack()
	if err != nil {
		return err
	}

	utilized := []string{current.ID, next.ID}
	tmpFiles, err := fs.ListFilesFromDir(dirPath, "")
	if err != nil {
		return err
	}

	for _, tmpFile := range tmpFiles {
		keep := false
		for _, prefix := range utilized {
			if strings.HasPrefix(tmpFile, prefix) {
				keep = true
				break
			}
		}
		if !keep {
			fs.DeleteFile(path.Join(dirPath, tmpFile))
		}
	}

	return nil
}


================================================
FILE: internal/queue/types.go
================================================
package queue

import "github.com/cheatsnake/airstation/internal/track"

type Store interface {
	Queue() ([]*track.Track, error)
	AddToQueue(tracks []*track.Track) error
	RemoveFromQueue(trackIDs []string) error
	ReorderQueue(trackIDs []string) error
	SpinQueue() error
	CurrentAndNextTrack() (*track.Track, *track.Track, error)
}


================================================
FILE: internal/station/const.go
================================================
package station

const (
	propName        = "name"
	propDescription = "description"
	propFaviconURL  = "faviconURL"
	propLogoURL     = "logoURL"
	propLocation    = "location"
	propTimezone    = "timezone"
	propLinks       = "links"
	propTheme       = "theme"
)


================================================
FILE: internal/station/service.go
================================================
package station

type Service struct {
	store Store
}

func NewService(store Store) *Service {
	return &Service{
		store: store,
	}
}

func (s *Service) Info() (*Info, error) {
	rawProps, err := s.store.StationProperties()
	if err != nil {
		return nil, err
	}

	info := &Info{}

	for _, prop := range rawProps {
		switch prop.Key {
		case propName:
			info.Name = prop.Value
		case propDescription:
			info.Description = prop.Value
		case propFaviconURL:
			info.FaviconURL = prop.Value
		case propLogoURL:
			info.LogoURL = prop.Value
		case propLocation:
			info.Location = prop.Value
		case propTimezone:
			info.Timezone = prop.Value
		case propLinks:
			info.Links = prop.Value
		case propTheme:
			info.Theme = prop.Value
		}
	}

	return info, nil
}

func (s *Service) EditInfo(editedInfo *Info) (*Info, error) {
	currentInfo, err := s.Info()
	if err != nil {
		return nil, err
	}

	if currentInfo.Name != editedInfo.Name {
		if _, err := s.store.UpsertStationProperty(propName, editedInfo.Name); err != nil {
			return nil, err
		}
	}

	if currentInfo.Description != editedInfo.Description {
		if _, err := s.store.UpsertStationProperty(propDescription, editedInfo.Description); err != nil {
			return nil, err
		}
	}

	if currentInfo.FaviconURL != editedInfo.FaviconURL {
		if _, err := s.store.UpsertStationProperty(propFaviconURL, editedInfo.FaviconURL); err != nil {
			return nil, err
		}
	}

	if currentInfo.LogoURL != editedInfo.LogoURL {
		if _, err := s.store.UpsertStationProperty(propLogoURL, editedInfo.LogoURL); err != nil {
			return nil, err
		}
	}

	if currentInfo.Location != editedInfo.Location {
		if _, err := s.store.UpsertStationProperty(propLocation, editedInfo.Location); err != nil {
			return nil, err
		}
	}

	if currentInfo.Timezone != editedInfo.Timezone {
		if _, err := s.store.UpsertStationProperty(propTimezone, editedInfo.Timezone); err != nil {
			return nil, err
		}
	}

	if currentInfo.Links != editedInfo.Links {
		if _, err := s.store.UpsertStationProperty(propLinks, editedInfo.Links); err != nil {
			return nil, err
		}
	}

	if currentInfo.Theme != editedInfo.Theme {
		if _, err := s.store.UpsertStationProperty(propTheme, editedInfo.Theme); err != nil {
			return nil, err
		}
	}

	freshInfo, err := s.Info()
	if err != nil {
		return nil, err
	}

	return freshInfo, nil
}


================================================
FILE: internal/station/types.go
================================================
package station

type Info struct {
	Name        string `json:"name"`
	Description string `json:"description"`
	FaviconURL  string `json:"faviconURL"`
	LogoURL     string `json:"logoURL"`
	Location    string `json:"location"`
	Timezone    string `json:"timezone"`
	Links       string `json:"links"`
	Theme       string `json:"theme"`
}

type Property struct {
	Key   string
	Value string
}

type Store interface {
	StationProperties() ([]*Property, error)
	UpsertStationProperty(key, value string) (*Property, error)
	DeleteStationProperty(key string) error
}


================================================
FILE: internal/storage/sqlite/migrations/migrations.go
================================================
package migrations

import (
	"database/sql"
	"fmt"
)

type Migration struct {
	Version int
	Name    string
	Up      func(*sql.Tx) error
	Down    func(*sql.Tx) error
}

var migrations = []Migration{
	{
		Version: 1,
		Name:    "create_main_tables",
		Up: func(tx *sql.Tx) error {
			queries := []string{
				`CREATE TABLE IF NOT EXISTS migrations (
				    version INTEGER PRIMARY KEY,
				    name TEXT NOT NULL,
				    applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
				);`,
				`CREATE TABLE IF NOT EXISTS tracks (
                    id TEXT PRIMARY KEY,
                    name TEXT NOT NULL UNIQUE,
                    path TEXT NOT NULL,
                    duration REAL NOT NULL,
                    bitRate INTEGER NOT NULL
                );`,
				`CREATE TABLE IF NOT EXISTS queue (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    track_id TEXT NOT NULL UNIQUE,
                    FOREIGN KEY (track_id) REFERENCES tracks (id)
                );`,
				`CREATE TABLE IF NOT EXISTS playback_history (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    played_at INTEGER NOT NULL,
                    track_name TEXT NOT NULL
                );`,
				`CREATE TABLE IF NOT EXISTS playlist (
                    id TEXT PRIMARY KEY,
                    name TEXT NOT NULL UNIQUE,
                    description TEXT
                );`,
				`CREATE TABLE IF NOT EXISTS playlist_track (
                    playlist_id TEXT NOT NULL,
                    track_id TEXT NOT NULL,
                    position INTEGER NOT NULL,
                    FOREIGN KEY (playlist_id) REFERENCES playlist (id) ON DELETE CASCADE,
                    FOREIGN KEY (track_id) REFERENCES tracks (id),
                    PRIMARY KEY (playlist_id, position),
                    UNIQUE (playlist_id, track_id)
                );`,
				`CREATE TABLE IF NOT EXISTS station_properties (
                    key VARCHAR(100) PRIMARY KEY,
                    value TEXT,
                    created_at INTEGER DEFAULT (strftime('%s', 'now')),
                    updated_at INTEGER DEFAULT (strftime('%s', 'now'))
                );`,
			}

			for _, query := range queries {
				if _, err := tx.Exec(query); err != nil {
					return fmt.Errorf("failed to execute query: %w, query: %s", err, query)
				}
			}
			return nil
		},
	},
	{
		Version: 2,
		Name:    "create_main_indexes",
		Up: func(tx *sql.Tx) error {
			indexes := []string{
				`CREATE INDEX IF NOT EXISTS idx_tracks_name ON tracks (name COLLATE NOCASE);`,
				`CREATE INDEX IF NOT EXISTS idx_playback_history_played_at ON playback_history(played_at);`,
				`CREATE INDEX IF NOT EXISTS idx_playlist_track_ids ON playlist_track (playlist_id, track_id);`,
			}

			for _, query := range indexes {
				if _, err := tx.Exec(query); err != nil {
					return fmt.Errorf("failed to create index: %w, query: %s", err, query)
				}
			}
			return nil
		},
	},
}


================================================
FILE: internal/storage/sqlite/migrations/runner.go
================================================
package migrations

import (
	"database/sql"
	"fmt"
	"log/slog"

	sqltool "github.com/cheatsnake/airstation/internal/pkg/sql"
)

func RunMigrations(db *sql.DB, log *slog.Logger) error {
	migrationTableExists, err := sqltool.TableExists(db, "migrations")
	if err != nil {
		return err
	}

	var currentVersion int

	if migrationTableExists {
		err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM migrations").Scan(&currentVersion)
		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
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta name="msapplication-TileColor" content="#29323c" />
        <meta name="theme-color" content="#29323c" />
        <title>%AIRSTATION_PLAYER_TITLE%</title>
    </head>
    <body>
        <div id="root"></div>
        <script type="module" src="/src/index.tsx"></script>
    </body>
</html>


================================================
FILE: web/player/package.json
================================================
{
    "name": "player",
    "private": true,
    "version": "0.0.0",
    "type": "module",
    "scripts": {
        "dev": "vite",
        "build": "tsc -b && vite build",
        "preview": "vite preview"
    },
    "dependencies": {
        "hls.js": "^1.6.15",
        "solid-js": "^1.9.12"
    },
    "devDependencies": {
        "@types/node": "^22.15.29",
        "typescript": "^6.0.2",
        "vite": "^8.0.3",
        "vite-plugin-pwa": "^1.2.0",
        "vite-plugin-solid": "^2.11.11"
    },
    "overrides": {
        "vite-plugin-pwa": {
            "vite": "$vite"
        }
    }
}


================================================
FILE: web/player/src/App.tsx
================================================
import { Page } from "./page";

const App = () => {
    return <Page />;
};

export default App;


================================================
FILE: web/player/src/api/index.ts
================================================
import { PlaybackHistory, PlaybackState, ResponseErr, StationInfo } from "./types";
import { queryParams } from "./utils";

export const API_HOST = "";
export const API_PREFIX = "/api/v1";

class AirstationAPI {
    private host: string;
    private prefix: string;
    private url: () => string;

    constructor(host: string, prefix: string) {
        this.host = host;
        this.prefix = prefix;
        this.url = () => `${this.host + this.prefix}`;
    }

    async getPlayback() {
        const url = `${this.url()}/playback`;
        return await this.makeRequest<PlaybackState>(url);
    }

    async getPlaybackHistory(limit?: number) {
        let url = `${this.url()}/playback/history`;
        if (limit) url += `?${queryParams({ limit })}`;
        return await this.makeRequest<PlaybackHistory[]>(url);
    }

    async getStationInfo() {
        const url = `${this.url()}/station/info`;
        return await this.makeRequest<StationInfo>(url);
    }

    private async makeRequest<T>(url: string, params: RequestInit = {}): Promise<T> {
        const resp = await fetch(url, params);
        if (!resp.ok) {
            const body: ResponseErr = await resp.json();
            throw new Error(body.message);
        }

        return resp.json();
    }
}

export const airstationAPI = new AirstationAPI(API_HOST, API_PREFIX);


================================================
FILE: web/player/src/api/types.ts
================================================
export interface Track {
    id: string;
    name: string;
    path: string;
    duration: number;
    bitRate: number;
}

export interface PlaybackState {
    currentTrack: Track | null;
    currentTrackElapsed: number;
    isPlaying: boolean;
}

export interface PlaybackHistory {
    id: number;
    playedAt: number;
    trackName: string;
}

export interface StationInfo {
    name: string;
    description: string;
    faviconURL: string;
    logoURL: string;
    location: string;
    timezone: string;
    links: string;
    theme: string;
}

export interface ResponseErr {
    message: string;
}

export interface ResponseOK {
    message: string;
}


================================================
FILE: web/player/src/api/utils.ts
================================================
export const jsonRequestParams = (method: string, body: Record<string, any>) => {
    return {
        method,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
    };
};

export const queryParams = (params: Record<string, any>) => {
    removeEmptyFields(params);
    return new URLSearchParams(params).toString();
};

const removeEmptyFields = (obj: Record<string, any>) => {
    for (const key in obj) {
        if (obj.hasOwnProperty(key) && [undefined, null, ""].includes(obj[key])) {
            delete obj[key];
        }
    }
};


================================================
FILE: web/player/src/const.ts
================================================
export const DESKTOP_WIDTH = 1100;
export const MAX_HISTORY_LIMIT = 500;


================================================
FILE: web/player/src/index.css
================================================
:root {
    --bg-gradient-start: #29323c;
    --bg-gradient-end: #485563;
    --bg-icon: #a8a8a8;
    --text-color: #ffffff;

    --text-dimmed-color: color-mix(in srgb, var(--text-color) 70%, transparent);
    --sidebar-color: color-mix(in srgb, var(--bg-gradient-start) 80%, black 20%);
    --scrollbar-color: color-mix(in srgb, var(--bg-gradient-end) 90%, black 10%);
}

* {
    font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    margin: 0;
    padding: 0;
    box-sizing: border-box;
    color: var(--text-color);
}

::-webkit-scrollbar {
    width: 8px;
}

::-webkit-scrollbar-track {
    background: color-mix(in srgb, var(--bg-gradient-start) 80%, black 20%);
}

::-webkit-scrollbar-thumb {
    background: var(--scrollbar-color);
}

::-webkit-scrollbar-thumb:hover {
    background: var(--scrollbar-color);
}

body {
    background: var(--bg-gradient-end);
    background: -webkit-linear-gradient(to top, var(--bg-gradient-start), var(--bg-gradient-end));
    background: linear-gradient(to top, var(--bg-gradient-start), var(--bg-gradient-end));
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
    overflow: hidden;
}

.empty_icon {
    width: 24px;
    height: 24px;
}


================================================
FILE: web/player/src/index.tsx
================================================
/* @refresh reload */
import { render } from 'solid-js/web'
import './index.css'
import App from './App.tsx'

const root = document.getElementById('root')

render(() => <App />, root!)


================================================
FILE: web/player/src/page/CurrentTrack.module.css
================================================
.box {
    width: 100%;
}

.label,
.offline_label {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 0.5rem;
    font-size: 0.9rem;
    cursor: pointer;
    padding: 1rem 0.5rem;
}

.offline_label {
    user-select: none;
    cursor: default;
    color: var(--text-dimmed-color);
}

.offline_label_icon {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background: rgb(240, 78, 78);
}


================================================
FILE: web/player/src/page/CurrentTrack.tsx
================================================
import { onMount, Show } from "solid-js";
import { airstationAPI } from "../api";
import styles from "./CurrentTrack.module.css";
import { addEventListener, EVENTS } from "../store/events";
import { setTrackStore, trackStore } from "../store/track";
import { addHistory } from "../store/history";
import { getUnixTime } from "../utils/date";

export const CurrentTrack = () => {
    onMount(async () => {
        try {
            const cs = await airstationAPI.getPlayback();
            if (cs.isPlaying && cs.currentTrack) setTrackStore("trackName", cs.currentTrack.name);
        } catch (error) {
            console.log(error);
        }

        addEventListener(EVENTS.newTrack, (e: MessageEvent<string>) => {
            const unixTime = getUnixTime();
            setTrackStore("trackName", e.data);
            addHistory({ id: unixTime, playedAt: unixTime, trackName: e.data });
        });
    });

    const copyToClipboard = async () => {
        try {
            await navigator.clipboard.writeText(trackStore.trackName);
        } catch (error) {
            console.log(error);
        }
    };

    return (
        <div class={styles.box}>
            <Show when={trackStore.trackName.length > 0} fallback={<OfflineLabel />}>
                <div onClick={copyToClipboard} class={styles.label}>
                    {trackStore.trackName}
                </div>
            </Show>
        </div>
    );
};

const OfflineLabel = () => {
    return (
        <div class={styles.offline_label}>
            <div class={styles.offline_label_icon}></div>
            <div class={styles.offline_label_title}>Stream offline</div>
        </div>
    );
};


================================================
FILE: web/player/src/page/History.module.css
================================================
.history_icon {
    width: 24px;
    height: 24px;
    cursor: pointer;
    background-color: var(--bg-icon);
    -webkit-mask: url('data:image/svg+xml;utf8,<svg  xmlns="http://www.w3.org/2000/svg"  width="24"  height="24"  viewBox="0 0 24 24"  fill="none"  stroke="currentColor"  stroke-width="2"  stroke-linecap="round"  stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 6l16 0" /><path d="M4 12l16 0" /><path d="M4 18l16 0" /></svg>');
    mask: -webkit-mask;
}

.history_menu {
    position: fixed;
    top: 0;
    left: -500px;
    width: 100%;
    max-width: 500px;
    height: 100vh;
    padding: 0.5rem;
    transition: left 0.3s ease;
    overflow-y: auto;
    z-index: 10;
}

.history_open {
    left: 0;
}

.history {
    display: flex;
    flex-direction: column;
    gap: 1rem;
}

.history_item {
    font-size: 0.9rem;
    cursor: pointer;
}

.history_timestamp {
    color: var(--text-dimmed-color);
}

.load_more_btn {
    background: transparent;
    border: none;
    cursor: pointer;
    color: var(--text-dimmed-color);
}

.load_more_btn:hover {
    opacity: 1;
}


================================================
FILE: web/player/src/page/History.tsx
================================================
import { Accessor, Component, createSignal, onMount } from "solid-js";
import styles from "./History.module.css";
import pageStyles from "./Page.module.css";
import { airstationAPI } from "../api";
import { formatDateToTimeFirst } from "../utils/date";
import { history, setHistory } from "../store/history";
import { DESKTOP_WIDTH, MAX_HISTORY_LIMIT } from "../const";

export const History = () => {
    const [isOpen, setIsOpen] = createSignal(false);
    const open = () => setIsOpen(true);
    const close = () => setIsOpen(false);

    return (
        <>
            <div
                tabIndex={0}
                role="button"
                class={`${isOpen() ? "empty_icon" : styles.history_icon}`}
                onClick={open}
            ></div>
            <Menu isOpen={isOpen} close={close} />
        </>
    );
};

const Menu: Component<{ isOpen: Accessor<boolean>; close: () => void }> = ({ isOpen, close }) => {
    const [hideLoadMore, setHideLoadMore] = createSignal(false);
    const loadHistory = async (limit?: number) => {
        try {
            const h = await airstationAPI.getPlaybackHistory(limit);
            setHistory(h);
        } catch (error) {
            console.log(error);
        }
    };

    const loadMoreHistory = () => {
        loadHistory(MAX_HISTORY_LIMIT);
        setHideLoadMore(true);
    };

    const copyToClipboard = async (text: string) => {
        try {
            await navigator.clipboard.writeText(text);
        } catch (error) {
            console.log(error);
        }
    };

    onMount(() => {
        loadHistory();
    });

    return (
        <div
            class={`${styles.history_menu} ${isOpen() ? styles.history_open : ""} ${
                window.screen.width > DESKTOP_WIDTH ? pageStyles.menu_desktop : pageStyles.menu_mobile
            }`}
        >
            <div class={pageStyles.menu_header}>
                <div tabIndex={0} role="button" class={pageStyles.close_icon} onClick={close}></div>
            </div>
            <div class={styles.history}>
                {history().map((h) => (
                    <div class={styles.history_item} onClick={() => copyToClipboard(h.trackName)}>
                        <div class={styles.history_name}>{h.trackName}</div>
                        <div class={styles.history_timestamp}>{formatDateToTimeFirst(new Date(h.playedAt * 1000))}</div>
                    </div>
                ))}
                {hideLoadMore() ? null : (
                    <button class={styles.load_more_btn} onClick={loadMoreHistory}>
                        Load more
                    </button>
                )}
            </div>
        </div>
    );
};


================================================
FILE: web/player/src/page/ListenersCounter.module.css
================================================
.counter {
    color: var(--bg-gradient-end);
    font-size: 0.9rem;
}

.box {
    background-color: var(--bg-icon);
    display: flex;
    gap: 0.3rem;
    align-items: center;
    justify-content: center;
    padding: 0.05rem 0.45rem;
    border-radius: 0.25rem;
}

.icon {
    width: 16px;
    height: 16px;
    background-color: var(--bg-gradient-end);
    -webkit-mask: url('data:image/svg+xml;utf8,<svg  xmlns="http://www.w3.org/2000/svg"  width="16"  height="16"  viewBox="0 0 24 24"  fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 13m0 2a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-1a2 2 0 0 1 -2 -2z" /><path d="M15 13m0 2a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-1a2 2 0 0 1 -2 -2z" /><path d="M4 15v-3a8 8 0 0 1 16 0v3" /></svg>');
    mask: -webkit-mask;
}

.number {
    color: var(--bg-gradient-end);
    user-select: none;
    line-height: 0rem;
    font-weight: 700;
    font-size: 1rem;
}


================================================
FILE: web/player/src/page/ListenersCounter.tsx
================================================
import { createSignal, onMount } from "solid-js";
import { addEventListener, EVENTS } from "../store/events";
import styles from "./ListenersCounter.module.css";

export const ListenersCounter = () => {
    const [count, setCount] = createSignal(0);

    onMount(() => {
        addEventListener(EVENTS.countListeners, (e: MessageEvent<string>) => {
            setCount(+e.data);
        });
    });

    return (
        <div class={styles.counter}>
            <div class={styles.box}>
                <div class={styles.icon}></div>
                <div class={styles.number}>{!count() ? "" : count()}</div>
            </div>
        </div>
    );
};


================================================
FILE: web/player/src/page/Page.module.css
================================================
.page {
    display: flex;
    flex-direction: column;
    height: 100vh;
}

.header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0.25rem 0.5rem;
}

.menu_mobile {
    background-color: var(--sidebar-color);
}

.menu_desktop {
    background-color: color-mix(in srgb, var(--sidebar-color) 60%, transparent);
}

.menu_header {
    width: 100%;
    display: flex;
    justify-content: flex-end;
}

.menu_title {
    font-size: 1rem;
    user-select: none;
}

.close_icon {
    width: 18px;
    height: 18px;
    cursor: pointer;
    background-color: var(--text-color);
    -webkit-mask: url('data:image/svg+xml;utf8,<svg  xmlns="http://www.w3.org/2000/svg"  width="18"  height="18"  viewBox="0 0 24 24"  fill="none"  stroke="currentColor"  stroke-width="2"  stroke-linecap="round"  stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 6l-12 12" /><path d="M6 6l12 12" /></svg>');
    mask: -webkit-mask;
}


================================================
FILE: web/player/src/page/RadioButton.module.css
================================================
video {
    display: none;
}

.container {
    flex: 1;
}

.box {
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
}

.pause_icon {
    margin: 0px auto 0;
    width: 74px;
    height: 74px;
    box-sizing: border-box;
    border-color: transparent transparent transparent var(--text-color);
    background-color: var(--text-color);
    border-radius: 0.2rem;
    transition:
        transform 0.05s ease-out,
        background-color 0.3s,
        box-shadow 0.3s;
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
}

.play_icon {
    width: 74px;
    height: 74px;
    box-sizing: border-box;
    border-style: solid;
    border-width: 36px 0px 36px 74px;
    border-color: transparent transparent transparent var(--text-color);
    transition: all 200ms ease-in-out;
    border-radius: 0.2rem;
}

.pause_icon,
.play_icon {
    cursor: pointer;
    user-select: none;
}


================================================
FILE: web/player/src/page/RadioButton.tsx
================================================
import HLS from "hls.js";
import styles from "./RadioButton.module.css";
import { setTrackStore, trackStore } from "../store/track";
import { Component, onCleanup, onMount } from "solid-js";
import { addEventListener, EVENTS } from "../store/events";
import { getUnixTime } from "../utils/date";
import { addHistory } from "../store/history";
import { getCssVariable } from "../utils/document";
import { getHueFromHex } from "../utils/color";

const STREAM_SOURCE = "/stream";

export const RadioButton = () => {
    let videoRef: HTMLAudioElement | undefined;
    let hls: HLS | undefined;

    const initStream = () => {
        if (!trackStore.isPlay && HLS.isSupported()) {
            hls = new HLS();
            hls.loadSource(STREAM_SOURCE);
            hls.attachMedia(videoRef as unknown as HTMLMediaElement);
        }
    };

    const handlePlay = () => {
        initStream();
        if (!trackStore.trackName) return;
        setTrackStore("isPlay", true);
    };

    const handlePause = () => {
        setTrackStore("isPlay", false);
        hls?.destroy();
    };

    onMount(() => {
        addEventListener(EVENTS.pause, (_e: MessageEvent<string>) => {
            setTrackStore("trackName", "");
            (() => videoRef?.pause())();
        });

        addEventListener(EVENTS.play, (e: MessageEvent<string>) => {
            const unixTime = getUnixTime();
            setTrackStore("trackName", e.data);
            addHistory({ id: unixTime, playedAt: unixTime, trackName: e.data });

            if (trackStore.isPlay) (() => videoRef?.pause())();
            (() => videoRef?.play())();
        });

        document.body.addEventListener("keydown", (event) => {
            if (event.key === " ") {
                event.preventDefault();
                trackStore.isPlay ? videoRef?.pause() : videoRef?.play();
            }
        });
    });

    return (
        <div class={styles.container}>
            <audio id="video" ref={videoRef} onPause={handlePause} onPlay={handlePlay}></audio>
            <div class={styles.box}>
                {trackStore.isPlay ? (
                    <AnimatedPauseButton pause={() => videoRef?.pause()} media={videoRef} />
                ) : (
                    <div class={styles.play_icon} tabIndex={0} role="button" onClick={() => videoRef?.play()}></div>
                )}
            </div>
        </div>
    );
};

let audioSource: MediaElementAudioSourceNode | null = null;
let audioContext: AudioContext | null = null;

const AnimatedPauseButton: Component<{ pause: () => void; media?: HTMLAudioElement }> = (props) => {
    let pauseIconRef: HTMLDivElement | undefined;
    let analyser: AnalyserNode | null = null;
    let dataArray: Uint8Array | null = null;
    let animationId: number | null = null;
    let gainNode: GainNode | null = null;
    let accentHue: number | null = null;
    let currentHue = 0;
    let currentSaturation = 50;
    let currentLightness = 60;

    const loadAccentColor = () => {
        const accentColor = getCssVariable("--accent-color");
        accentHue = accentColor ? getHueFromHex(accentColor) : null;

        currentHue = accentHue !== null ? accentHue : 0;
        currentSaturation = accentHue !== null ? 100 : 50;
    };

    onMount(async () => {
        loadAccentColor();
        setInterval(loadAccentColor, 1000); // Need for hot reload

        if (!pauseIconRef || !props.media) return;
        await initAudio();
        draw();
    });

    onCleanup(async () => {
        if (animationId !== null) {
            cancelAnimationFrame(animationId);
            animationId = null;
        }

        if (gainNode) {
            gainNode.disconnect();
            gainNode = null;
        }

        if (analyser) {
            analyser.disconnect();
            analyser = null;
        }

        dataArray = null;

        if (pauseIconRef) {
            pauseIconRef.style.transform = "scale(1)";
            pauseIconRef.style.backgroundColor = "white";
            pauseIconRef.style.boxShadow = "none";
        }
    });

    const initAudio = async () => {
        try {
            if (!props.media) return;
            if (!audioContext) audioContext = new window.AudioContext();

            analyser = audioContext.createAnalyser();
            analyser.fftSize = 256;
            gainNode = audioContext.createGain();
            gainNode.gain.value = 1;

            if (!audioSource) audioSource = audioContext.createMediaElementSource(props.media);
            audioSource.connect(gainNode);
            gainNode.connect(analyser);
            analyser.connect(audioContext.destination);

            const bufferLength = analyser.frequencyBinCount;
            dataArray = new Uint8Array(bufferLength);
        } catch (err) {
            console.error("Error initializing audio:", err);
        }
    };

    const draw = () => {
        if (!pauseIconRef || !analyser || !dataArray) return;

        animationId = requestAnimationFrame(draw);
        analyser.getByteFrequencyData(dataArray as Uint8Array<ArrayBuffer>);

        let bass = 0;
        let treble = 0;
        const bassEnd = Math.floor(dataArray.length * 0.3);
        const trebleStart = Math.floor(dataArray.length * 0.6);

        for (let i = 0; i < dataArray.length; i++) {
            if (i < bassEnd) bass += dataArray[i];
            else if (i > trebleStart) treble += dataArray[i];
        }

        bass /= bassEnd;
        treble /= dataArray.length - trebleStart;

        const scale = 1 + bass / 300;
        const jump = (bass / 300) * 20;

        pauseIconRef.style.transform = `translateY(${-jump}px) scale(${scale})`;

        const bassImpact = bass / 255;
        const trebleImpact = treble / 255;

        if (accentHue == null) {
            currentHue += (Math.random() - 0.5) * bassImpact * 120;
            currentHue += trebleImpact * 2;
            currentHue = (currentHue + 360) % 360;
        }

        const color = `hsl(${currentHue}, ${currentSaturation}%, ${currentLightness}%)`;
        pauseIconRef.style.backgroundColor = color;

        const glowIntensity = bass / 2 + 20;
        pauseIconRef.style.boxShadow = `0 0 ${glowIntensity}px ${color}`;
    };

    return <div ref={pauseIconRef} tabIndex={0} role="button" class={styles.pause_icon} onClick={props.pause}></div>;
};


================================================
FILE: web/player/src/page/StationInformation.module.css
================================================
.info_menu {
    position: fixed;
    top: 0;
    right: -500px;
    width: 100%;
    max-width: 500px;
    height: 100vh;
    transition: right 0.3s ease;
    overflow-y: hidden;
    z-index: 10;
}

.info_icon {
    background-color: var(--bg-icon);
    -webkit-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M20.975 11.33a9 9 0 1 0 -5.673 9.043" /><path d="M3.6 9h16.8" /><path d="M3.6 15h9.9" /><path d="M11.5 3a17 17 0 0 0 0 18" /><path d="M12.5 3a16.988 16.988 0 0 1 2.57 9.518m-1.056 5.403a17 17 0 0 1 -1.514 3.079" /><path d="M19 22v.01" /><path d="M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483" /></svg>');
    mask: -webkit-mask;
    width: 24px;
    height: 24px;
    cursor: pointer;
}

.info_open {
    right: 0;
}

.header {
    position: absolute;
    padding: 0.5rem;
    display: flex;
    align-items: center;
}

.content {
    padding: 1rem 0.5rem;
}

.logo {
    max-width: 100%;
    object-fit: contain;
    display: block;
    margin-left: auto;
    margin-right: auto;
}

.title {
    margin: 0;
    font-size: 1.3rem;
    font-weight: 600;
    text-align: center;
}

.metadata {
    margin-top: 0.25rem;
    display: flex;
    justify-content: center;
    gap: 0.5rem;
}

.location,
.timezone {
    color: var(--text-dimmed-color);
}

.description {
    margin-top: 1rem;
}

.footer {
    margin-top: 1rem;
    display: flex;
    gap: 0.25rem;
    align-items: center;
    flex-direction: column;
    justify-content: space-between;
}

.footer a {
    color: var(--text-dimmed-color);
    text-decoration: none;
    transition: 0.2s all;
}

.footer a:hover {
    text-decoration: underline;
}


================================================
FILE: web/player/src/page/StationInformation.tsx
================================================
import { Accessor, Component, createSignal, onMount } from "solid-js";
import pageStyles from "./Page.module.css";
import styles from "./StationInformation.module.css";
import { airstationAPI } from "../api";
import { DESKTOP_WIDTH } from "../const";
import { StationInfo } from "../api/types";
import { isValidURL } from "../utils/url";
import { isValidHexColor } from "../utils/color";
import { setCssVariable, setFavicon, setPageTitle } from "../utils/document";
import { addEventListener, EVENTS } from "../store/events";

export const StationInformation = () => {
    const [isOpen, setIsOpen] = createSignal(false);
    const open = () => setIsOpen(true);
    const close = () => setIsOpen(false);

    return (
        <>
            <div role="button" class={`${isOpen() ? "empty_icon" : styles.info_icon}`} onClick={open} />
            <Card isOpen={isOpen} close={close} />
        </>
    );
};

const parseLinks = (rawLinks: string): { title: string; url: string }[] => {
    const regex = /\[([^\]]+)]\((https?:\/\/[^\s)]+)\)/g;
    return Array.from(rawLinks.matchAll(regex), (m) => ({
        title: m[1],
        url: m[2],
    }));
};

const parseTheme = (rawTheme: string) => {
    const [bgStart, bgEnd, bgIcon, text, accent, bgImage] = rawTheme.split(";");

    if (bgStart && isValidHexColor(bgStart)) setCssVariable("--bg-gradient-start", bgStart);
    if (bgEnd && isValidHexColor(bgEnd)) setCssVariable("--bg-gradient-end", bgEnd);
    if (bgIcon && isValidHexColor(bgIcon)) setCssVariable("--bg-icon", bgIcon);
    if (text && isValidHexColor(text)) setCssVariable("--text-color", text);

    if (accent && isValidHexColor(accent)) {
        setCssVariable("--accent-color", accent);
    } else {
        setCssVariable("--accent-color", "");
    }

    if (bgImage && isValidURL(bgImage)) {
        document.body.style.backgroundImage = `url(${bgImage})`;
    } else {
        document.body.style.backgroundImage = "";
    }
};

const Card: Component<{ isOpen: Accessor<boolean>; close: () => void }> = ({ isOpen, close }) => {
    const [info, setInfo] = createSignal<StationInfo | null>(null);

    const loadInfo = async () => {
        try {
            const h = await airstationAPI.getStationInfo();
            setInfo(h);
            if (h.name) setPageTitle(h.name);
            if (isValidURL(h.faviconURL)) setFavicon(h.faviconURL);
            if (h.theme) parseTheme(h.theme);
        } catch (error) {
            console.log(error);
        }
    };

    onMount(() => {
        loadInfo();

        addEventListener(EVENTS.changeTheme, (_e: MessageEvent<string>) => {
            loadInfo();
        });
    });

    return (
        <div
            class={`${styles.info_menu} ${isOpen() ? styles.info_open : ""} ${
                window.screen.width > DESKTOP_WIDTH ? pageStyles.menu_desktop : pageStyles.menu_mobile
            }`}
        >
            <div class={styles.header}>
                <div role="button" class={pageStyles.close_icon} onClick={close}></div>
            </div>

            {info()?.logoURL && <img src={info()?.logoURL} alt={info?.name} class={styles.logo} />}

            <div class={styles.content}>
                <div class={styles.title}>{info()?.name}</div>

                <div class={styles.metadata}>
                    {info()?.location && <span class={styles.location}>{info()!.location}</span>}
                    {info()?.timezone && <span class={styles.timezone}>{info()!.timezone}</span>}
                </div>

                <div class={styles.description} innerHTML={info()?.description} />

                {info()?.links && (
                    <div class={styles.footer}>
                        {parseLinks(info()?.links!).map((link) => (
                            <a href={link.url} target="_blank" rel="noreferrer">
                                {link.title}
                            </a>
                        ))}
                    </div>
                )}
            </div>
        </div>
    );
};


================================================
FILE: web/player/src/page/index.tsx
================================================
import { onMount, onCleanup } from "solid-js";
import { CurrentTrack } from "./CurrentTrack";
import { ListenersCounter } from "./ListenersCounter";
import { RadioButton } from "./RadioButton";
import { closeEventSource, initEventSource } from "../store/events";
import { History } from "./History";
import styles from "./Page.module.css";
import { StationInformation } from "./StationInformation";

export const Page = () => {
    onMount(() => {
        initEventSource();
    });

    onCleanup(() => {
        closeEventSource();
    });

    return (
        <div class={styles.page}>
            <div class={styles.header}>
                <History />
                <ListenersCounter />
                <StationInformation />
            </div>
            <RadioButton />
            <CurrentTrack />
        </div>
    );
};


================================================
FILE: web/player/src/store/events.ts
================================================
import { createStore } from "solid-js/store";
import { API_HOST, API_PREFIX } from "../api";

export const EVENT_SOURCE_URL = API_HOST + API_PREFIX + "/events";
export const EVENTS = {
    newTrack: "new_track",
    changeTheme: "change_theme",
    countListeners: "count_listeners",
    pause: "pause",
    play: "play",
};

const [eventSourceStore, setEventSourceStore] = createStore<{ eventSource: EventSource | null }>({
    eventSource: null,
});

export const initEventSource = () => {
    if (eventSourceStore.eventSource) eventSourceStore.eventSource.close();

    const es = new EventSource(EVENT_SOURCE_URL);
    setEventSourceStore("eventSource", es);
};

export const addEventListener = (event: string, listener: (event: MessageEvent) => void) => {
    if (eventSourceStore.eventSource) {
        eventSourceStore.eventSource.addEventListener(event, listener);
    }
};

export const closeEventSource = () => {
    if (eventSourceStore.eventSource) {
        eventSourceStore.eventSource.close();
        setEventSourceStore("eventSource", null);
    }
};


================================================
FILE: web/player/src/store/history.ts
================================================
import { createSignal } from "solid-js";
import { PlaybackHistory } from "../api/types";

export const [history, setHistory] = createSignal<PlaybackHistory[]>([]);
export const addHistory = (h: PlaybackHistory) => {
    setHistory([h, ...history()]);
};


================================================
FILE: web/player/src/store/track.ts
================================================
import { createStore } from "solid-js/store";

export const [trackStore, setTrackStore] = createStore({
    trackName: "",
    isPlay: false,
});


================================================
FILE: web/player/src/utils/color.ts
================================================
export function getHueFromHex(hex: string) {
    hex = hex.replace("#", "");

    const r = parseInt(hex.slice(0, 2), 16) / 255;
    const g = parseInt(hex.slice(2, 4), 16) / 255;
    const b = parseInt(hex.slice(4, 6), 16) / 255;

    const max = Math.max(r, g, b);
    const min = Math.min(r, g, b);
    const d = max - min;

    if (d === 0) return 0;

    let h;
    switch (max) {
        case r:
            h = ((g - b) / d) % 6;
            break;
        case g:
            h = (b - r) / d + 2;
            break;
        default:
            h = (r - g) / d + 4;
    }

    return Math.round(h * 60 < 0 ? h * 60 + 360 : h * 60);
}

export function isValidHexColor(hex: string) {
    return /^#[0-9a-f]{6}$/.test(hex);
}


================================================
FILE: web/player/src/utils/date.ts
================================================
export const formatDateToTimeFirst = (date: Date) => {
    const timeParts = new Intl.DateTimeFormat(undefined, {
        hour: "2-digit",
        minute: "2-digit",
        second: "2-digit",
        hour12: false,
    }).formatToParts(date);

    const dateParts = new Intl.DateTimeFormat(undefined, {
        day: "2-digit",
        month: "2-digit",
        year: "numeric",
    }).formatToParts(date);

    const time = timeParts.map((p) => p.value).join("");
    const dateStr = dateParts.map((p) => p.value).join("");

    return `${time} ${dateStr}`;
};

export const getUnixTime = (): number => Math.floor(Date.now() / 1000);


================================================
FILE: web/player/src/utils/document.ts
================================================
export const setFavicon = (url: string) => {
    let link = document.querySelector<HTMLLinkElement>("link[rel*='icon']") || document.createElement("link");
    link.type = "image/png";
    link.rel = "icon";
    link.href = url;
    document.getElementsByTagName("head")[0].appendChild(link);
};

export const setPageTitle = (title: string) => {
    document.title = title;
};

export const getCssVariable = (name: string): string => {
    return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
};

export const setCssVariable = (name: string, value: string): void => {
    document.documentElement.style.setProperty(name, value);
};


================================================
FILE: web/player/src/utils/url.ts
================================================
export const isValidURL = (str: string) => {
    try {
        const url = new URL(str);
        return url.protocol === "http:" || url.protocol === "https:";
    } catch (err) {
        return false;
    }
};


================================================
FILE: web/player/src/vite-env.d.ts
================================================
/// <reference types="vite/client" />


================================================
FILE: web/player/tsconfig.app.json
================================================
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "preserve",
    "jsxImportSource": "solid-js",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["src"]
}


================================================
FILE: web/player/tsconfig.json
================================================
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}


================================================
FILE: web/player/tsconfig.node.json
================================================
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
    "target": "ES2022",
    "lib": ["ES2023"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["vite.config.ts"]
}


================================================
FILE: web/player/vite.config.ts
================================================
import { defineConfig, loadEnv } from "vite";
import solid from "vite-plugin-solid";
import { VitePWA } from "vite-plugin-pwa";
import path from "path";

export default defineConfig(({ mode }) => {
    const globalEnv = loadEnv(mode, path.join(process.cwd(), "..", ".."), "");
    const localEnv = loadEnv(mode, process.cwd(), "");
    const appTitle = globalEnv.AIRSTATION_PLAYER_TITLE || localEnv.AIRSTATION_PLAYER_TITLE || "Radio";
    return {
        plugins: [
            solid(),
            VitePWA({
                scope: "/",
                registerType: "autoUpdate",
                workbox: {
                    cleanupOutdatedCaches: true,
                    navigateFallback: "/index.html",
                    navigateFallbackDenylist: [/^\/studio\//],
                },
                devOptions: {
                    enabled: true,
                },
                manifest: {
                    scope: "/",
                    start_url: "/",
                    lang: "en",
                    name: "Radio",
                    short_name: "Radio",
                    icons: [
                        {
                            src: "icon48.png",
                            sizes: "48x48",
                            type: "image/png",
                            purpose: "maskable any",
                        },
                        {
                            src: "icon72.png",
                            sizes: "72x72",
                            type: "image/png",
                            purpose: "maskable any",
                        },
                        {
                            src: "icon96.png",
                            sizes: "96x96",
                            type: "image/png",
                            purpose: "maskable any",
                        },
                        {
                            src: "icon128.png",
                            sizes: "128x128",
                            type: "image/png",
                            purpose: "maskable any",
                        },
                        {
                            src: "icon144.png",
                            sizes: "144x144",
                            type: "image/png",
                            purpose: "maskable any",
                        },
                        {
                            src: "icon152.png",
                            sizes: "152x152",
                            type: "image/png",
                            purpose: "maskable any",
                        },
                        {
                            src: "icon192.png",
                            sizes: "192x192",
                            type: "image/png",
                            purpose: "maskable any",
                        },
                        {
                            src: "icon256.png",
                            sizes: "256x256",
                            type: "image/png",
                            purpose: "maskable any",
                        },
                        {
                            src: "icon512.png",
                            sizes: "512x512",
                            type: "image/png",
                            purpose: "maskable any",
                        },
                    ],
                },
            }),
        ],
        server: {
            proxy: {
                "/api": { target: "http://localhost:7331", changeOrigin: true },
                "/stream": { target: "http://localhost:7331", changeOrigin: true },
                "/static": { target: "http://localhost:7331", changeOrigin: true },
            },
        },
        envPrefix: "AIRSTATION_PLAYER_",
        define: {
            "import.meta.env.AIRSTATION_PLAYER_TITLE": JSON.stringify(appTitle),
        },
    };
});


================================================
FILE: web/studio/.gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
dev-dist
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?


================================================
FILE: web/studio/.prettierrc
================================================
{
    "trailingComma": "all",
    "tabWidth": 4,
    "semi": true,
    "singleQuote": false,
    "printWidth": 120
}

================================================
FILE: web/studio/README.md
================================================
# Airstation Studio


================================================
FILE: web/studio/index.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta name="msapplication-TileColor" content="#29323c" />
        <meta name="theme-color" content="#29323c" />
        <title>Airstation Studio</title>
        <link rel="preconnect" href="https://fonts.googleapis.com" />
        <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
        <link
            href="https://fonts.googleapis.com/css2?family=Exo+2:ital,wght@0,100..900;1,100..900&display=swap"
            rel="stylesheet"
        />
    </head>
    <body>
        <div id="root"></div>
        <script type="module" src="/src/main.tsx"></script>
    </body>
</html>


================================================
FILE: web/studio/package.json
================================================
{
    "name": "studio",
    "private": true,
    "version": "0.0.0",
    "type": "module",
    "scripts": {
        "dev": "vite",
        "build": "tsc -b && vite build",
        "preview": "vite preview"
    },
    "dependencies": {
        "@dnd-kit/core": "^6.3.1",
        "@dnd-kit/modifiers": "^9.0.0",
        "@dnd-kit/sortable": "^10.0.0",
        "@dnd-kit/utilities": "^3.2.2",
        "@mantine/core": "^9.0.0",
        "@mantine/form": "^9.0.0",
        "@mantine/hooks": "^9.0.0",
        "@mantine/modals": "^9.0.0",
        "@mantine/notifications": "^9.0.0",
        "hls.js": "^1.6.15",
        "react": "^19.2.4",
        "react-dom": "^19.2.4",
        "zustand": "^5.0.12"
    },
    "devDependencies": {
        "@types/react": "^19.2.14",
        "@types/react-dom": "^19.2.3",
        "@vitejs/plugin-react": "^6.0.1",
        "globals": "^17.4.0",
        "typescript": "~6.0.2",
        "vite": "^8.0.3",
        "vite-plugin-pwa": "^1.2.0"
    },
    "overrides": {
        "vite-plugin-pwa": {
            "vite": "$vite"
        }
    }
}


================================================
FILE: web/studio/src/App.tsx
================================================
import { MantineProvider } from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
import { AuthGuard } from "./components/AuthGuard";
import { theme } from "./theme";
import { Page } from "./page";

const App = () => {
    return (
        <MantineProvider defaultColorScheme="dark" theme={theme}>
            <ModalsProvider modalProps={{ transitionProps: { duration: 100 } }}>
                <Notifications position="bottom-right" autoClose={7000} />
                <AuthGuard>
                    <Page />
                </AuthGuard>
            </ModalsProvider>
        </MantineProvider>
    );
};

export default App;


================================================
FILE: web/studio/src/api/index.ts
================================================
import { PlaybackState, Playlist, ResponseErr, ResponseOK, StationInfo, Track, TracksPage } from "./types";
import { jsonRequestParams, queryParams } from "./utils";

export const API_HOST = "";
export const API_PREFIX = "/api/v1";

class AirstationAPI {
    private host: string;
    private prefix: string;
    private url: () => string;

    constructor(host: string, prefix: string) {
        this.host = host;
        this.prefix = prefix;
        this.url = () => `${this.host + this.prefix}`;
    }

    async login(secret: string) {
        const url = `${this.url()}/login`;
        return await this.makeRequest<ResponseOK>(url, jsonRequestParams("POST", { secret }));
    }

    async getPlayback() {
        const url = `${this.url()}/playback`;
        return await this.makeRequest<PlaybackState>(url);
    }

    async pausePlayback() {
        const url = `${this.url()}/playback/pause`;
        return await this.makeRequest<PlaybackState>(url, jsonRequestParams("POST", {}));
    }

    async playPlayback() {
        const url = `${this.url()}/playback/play`;
        return await this.makeRequest<PlaybackState>(url, jsonRequestParams("POST", {}));
    }

    async getTracks(page: number, limit: number, search: string, sortBy: keyof Track, sortOrder: "asc" | "desc") {
        const url = `${this.url()}/tracks?${queryParams({
            page,
            limit,
            search,
            sort_by: sortBy,
            sort_order: sortOrder,
        })}`;
        return await this.makeRequest<TracksPage>(url);
    }

    async uploadTracks(files: File[]) {
        const url = `${this.url()}/tracks`;
        const formData = new FormData();

        for (let i = 0; i < files.length; i++) {
            formData.append("tracks", files[i]);
        }

        return await this.makeRequest<ResponseOK>(url, {
            method: "POST",
            body: formData,
        });
    }

    async deleteTracks(ids: string[]) {
        const url = `${this.url()}/tracks`;
        return await this.makeRequest<ResponseOK>(url, jsonRequestParams("DELETE", { ids }));
    }

    async getQueue() {
        const url = `${this.url()}/queue`;
        return await this.makeRequest<Track[]>(url);
    }

    async addToQueue(trackIDs: string[]) {
        const url = `${this.url()}/queue`;
        return await this.makeRequest<ResponseOK>(url, jsonRequestParams("POST", { ids: trackIDs }));
    }

    async updateQueue(trackIDs: string[]) {
        const url = `${this.url()}/queue`;
        return await this.makeRequest<ResponseOK>(url, jsonRequestParams("PUT", { ids: trackIDs }));
    }

    async removeFromQueue(trackIDs: string[]) {
        const url = `${this.url()}/queue`;
        return await this.makeRequest<ResponseOK>(url, jsonRequestParams("DELETE", { ids: trackIDs }));
    }

    async addPlaylist(name: string, trackIDs: string[], description?: string) {
        const url = `${this.url()}/playlist`;
        return await this.makeRequest<Playlist>(url, jsonRequestParams("POST", { name, description, trackIDs }));
    }

    async getPlaylists() {
        const url = `${this.url()}/playlists`;
        return await this.makeRequest<Playlist[]>(url);
    }

    async getPlaylist(id: string) {
        const url = `${this.url()}/playlist/` + id;
        return await this.makeRequest<Playlist>(url);
    }

    async editPlaylist(id: string, name: string, trackIDs: string[], description?: string) {
        const url = `${this.url()}/playlist/` + id;
        return await this.makeRequest<ResponseOK>(url, jsonRequestParams("PUT", { name, description, trackIDs }));
    }

    async deletePlaylist(id: string) {
        const url = `${this.url()}/playlist/` + id;
        return await this.makeRequest<ResponseOK>(url, jsonRequestParams("DELETE", {}));
    }

    async getStationInfo() {
        const url = `${this.url()}/station/info`;
        return await this.makeRequest<StationInfo>(url);
    }

    async editStationInfo(info: StationInfo) {
        const url = `${this.url()}/station/info`;
        return await this.makeRequest<StationInfo>(url, jsonRequestParams("PUT", info));
    }

    private async makeRequest<T>(url: string, params: RequestInit = {}): Promise<T> {
        const resp = await fetch(url, params);
        if (!resp.ok) {
            const body: ResponseErr = await resp.json();
            throw new Error(body.message);
        }

        return resp.json();
    }
}

export const airstationAPI = new AirstationAPI(API_HOST, API_PREFIX);


================================================
FILE: web/studio/src/api/types.ts
================================================
export interface Track {
    id: string;
    name: string;
    path: string;
    duration: number;
    bitRate: number;
}

export interface TracksPage {
    tracks: Track[];
    page: number;
    limit: number;
    total: number;
}

export interface PlaybackState {
    currentTrack: Track | null;
    currentTrackElapsed: number;
    isPlaying: boolean;
    updatedAt: number;
}

export interface ResponseErr {
    message: string;
}

export interface ResponseOK {
    message: string;
}

export interface Playlist {
    id: string;
    name: string;
    description?: string;
    tracks: Track[];
    trackCount: number;
}

export interface StationInfo {
    name: string;
    description: string;
    faviconURL: string;
    logoURL: string;
    location: string;
    timezone: string;
    links: string;
    theme: string;
}


================================================
FILE: web/studio/src/api/utils.ts
================================================
export const jsonRequestParams = (method: string, body: Record<string, any>) => {
    return {
        method,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
    };
};

export const queryParams = (params: Record<string, any>) => {
    removeEmptyFields(params);
    return new URLSearchParams(params).toString();
};

const removeEmptyFields = (obj: Record<string, any>) => {
    for (const key in obj) {
        if (obj.hasOwnProperty(key) && [undefined, null, ""].includes(obj[key])) {
            delete obj[key];
        }
    }
};


================================================
FILE: web/studio/src/components/AudioPlayer.tsx
================================================
import React, { useEffect, useRef } from "react";
import { useThrottledState } from "@mantine/hooks";
import { ActionIcon, Box, Checkbox, Flex, Group, Progress, Text, Tooltip, useMantineColorScheme } from "@mantine/core";
import { API_HOST } from "../api";
import { Track } from "../api/types";
import { formatTime } from "../utils/time";
import { IconPlayerPlayFilled } from "../icons";
import { IconPlayerStopFilled } from "../icons";

interface AudioPlayerProps {
    track: Track;
    isPlaying: boolean;
    selected: Set<string>;
    setSelected: React.Dispatch<React.SetStateAction<Set<string>>>;
    togglePlaying: () => void;
}

export const AudioPlayer: React.FC<AudioPlayerProps> = ({ track, isPlaying, selected, setSelected, togglePlaying }) => {
    const audioRef = useRef<HTMLAudioElement>(null);
    const [progress, setProgress] = useThrottledState(0, 500);
    const [cursorPos, setCursorPos] = useThrottledState(0, 100);
    const { colorScheme } = useMantineColorScheme();

    const btnColor = colorScheme === "dark" ? "gray" : "black";

    const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
        if (audioRef.current) {
            const rect = e.currentTarget.getBoundingClientRect();
            const clickPosition = (e.clientX - rect.left) / rect.width;
            const newTime = clickPosition * audioRef.current.duration;
            audioRef.current.currentTime = newTime;
            setProgress(clickPosition * 100);
            if (!isPlaying) togglePlaying();
        }
    };

    const handleTimeUpdate = () => {
        if (audioRef.current) {
            const currentTime = audioRef.current.currentTime;
            const duration = audioRef.current.duration;
            setProgress((currentTime / duration) * 100);
        }
    };

    const handleAudioEnd = () => {
        if (audioRef.current) {
            audioRef.current.pause();
            audioRef.current.currentTime = 0;
            t
Download .txt
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
Download .txt
SYMBOL INDEX (340 symbols across 67 files)

FILE: cmd/main.go
  function main (line 18) | func main() {
  function shutdown (line 43) | func shutdown(log *slog.Logger, store storage.Storage) {

FILE: internal/config/config.go
  constant minSecretLength (line 12) | minSecretLength = 10
  type Config (line 14) | type Config struct
  function Load (line 27) | func Load() *Config {
  function getEnv (line 44) | func getEnv(key, defaultValue string) string {
  function getEnvBool (line 51) | func getEnvBool(key string, defaultValue bool) bool {
  function getSecret (line 61) | func getSecret(key string) string {

FILE: internal/http/consts.go
  constant eventPlay (line 4) | eventPlay           = "play"
  constant eventPause (line 5) | eventPause          = "pause"
  constant eventNewTrack (line 6) | eventNewTrack       = "new_track"
  constant eventLoadedTracks (line 7) | eventLoadedTracks   = "loaded_tracks"
  constant eventCountListeners (line 8) | eventCountListeners = "count_listeners"
  constant eventChangeTheme (line 9) | eventChangeTheme    = "change_theme"

FILE: internal/http/handlers.go
  constant multipartChunkLimit (line 21) | multipartChunkLimit = 64 * 1024 * 1024
  constant copyBufferSize (line 22) | copyBufferSize = 256 * 1024
  method handleHLSPlaylist (line 24) | func (s *Server) handleHLSPlaylist(w http.ResponseWriter, r *http.Reques...
  method handleEvents (line 32) | func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
  method handleLogin (line 63) | func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
  method handleTracks (line 107) | func (s *Server) handleTracks(w http.ResponseWriter, r *http.Request) {
  method handleTracksUpload (line 124) | func (s *Server) handleTracksUpload(w http.ResponseWriter, r *http.Reque...
  method handleDeleteTracks (line 151) | func (s *Server) handleDeleteTracks(w http.ResponseWriter, r *http.Reque...
  method handleQueue (line 168) | func (s *Server) handleQueue(w http.ResponseWriter, _ *http.Request) {
  method handleAddToQueue (line 179) | func (s *Server) handleAddToQueue(w http.ResponseWriter, r *http.Request) {
  method handleReorderQueue (line 206) | func (s *Server) handleReorderQueue(w http.ResponseWriter, r *http.Reque...
  method handleRemoveFromQueue (line 227) | func (s *Server) handleRemoveFromQueue(w http.ResponseWriter, r *http.Re...
  method handlePlaybackState (line 255) | func (s *Server) handlePlaybackState(w http.ResponseWriter, _ *http.Requ...
  method handlePausePlayback (line 259) | func (s *Server) handlePausePlayback(w http.ResponseWriter, _ *http.Requ...
  method handlePlayPlayback (line 264) | func (s *Server) handlePlayPlayback(w http.ResponseWriter, _ *http.Reque...
  method handlePlaybackHistory (line 274) | func (s *Server) handlePlaybackHistory(w http.ResponseWriter, r *http.Re...
  method handleAddPlaylist (line 287) | func (s *Server) handleAddPlaylist(w http.ResponseWriter, r *http.Reques...
  method handlePlaylists (line 307) | func (s *Server) handlePlaylists(w http.ResponseWriter, r *http.Request) {
  method handlePlaylist (line 316) | func (s *Server) handlePlaylist(w http.ResponseWriter, r *http.Request) {
  method handleEditPlaylist (line 327) | func (s *Server) handleEditPlaylist(w http.ResponseWriter, r *http.Reque...
  method handleDeletePlaylist (line 349) | func (s *Server) handleDeletePlaylist(w http.ResponseWriter, r *http.Req...
  method handleStaticDir (line 360) | func (s *Server) handleStaticDir(prefix string, path string) http.Handler {
  method handleStaticDirWithoutCache (line 364) | func (s *Server) handleStaticDirWithoutCache(prefix string, path string)...
  method handleStationInfo (line 373) | func (s *Server) handleStationInfo(w http.ResponseWriter, _ *http.Reques...
  method handleEditStationInfo (line 383) | func (s *Server) handleEditStationInfo(w http.ResponseWriter, r *http.Re...
  method saveFile (line 401) | func (s *Server) saveFile(fileHeader *multipart.FileHeader) (string, err...

FILE: internal/http/messages.go
  type Message (line 8) | type Message struct
  function jsonResponse (line 12) | func jsonResponse(w http.ResponseWriter, data interface{}) {
  function jsonMessage (line 20) | func jsonMessage(w http.ResponseWriter, code int, body string) {
  function jsonOK (line 27) | func jsonOK(w http.ResponseWriter, body string) {
  function jsonBadRequest (line 31) | func jsonBadRequest(w http.ResponseWriter, body string) {
  function jsonUnauthorized (line 35) | func jsonUnauthorized(w http.ResponseWriter, body string) {
  function jsonForbidden (line 39) | func jsonForbidden(w http.ResponseWriter, body string) {
  function jsonInternalError (line 43) | func jsonInternalError(w http.ResponseWriter, body string) {

FILE: internal/http/middlewares.go
  method jwtAuth (line 10) | func (s *Server) jwtAuth(next http.Handler) http.Handler {

FILE: internal/http/parser.go
  function parseIntQuery (line 12) | func parseIntQuery(queries url.Values, key string, defaultValue int) int {
  function parseJSONBody (line 22) | func parseJSONBody[T any](r *http.Request) (*T, error) {

FILE: internal/http/server.go
  type Server (line 23) | type Server struct
    method Run (line 59) | func (s *Server) Run() {
    method registerMP2TMimeType (line 110) | func (s *Server) registerMP2TMimeType() {
    method countListeners (line 117) | func (s *Server) countListeners() *sse.Event {
    method listenEvents (line 122) | func (s *Server) listenEvents() {
  function NewServer (line 36) | func NewServer(store storage.Storage, conf *config.Config, logger *slog....

FILE: internal/logger/logger.go
  type logEntry (line 14) | type logEntry struct
  type customJSONHandler (line 22) | type customJSONHandler struct
    method Enabled (line 29) | func (h *customJSONHandler) Enabled(ctx context.Context, level slog.Le...
    method Handle (line 36) | func (h *customJSONHandler) Handle(ctx context.Context, r slog.Record)...
    method WithAttrs (line 88) | func (h *customJSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
    method WithGroup (line 101) | func (h *customJSONHandler) WithGroup(name string) slog.Handler {
  function jsonEscape (line 83) | func jsonEscape(s string) string {
  function newCustomJSONHandler (line 110) | func newCustomJSONHandler(w io.Writer, opts *slog.HandlerOptions) *custo...
  function New (line 118) | func New() *slog.Logger {

FILE: internal/pkg/ffmpeg/const.go
  constant ffmpegBin (line 5) | ffmpegBin  = "ffmpeg"
  constant ffprobeBin (line 6) | ffprobeBin = "ffprobe"

FILE: internal/pkg/ffmpeg/ffmpeg.go
  type CLI (line 18) | type CLI struct
    method MakeHLSPlaylist (line 36) | func (cli *CLI) MakeHLSPlaylist(trackPath, outDir, segName string, seg...
    method AudioMetadata (line 76) | func (cli *CLI) AudioMetadata(filePath string) (AudioMetadata, error) {
    method PadAudio (line 148) | func (cli *CLI) PadAudio(filePath string, padDuration float64, meta Au...
    method TrimAudio (line 194) | func (cli *CLI) TrimAudio(filePath string, totalDuration float64) error {
    method ConvertAudioToAAC (line 236) | func (cli *CLI) ConvertAudioToAAC(inputPath, outputPath string, bitRat...
    method generateSilence (line 257) | func (cli *CLI) generateSilence(duration float64, bitRate, sampleRate,...
  function NewCLI (line 21) | func NewCLI() *CLI {

FILE: internal/pkg/ffmpeg/types.go
  type AudioMetadata (line 4) | type AudioMetadata struct
  type rawAudioMetadata (line 13) | type rawAudioMetadata struct

FILE: internal/pkg/fs/fs.go
  function MustDir (line 10) | func MustDir(dirPath string) {
  function FileExists (line 17) | func FileExists(filePath string) error {
  function DeleteFile (line 30) | func DeleteFile(filePath string) error {
  function DeleteDirIfExists (line 39) | func DeleteDirIfExists(path string) error {
  function RenameFile (line 55) | func RenameFile(oldPath, newPath string) error {
  function ListFilesFromDir (line 64) | func ListFilesFromDir(dirPath, fileExt string) ([]string, error) {

FILE: internal/pkg/hls/const.go
  constant SegmentExtension (line 3) | SegmentExtension = ".ts"
  constant timeFormat (line 4) | timeFormat = "2006-01-02T15:04:05.000Z"
  constant DefaultMaxSegmentDuration (line 7) | DefaultMaxSegmentDuration = 5
  constant DefaultLiveSegmentsAmount (line 8) | DefaultLiveSegmentsAmount = 3

FILE: internal/pkg/hls/playlist.go
  type Playlist (line 11) | type Playlist struct
    method Generate (line 58) | func (p *Playlist) Generate(elapsedTime float64) string {
    method Next (line 85) | func (p *Playlist) Next(next []*Segment) {
    method ChangeNext (line 94) | func (p *Playlist) ChangeNext(next []*Segment) {
    method AddSegments (line 102) | func (p *Playlist) AddSegments(segments []*Segment) {
    method SetMediaSequence (line 107) | func (p *Playlist) SetMediaSequence(sequence int64) {
    method UpdateDisconSequence (line 115) | func (p *Playlist) UpdateDisconSequence(elapsedTime float64) {
    method FirstNextTrackSegment (line 131) | func (p *Playlist) FirstNextTrackSegment() *Segment {
    method currentSegments (line 140) | func (p *Playlist) currentSegments(elapsedTime float64) []*Segment {
    method calcCurrentSegmentIndex (line 161) | func (p *Playlist) calcCurrentSegmentIndex(elapsedTime float64) int {
  function NewPlaylist (line 33) | func NewPlaylist(cur, next []*Segment) *Playlist {
  function hlsHeader (line 166) | func hlsHeader(dur int, mediaSeq, disconSeq int64, offset float64) string {
  function hlsSegment (line 178) | func hlsSegment(dur float64, path string, isDiscon bool) string {

FILE: internal/pkg/hls/playlist_test.go
  function TestNewPlaylist (line 9) | func TestNewPlaylist(t *testing.T) {
  function TestGenerate (line 31) | func TestGenerate(t *testing.T) {
  function TestNext (line 140) | func TestNext(t *testing.T) {
  function TestAddSegments (line 162) | func TestAddSegments(t *testing.T) {
  function TestCollectLiveSegments (line 182) | func TestCollectLiveSegments(t *testing.T) {
  function TestHlsHeader (line 320) | func TestHlsHeader(t *testing.T) {
  function TestHlsSegment (line 405) | func TestHlsSegment(t *testing.T) {

FILE: internal/pkg/hls/segment.go
  type Segment (line 10) | type Segment struct
  function NewSegment (line 25) | func NewSegment(duration float64, path string, isFirst bool) *Segment {
  function GenerateSegments (line 44) | func GenerateSegments(trackDuration float64, segmentDuration int, trackI...

FILE: internal/pkg/hls/segment_test.go
  function TestNewSegment (line 7) | func TestNewSegment(t *testing.T) {
  function TestGenerateSegments (line 78) | func TestGenerateSegments(t *testing.T) {

FILE: internal/pkg/sql/sql.go
  function BuildInClause (line 9) | func BuildInClause(column string, num int) string {
  function ColumnExists (line 19) | func ColumnExists(db *sql.DB, tableName, columnName string) (bool, error) {
  function TableExists (line 35) | func TableExists(db *sql.DB, tableName string) (bool, error) {

FILE: internal/pkg/sse/emitter.go
  type Emitter (line 8) | type Emitter struct
    method RegisterEvent (line 26) | func (ee *Emitter) RegisterEvent(name, data string) {
    method CountSubscribers (line 39) | func (ee *Emitter) CountSubscribers() int {
    method Subscribe (line 52) | func (ee *Emitter) Subscribe(eventChan chan *Event) {
    method Unsubscribe (line 60) | func (ee *Emitter) Unsubscribe(eventChan chan *Event) {
  function NewEmitter (line 16) | func NewEmitter() *Emitter {

FILE: internal/pkg/sse/event.go
  type Event (line 11) | type Event struct
    method Stringify (line 37) | func (e *Event) Stringify() string {
  function NewEvent (line 24) | func NewEvent(name, data string) *Event {

FILE: internal/pkg/ulid/ulid.go
  constant Length (line 13) | Length = ulid.EncodedSize
  type generator (line 20) | type generator struct
  function New (line 25) | func New() string {
  function Verify (line 31) | func Verify(s string) error {
  function initGenerator (line 39) | func initGenerator() *generator {

FILE: internal/playback/service.go
  type Service (line 8) | type Service struct
    method AddPlaybackHistory (line 23) | func (s *Service) AddPlaybackHistory(trackName string) {
    method RecentPlaybackHistory (line 37) | func (s *Service) RecentPlaybackHistory(limit int) ([]*History, error) {
    method DeleteOldPlaybackHistory (line 43) | func (s *Service) DeleteOldPlaybackHistory() {
  function NewService (line 13) | func NewService(store Store) *Service {

FILE: internal/playback/state.go
  type State (line 22) | type State struct
    method Run (line 72) | func (s *State) Run() {
    method Play (line 102) | func (s *State) Play() error {
    method Pause (line 131) | func (s *State) Pause() {
    method Reload (line 145) | func (s *State) Reload() error {
    method initHLSPlaylist (line 180) | func (s *State) initHLSPlaylist(current, next *track.Track) error {
    method loadNextTrack (line 200) | func (s *State) loadNextTrack() error {
    method makeHLSSegments (line 224) | func (s *State) makeHLSSegments(track *track.Track, dir string) ([]*hl...
  function NewState (line 48) | func NewState(ts *track.Service, qs *queue.Service, ps *Service, tmpDir ...

FILE: internal/playback/types.go
  type History (line 3) | type History struct
  type Store (line 9) | type Store interface

FILE: internal/playlist/consts.go
  constant minNameLen (line 4) | minNameLen  = 3
  constant maxNameLen (line 5) | maxNameLen  = 128
  constant maxDescrLen (line 6) | maxDescrLen = 4096
  constant maxTracks (line 7) | maxTracks   = 100

FILE: internal/playlist/service.go
  type Service (line 8) | type Service struct
    method AddPlaylist (line 18) | func (s *Service) AddPlaylist(name, description string, trackIDs []str...
    method Playlists (line 46) | func (s *Service) Playlists() ([]*Playlist, error) {
    method Playlist (line 51) | func (s *Service) Playlist(id string) (*Playlist, error) {
    method EditPlaylist (line 56) | func (s *Service) EditPlaylist(id, name, description string, trackIDs ...
    method DeletePlaylist (line 76) | func (s *Service) DeletePlaylist(id string) error {
  function NewService (line 12) | func NewService(store Store) *Service {
  function validateName (line 81) | func validateName(name string) error {
  function validateDescr (line 91) | func validateDescr(descr string) error {
  function validateTracks (line 98) | func validateTracks(trackIDs []string) error {

FILE: internal/playlist/types.go
  type Playlist (line 5) | type Playlist struct
  type Store (line 13) | type Store interface

FILE: internal/queue/service.go
  type Service (line 13) | type Service struct
    method Queue (line 27) | func (s *Service) Queue() ([]*track.Track, error) {
    method AddToQueue (line 39) | func (s *Service) AddToQueue(tracks []*track.Track) error {
    method ReorderQueue (line 51) | func (s *Service) ReorderQueue(ids []string) error {
    method RemoveFromQueue (line 63) | func (s *Service) RemoveFromQueue(ids []string) error {
    method SpinQueue (line 72) | func (s *Service) SpinQueue() error {
    method CurrentAndNextTrack (line 81) | func (s *Service) CurrentAndNextTrack() (*track.Track, *track.Track, e...
    method CleanupHLSPlaylists (line 93) | func (s *Service) CleanupHLSPlaylists(dirPath string) error {
  function NewService (line 17) | func NewService(store Store) *Service {

FILE: internal/queue/types.go
  type Store (line 5) | type Store interface

FILE: internal/station/const.go
  constant propName (line 4) | propName        = "name"
  constant propDescription (line 5) | propDescription = "description"
  constant propFaviconURL (line 6) | propFaviconURL  = "faviconURL"
  constant propLogoURL (line 7) | propLogoURL     = "logoURL"
  constant propLocation (line 8) | propLocation    = "location"
  constant propTimezone (line 9) | propTimezone    = "timezone"
  constant propLinks (line 10) | propLinks       = "links"
  constant propTheme (line 11) | propTheme       = "theme"

FILE: internal/station/service.go
  type Service (line 3) | type Service struct
    method Info (line 13) | func (s *Service) Info() (*Info, error) {
    method EditInfo (line 45) | func (s *Service) EditInfo(editedInfo *Info) (*Info, error) {
  function NewService (line 7) | func NewService(store Store) *Service {

FILE: internal/station/types.go
  type Info (line 3) | type Info struct
  type Property (line 14) | type Property struct
  type Store (line 19) | type Store interface

FILE: internal/storage/sqlite/migrations/migrations.go
  type Migration (line 8) | type Migration struct

FILE: internal/storage/sqlite/migrations/runner.go
  function RunMigrations (line 11) | func RunMigrations(db *sql.DB, log *slog.Logger) error {

FILE: internal/storage/sqlite/playback.go
  type PlaybackStore (line 11) | type PlaybackStore struct
    method AddPlaybackHistory (line 23) | func (ps *PlaybackStore) AddPlaybackHistory(playedAt int64, trackName ...
    method RecentPlaybackHistory (line 37) | func (ps *PlaybackStore) RecentPlaybackHistory(limit int) ([]*playback...
    method DeleteOldPlaybackHistory (line 65) | func (ps *PlaybackStore) DeleteOldPlaybackHistory() (int64, error) {
  function NewPlaybackStore (line 16) | func NewPlaybackStore(db *sql.DB, mutex *sync.Mutex) PlaybackStore {

FILE: internal/storage/sqlite/playlist.go
  type PlaylistStore (line 13) | type PlaylistStore struct
    method AddPlaylist (line 26) | func (ps *PlaylistStore) AddPlaylist(name, description string, trackID...
    method Playlists (line 56) | func (ps *PlaylistStore) Playlists() ([]*playlist.Playlist, error) {
    method Playlist (line 86) | func (ps *PlaylistStore) Playlist(id string) (*playlist.Playlist, erro...
    method IsPlaylistExists (line 122) | func (ps *PlaylistStore) IsPlaylistExists(name string) (bool, error) {
    method EditPlaylist (line 139) | func (ps *PlaylistStore) EditPlaylist(id, name, description string, tr...
    method DeletePlaylist (line 171) | func (ps *PlaylistStore) DeletePlaylist(id string) error {
  function NewPlaylistStore (line 18) | func NewPlaylistStore(db *sql.DB, mutex *sync.Mutex) PlaylistStore {

FILE: internal/storage/sqlite/queue.go
  type QueueStore (line 12) | type QueueStore struct
    method Queue (line 24) | func (qs *QueueStore) Queue() ([]*track.Track, error) {
    method AddToQueue (line 57) | func (qs *QueueStore) AddToQueue(tracks []*track.Track) error {
    method RemoveFromQueue (line 77) | func (qs *QueueStore) RemoveFromQueue(trackIDs []string) error {
    method ReorderQueue (line 92) | func (qs *QueueStore) ReorderQueue(trackIDs []string) error {
    method CurrentAndNextTrack (line 112) | func (qs *QueueStore) CurrentAndNextTrack() (*track.Track, *track.Trac...
    method SpinQueue (line 159) | func (qs *QueueStore) SpinQueue() error {
  function NewQueueStore (line 17) | func NewQueueStore(db *sql.DB, mutex *sync.Mutex) QueueStore {

FILE: internal/storage/sqlite/sqlite.go
  type Instance (line 13) | type Instance struct
    method Close (line 61) | func (ins *Instance) Close() error {
  function New (line 25) | func New(dbPath string, log *slog.Logger) (*Instance, error) {

FILE: internal/storage/sqlite/station.go
  type StationStore (line 11) | type StationStore struct
    method StationProperties (line 23) | func (ss *StationStore) StationProperties() ([]*station.Property, erro...
    method UpsertStationProperty (line 52) | func (ss *StationStore) UpsertStationProperty(key, value string) (*sta...
    method DeleteStationProperty (line 73) | func (ss *StationStore) DeleteStationProperty(key string) error {
  function NewStationStore (line 16) | func NewStationStore(db *sql.DB, mutex *sync.Mutex) StationStore {

FILE: internal/storage/sqlite/track.go
  type TrackStore (line 15) | type TrackStore struct
    method Tracks (line 27) | func (ts *TrackStore) Tracks(page, limit int, search, sortBy, sortOrde...
    method AddTrack (line 83) | func (ts *TrackStore) AddTrack(name, path string, duration float64, bi...
    method DeleteTracks (line 105) | func (ts *TrackStore) DeleteTracks(IDs []string) error {
    method EditTrack (line 120) | func (ts *TrackStore) EditTrack(track *track.Track) (*track.Track, err...
    method TrackByID (line 139) | func (ts *TrackStore) TrackByID(ID string) (*track.Track, error) {
    method TracksByIDs (line 158) | func (ts *TrackStore) TracksByIDs(IDs []string) ([]*track.Track, error) {
  function NewTrackStore (line 20) | func NewTrackStore(db *sql.DB, mutex *sync.Mutex) TrackStore {

FILE: internal/storage/storage.go
  type Storage (line 11) | type Storage interface

FILE: internal/track/consts.go
  constant minAllowedTrackDuration (line 6) | minAllowedTrackDuration = hls.DefaultMaxSegmentDuration * hls.DefaultLiv...
  constant maxAllowedTrackDuration (line 7) | maxAllowedTrackDuration = 36000
  constant defaultAudioBitRate (line 8) | defaultAudioBitRate     = 192
  constant m4aExtension (line 12) | m4aExtension  = "m4a"
  constant mp3Extension (line 13) | mp3Extension  = "mp3"
  constant aacExtension (line 14) | aacExtension  = "aac"
  constant wavExtension (line 15) | wavExtension  = "wav"
  constant flacExtension (line 16) | flacExtension = "flac"

FILE: internal/track/service.go
  type Service (line 17) | type Service struct
    method AddTrack (line 51) | func (s *Service) AddTrack(name, path string) (*Track, error) {
    method PrepareTrack (line 87) | func (s *Service) PrepareTrack(filePath string) (string, error) {
    method Tracks (line 108) | func (s *Service) Tracks(page, limit int, search, sortBy, sortOrder st...
    method DeleteTracks (line 137) | func (s *Service) DeleteTracks(ids []string) error {
    method FindTracks (line 165) | func (s *Service) FindTracks(ids []string) ([]*Track, error) {
    method MakeHLSPlaylist (line 180) | func (s *Service) MakeHLSPlaylist(trackPath string, outDir string, seg...
    method LoadTracksFromDisk (line 193) | func (s *Service) LoadTracksFromDisk(tracksDir string) ([]*Track, erro...
    method modifyTrackDuration (line 253) | func (s *Service) modifyTrackDuration(path string, metadata ffmpeg.Aud...
  function NewService (line 33) | func NewService(store Store, ffmpegCLI *ffmpeg.CLI, log *slog.Logger) *S...
  function roundDuration (line 265) | func roundDuration(trackDuration, segmentDuration float64) float64 {
  function defineTrackName (line 278) | func defineTrackName(fileName, metaName string) string {
  function replaceExtension (line 292) | func replaceExtension(path string, newExt string) string {

FILE: internal/track/types.go
  type Track (line 4) | type Track struct
  type Store (line 12) | type Store interface
  type Page (line 22) | type Page struct
  type BodyWithIDs (line 29) | type BodyWithIDs struct

FILE: web/player/src/api/index.ts
  constant API_HOST (line 4) | const API_HOST = "";
  constant API_PREFIX (line 5) | const API_PREFIX = "/api/v1";
  class AirstationAPI (line 7) | class AirstationAPI {
    method constructor (line 12) | constructor(host: string, prefix: string) {
    method getPlayback (line 18) | async getPlayback() {
    method getPlaybackHistory (line 23) | async getPlaybackHistory(limit?: number) {
    method getStationInfo (line 29) | async getStationInfo() {
    method makeRequest (line 34) | private async makeRequest<T>(url: string, params: RequestInit = {}): P...

FILE: web/player/src/api/types.ts
  type Track (line 1) | interface Track {
  type PlaybackState (line 9) | interface PlaybackState {
  type PlaybackHistory (line 15) | interface PlaybackHistory {
  type StationInfo (line 21) | interface StationInfo {
  type ResponseErr (line 32) | interface ResponseErr {
  type ResponseOK (line 36) | interface ResponseOK {

FILE: web/player/src/const.ts
  constant DESKTOP_WIDTH (line 1) | const DESKTOP_WIDTH = 1100;
  constant MAX_HISTORY_LIMIT (line 2) | const MAX_HISTORY_LIMIT = 500;

FILE: web/player/src/page/RadioButton.tsx
  constant STREAM_SOURCE (line 11) | const STREAM_SOURCE = "/stream";

FILE: web/player/src/store/events.ts
  constant EVENT_SOURCE_URL (line 4) | const EVENT_SOURCE_URL = API_HOST + API_PREFIX + "/events";
  constant EVENTS (line 5) | const EVENTS = {

FILE: web/player/src/utils/color.ts
  function getHueFromHex (line 1) | function getHueFromHex(hex: string) {
  function isValidHexColor (line 29) | function isValidHexColor(hex: string) {

FILE: web/studio/src/api/index.ts
  constant API_HOST (line 4) | const API_HOST = "";
  constant API_PREFIX (line 5) | const API_PREFIX = "/api/v1";
  class AirstationAPI (line 7) | class AirstationAPI {
    method constructor (line 12) | constructor(host: string, prefix: string) {
    method login (line 18) | async login(secret: string) {
    method getPlayback (line 23) | async getPlayback() {
    method pausePlayback (line 28) | async pausePlayback() {
    method playPlayback (line 33) | async playPlayback() {
    method getTracks (line 38) | async getTracks(page: number, limit: number, search: string, sortBy: k...
    method uploadTracks (line 49) | async uploadTracks(files: File[]) {
    method deleteTracks (line 63) | async deleteTracks(ids: string[]) {
    method getQueue (line 68) | async getQueue() {
    method addToQueue (line 73) | async addToQueue(trackIDs: string[]) {
    method updateQueue (line 78) | async updateQueue(trackIDs: string[]) {
    method removeFromQueue (line 83) | async removeFromQueue(trackIDs: string[]) {
    method addPlaylist (line 88) | async addPlaylist(name: string, trackIDs: string[], description?: stri...
    method getPlaylists (line 93) | async getPlaylists() {
    method getPlaylist (line 98) | async getPlaylist(id: string) {
    method editPlaylist (line 103) | async editPlaylist(id: string, name: string, trackIDs: string[], descr...
    method deletePlaylist (line 108) | async deletePlaylist(id: string) {
    method getStationInfo (line 113) | async getStationInfo() {
    method editStationInfo (line 118) | async editStationInfo(info: StationInfo) {
    method makeRequest (line 123) | private async makeRequest<T>(url: string, params: RequestInit = {}): P...

FILE: web/studio/src/api/types.ts
  type Track (line 1) | interface Track {
  type TracksPage (line 9) | interface TracksPage {
  type PlaybackState (line 16) | interface PlaybackState {
  type ResponseErr (line 23) | interface ResponseErr {
  type ResponseOK (line 27) | interface ResponseOK {
  type Playlist (line 31) | interface Playlist {
  type StationInfo (line 39) | interface StationInfo {

FILE: web/studio/src/components/AudioPlayer.tsx
  type AudioPlayerProps (line 10) | interface AudioPlayerProps {

FILE: web/studio/src/components/AuthGuard.tsx
  constant MIN_SECRET_LENGTH (line 54) | const MIN_SECRET_LENGTH = 10;

FILE: web/studio/src/hooks/useIsMobile.ts
  constant MAX_MOBILE_WIDTH (line 3) | const MAX_MOBILE_WIDTH = 800;

FILE: web/studio/src/hooks/useThemeBlackColor.ts
  function useThemeBlackColor (line 3) | function useThemeBlackColor() {

FILE: web/studio/src/icons/types.ts
  type IconProps (line 1) | interface IconProps extends React.ComponentPropsWithoutRef<"svg"> {

FILE: web/studio/src/page/MobileBar.tsx
  type MobileBarProps (line 4) | interface MobileBarProps {
  constant MOBILE_BARS (line 9) | const MOBILE_BARS = ["Playback", "Queue", "Tracks"];

FILE: web/studio/src/page/TracksLibrary.tsx
  constant PAGE_LIMIT (line 33) | const PAGE_LIMIT = 20;

FILE: web/studio/src/store/events.ts
  constant EVENT_SOURCE_URL (line 4) | const EVENT_SOURCE_URL = API_HOST + API_PREFIX + "/events";
  constant EVENTS (line 5) | const EVENTS = {
  type EventHandler (line 11) | type EventHandler = (event: MessageEvent) => void;
  type EventSourceStore (line 13) | interface EventSourceStore {

FILE: web/studio/src/store/playback.ts
  type PlaybackStore (line 7) | interface PlaybackStore {
  method setPlayback (line 19) | setPlayback(pb) {
  method fetchPlayback (line 24) | async fetchPlayback() {
  method play (line 35) | async play() {
  method pause (line 41) | async pause() {
  method syncElapsedTime (line 47) | syncElapsedTime() {

FILE: web/studio/src/store/playlists.ts
  type PlaylistStore (line 5) | interface PlaylistStore {
  method setPlaylists (line 18) | setPlaylists(p) {
  method fetchPlaylists (line 22) | async fetchPlaylists() {
  method addPlaylist (line 27) | async addPlaylist(name, trackIDs, description) {
  method editPlaylist (line 33) | async editPlaylist(id: string, name: string, trackIDs: string[], descrip...
  method deletePlaylist (line 52) | async deletePlaylist(id) {

FILE: web/studio/src/store/settings.ts
  type SettingsStore (line 4) | interface SettingsStore {

FILE: web/studio/src/store/track-queue.ts
  type TrackQueueStore (line 5) | interface TrackQueueStore {
  method fetchQueue (line 17) | async fetchQueue() {
  method updateQueue (line 22) | async updateQueue(tracks) {
  method addToQueue (line 27) | async addToQueue(trackIDs: string[]) {
  method removeFromQueue (line 34) | async removeFromQueue(trackIDs: string[]) {
  method rotateQueue (line 41) | rotateQueue() {

FILE: web/studio/src/store/tracks.ts
  type TracksStore (line 5) | interface TracksStore {
  method setTracks (line 19) | setTracks(q) {
  method fetchTracks (line 23) | async fetchTracks(p: number, l: number, s: string, sb: keyof Track, so: ...
  method uploadTracks (line 42) | async uploadTracks(files: File[]) {
  method deleteTracks (line 47) | async deleteTracks(trackIDs: string[]) {

FILE: web/studio/src/types/index.ts
  type DisclosureHandler (line 1) | interface DisclosureHandler {
Condensed preview — 142 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (312K chars).
[
  {
    "path": ".dockerignore",
    "chars": 45,
    "preview": "docs\nMakefile\nREADME.md\nDockerfile.multiarch\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 45,
    "preview": "github: [cheatsnake]\nbuy_me_a_coffee: yurace\n"
  },
  {
    "path": ".github/workflows/docker-image.yml",
    "chars": 952,
    "preview": "name: Build and Push Docker Image\n\non:\n    push:\n        tags:\n            - \"*\"\n\njobs:\n    build:\n        runs-on: ubun"
  },
  {
    "path": ".gitignore",
    "chars": 18,
    "preview": ".env\nstatic\n*.db*\n"
  },
  {
    "path": "Dockerfile",
    "chars": 792,
    "preview": "FROM node:22-alpine AS player\nWORKDIR /app\nCOPY ./web/player/package*.json ./\nRUN npm install\nCOPY ./web/player .\nARG AI"
  },
  {
    "path": "Dockerfile.multiarch",
    "chars": 766,
    "preview": "FROM node:22-alpine AS player\nWORKDIR /app\nCOPY ./web/player/package*.json ./\nRUN npm install\nCOPY ./web/player .\nRUN np"
  },
  {
    "path": "LICENSE",
    "chars": 1067,
    "preview": "MIT License\n\nCopyright (c) 2025 cheatsnake\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
  },
  {
    "path": "Makefile",
    "chars": 600,
    "preview": ".PHONY: build\n\ntest:\n\t@go test -cover -race ./... | grep -v '^?'\nfmt:\n\tgo fmt ./...\ncount-lines:\n\t@echo \"total code line"
  },
  {
    "path": "README.md",
    "chars": 1303,
    "preview": "<br>\n<p align=\"center\">\n  <a href=\"https://github.com/cheatsnake/airstation\">\n    <img src=\"./docs/images/logo.png\" alt="
  },
  {
    "path": "cmd/main.go",
    "chars": 1210,
    "preview": "package main\n\nimport (\n\t\"log/slog\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path\"\n\t\"syscall\"\n\n\t\"github.com/cheatsnake/airstation/internal/co"
  },
  {
    "path": "docker-compose.yml",
    "chars": 330,
    "preview": "services:\n  app:\n    build:\n      context: .\n      dockerfile: Dockerfile\n      args:\n        - AIRSTATION_PLAYER_TITLE="
  },
  {
    "path": "docs/installation.md",
    "chars": 3201,
    "preview": "# Installation\n\nTo run Airstation on your machine, there are two ways: using [Docker](https://docs.docker.com/) (recomme"
  },
  {
    "path": "docs/overview.md",
    "chars": 3669,
    "preview": "# Overview\n\nAirstation allows you to organize your own internet radio station where your audio tracks will be played. Th"
  },
  {
    "path": "docs/roadmap.md",
    "chars": 1304,
    "preview": "# Roadmap\n\nHere you can find a list of future updates.\n\n## 🚧 In Progress\n\n- [ ] New music visualizers for player page\n\n-"
  },
  {
    "path": "go.mod",
    "chars": 720,
    "preview": "module github.com/cheatsnake/airstation\n\ngo 1.26\n\nrequire github.com/oklog/ulid/v2 v2.1.1\n\nrequire github.com/rs/cors v1"
  },
  {
    "path": "go.sum",
    "chars": 4996,
    "preview": "github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0."
  },
  {
    "path": "internal/config/config.go",
    "chars": 1729,
    "preview": "package config\n\nimport (\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/joho/godotenv\"\n)\n\nconst minSecretLength "
  },
  {
    "path": "internal/http/consts.go",
    "chars": 238,
    "preview": "package http\n\nconst (\n\teventPlay           = \"play\"\n\teventPause          = \"pause\"\n\teventNewTrack       = \"new_track\"\n\te"
  },
  {
    "path": "internal/http/handlers.go",
    "chars": 10808,
    "preview": "package http\n\nimport (\n\t\"crypto/subtle\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sl"
  },
  {
    "path": "internal/http/messages.go",
    "chars": 1032,
    "preview": "package http\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n)\n\ntype Message struct {\n\tMessage string `json:\"message\"`\n}\n\nfunc js"
  },
  {
    "path": "internal/http/middlewares.go",
    "chars": 720,
    "preview": "package http\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n)\n\nfunc (s *Server) jwtAuth(next http.Handler"
  },
  {
    "path": "internal/http/parser.go",
    "chars": 659,
    "preview": "package http\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n)\n\nfunc parseIntQuery(queries url"
  },
  {
    "path": "internal/http/server.go",
    "chars": 5671,
    "preview": "package http\n\nimport (\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/cheatsnake/airstation/internal/c"
  },
  {
    "path": "internal/logger/logger.go",
    "chars": 2562,
    "preview": "package logger\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"os\"\n\t\"time\"\n)\n\ntype logEntry st"
  },
  {
    "path": "internal/pkg/ffmpeg/const.go",
    "chars": 117,
    "preview": "package ffmpeg\n\n// Constants defining the executable names.\nconst (\n\tffmpegBin  = \"ffmpeg\"\n\tffprobeBin = \"ffprobe\"\n)\n"
  },
  {
    "path": "internal/pkg/ffmpeg/ffmpeg.go",
    "chars": 8527,
    "preview": "// Package ffmpeg provides a CLI wrapper for executing FFmpeg and FFprobe commands,\n// enabling audio processing functio"
  },
  {
    "path": "internal/pkg/ffmpeg/types.go",
    "chars": 927,
    "preview": "package ffmpeg\n\n// AudioMetadata holds metadata information about an audio file.\ntype AudioMetadata struct {\n\tName      "
  },
  {
    "path": "internal/pkg/fs/fs.go",
    "chars": 1781,
    "preview": "package fs\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\nfunc MustDir(dirPath string) {\n\terr := os.MkdirAll(dirP"
  },
  {
    "path": "internal/pkg/hls/const.go",
    "chars": 163,
    "preview": "package hls\n\nconst SegmentExtension = \".ts\"\nconst timeFormat = \"2006-01-02T15:04:05.000Z\"\n\nconst (\n\tDefaultMaxSegmentDur"
  },
  {
    "path": "internal/pkg/hls/playlist.go",
    "chars": 6264,
    "preview": "// Package hls provides functionality for handling HTTP Live Streaming (HLS) playlists and segments.\npackage hls\n\nimport"
  },
  {
    "path": "internal/pkg/hls/playlist_test.go",
    "chars": 11810,
    "preview": "package hls\n\nimport (\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestNewPlaylist(t *testing.T) {\n\tcurrent := []*Segment{{D"
  },
  {
    "path": "internal/pkg/hls/segment.go",
    "chars": 2423,
    "preview": "package hls\n\nimport (\n\t\"math\"\n\t\"path/filepath\"\n\t\"strconv\"\n)\n\n// Segment represents a single segment in an HLS playlist.\n"
  },
  {
    "path": "internal/pkg/hls/segment_test.go",
    "chars": 4131,
    "preview": "package hls\n\nimport (\n\t\"testing\"\n)\n\nfunc TestNewSegment(t *testing.T) {\n\tcases := []struct {\n\t\tname     string\n\t\tduratio"
  },
  {
    "path": "internal/pkg/sql/sql.go",
    "chars": 1154,
    "preview": "package sql\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"strings\"\n)\n\nfunc BuildInClause(column string, num int) string {\n\tif num ="
  },
  {
    "path": "internal/pkg/sse/emitter.go",
    "chars": 1541,
    "preview": "package sse\n\nimport (\n\t\"sync\"\n)\n\n// Emitter manages a set of subscribers and broadcasts events to them.\ntype Emitter str"
  },
  {
    "path": "internal/pkg/sse/event.go",
    "chars": 1386,
    "preview": "// Package events provides a lightweight structure and utilities for creating\n// and formatting Server-Sent Events (SSE)"
  },
  {
    "path": "internal/pkg/ulid/ulid.go",
    "chars": 743,
    "preview": "package ulid\n\nimport (\n\t\"crypto/rand\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/oklog/ulid/v2\"\n)\n\nconst Length = u"
  },
  {
    "path": "internal/playback/service.go",
    "chars": 1189,
    "preview": "package playback\n\nimport (\n\t\"log/slog\"\n\t\"time\"\n)\n\ntype Service struct {\n\tstore Store\n\tlog   *slog.Logger\n}\n\nfunc NewServ"
  },
  {
    "path": "internal/playback/state.go",
    "chars": 6688,
    "preview": "// Package playback manages audio playback state, track transitions, and HLS playlist generation.\n// It coordinates the "
  },
  {
    "path": "internal/playback/types.go",
    "chars": 328,
    "preview": "package playback\n\ntype History struct {\n\tID        int    `json:\"id\"`\n\tPlayedAt  int64  `json:\"playedAt\"`\n\tTrackName str"
  },
  {
    "path": "internal/playlist/consts.go",
    "chars": 103,
    "preview": "package playlist\n\nconst (\n\tminNameLen  = 3\n\tmaxNameLen  = 128\n\tmaxDescrLen = 4096\n\tmaxTracks   = 100\n)\n"
  },
  {
    "path": "internal/playlist/service.go",
    "chars": 2276,
    "preview": "package playlist\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n)\n\ntype Service struct {\n\tstore Store\n}\n\nfunc NewService(store Store) *Servi"
  },
  {
    "path": "internal/playlist/types.go",
    "chars": 648,
    "preview": "package playlist\n\nimport \"github.com/cheatsnake/airstation/internal/track\"\n\ntype Playlist struct {\n\tID          string  "
  },
  {
    "path": "internal/queue/service.go",
    "chars": 2855,
    "preview": "package queue\n\nimport (\n\t\"path\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/cheatsnake/airstation/internal/pkg/fs\"\n\t\"github.com/che"
  },
  {
    "path": "internal/queue/types.go",
    "chars": 331,
    "preview": "package queue\n\nimport \"github.com/cheatsnake/airstation/internal/track\"\n\ntype Store interface {\n\tQueue() ([]*track.Track"
  },
  {
    "path": "internal/station/const.go",
    "chars": 261,
    "preview": "package station\n\nconst (\n\tpropName        = \"name\"\n\tpropDescription = \"description\"\n\tpropFaviconURL  = \"faviconURL\"\n\tpro"
  },
  {
    "path": "internal/station/service.go",
    "chars": 2325,
    "preview": "package station\n\ntype Service struct {\n\tstore Store\n}\n\nfunc NewService(store Store) *Service {\n\treturn &Service{\n\t\tstore"
  },
  {
    "path": "internal/station/types.go",
    "chars": 560,
    "preview": "package station\n\ntype Info struct {\n\tName        string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tFaviconU"
  },
  {
    "path": "internal/storage/sqlite/migrations/migrations.go",
    "chars": 2973,
    "preview": "package migrations\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n)\n\ntype Migration struct {\n\tVersion int\n\tName    string\n\tUp      fun"
  },
  {
    "path": "internal/storage/sqlite/migrations/runner.go",
    "chars": 2256,
    "preview": "package migrations\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log/slog\"\n\n\tsqltool \"github.com/cheatsnake/airstation/internal/pkg"
  },
  {
    "path": "internal/storage/sqlite/playback.go",
    "chars": 1774,
    "preview": "package sqlite\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"sync\"\n\n\t\"github.com/cheatsnake/airstation/internal/playback\"\n)\n\ntype P"
  },
  {
    "path": "internal/storage/sqlite/playlist.go",
    "chars": 4230,
    "preview": "package sqlite\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"sync\"\n\n\t\"github.com/cheatsnake/airstation/internal/pkg/ulid\"\n\t\"github."
  },
  {
    "path": "internal/storage/sqlite/queue.go",
    "chars": 4499,
    "preview": "package sqlite\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"sync\"\n\n\t\"github.com/cheatsnake/airstation/internal/track\"\n)\n"
  },
  {
    "path": "internal/storage/sqlite/sqlite.go",
    "chars": 1571,
    "preview": "package sqlite\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"sync\"\n\n\t\"github.com/cheatsnake/airstation/internal/storage"
  },
  {
    "path": "internal/storage/sqlite/station.go",
    "chars": 1741,
    "preview": "package sqlite\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"sync\"\n\n\t\"github.com/cheatsnake/airstation/internal/station\"\n)\n\ntype"
  },
  {
    "path": "internal/storage/sqlite/track.go",
    "chars": 4694,
    "preview": "package sqlite\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\n\tsqltool \"github.com/cheatsnake/airstation"
  },
  {
    "path": "internal/storage/storage.go",
    "chars": 408,
    "preview": "package storage\n\nimport (\n\t\"github.com/cheatsnake/airstation/internal/playback\"\n\t\"github.com/cheatsnake/airstation/inter"
  },
  {
    "path": "internal/track/consts.go",
    "chars": 448,
    "preview": "package track\n\nimport \"github.com/cheatsnake/airstation/internal/pkg/hls\"\n\nconst (\n\tminAllowedTrackDuration = hls.Defaul"
  },
  {
    "path": "internal/track/service.go",
    "chars": 8677,
    "preview": "// Package trackservice provides services related to audio track management.\npackage track\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n"
  },
  {
    "path": "internal/track/types.go",
    "chars": 1366,
    "preview": "package track\n\n// Track represents an audio track with its associated metadata.\ntype Track struct {\n\tID       string  `j"
  },
  {
    "path": "web/player/.gitignore",
    "chars": 262,
    "preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndis"
  },
  {
    "path": "web/player/.prettierrc",
    "chars": 116,
    "preview": "{\n    \"trailingComma\": \"all\",\n    \"tabWidth\": 4,\n    \"semi\": true,\n    \"singleQuote\": false,\n    \"printWidth\": 120\n}"
  },
  {
    "path": "web/player/README.md",
    "chars": 19,
    "preview": "# Airstation Player"
  },
  {
    "path": "web/player/index.html",
    "chars": 530,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\" />\n        <link rel=\"icon\" type=\"image/svg+xm"
  },
  {
    "path": "web/player/package.json",
    "chars": 598,
    "preview": "{\n    \"name\": \"player\",\n    \"private\": true,\n    \"version\": \"0.0.0\",\n    \"type\": \"module\",\n    \"scripts\": {\n        \"dev"
  },
  {
    "path": "web/player/src/App.tsx",
    "chars": 97,
    "preview": "import { Page } from \"./page\";\n\nconst App = () => {\n    return <Page />;\n};\n\nexport default App;\n"
  },
  {
    "path": "web/player/src/api/index.ts",
    "chars": 1345,
    "preview": "import { PlaybackHistory, PlaybackState, ResponseErr, StationInfo } from \"./types\";\nimport { queryParams } from \"./utils"
  },
  {
    "path": "web/player/src/api/types.ts",
    "chars": 659,
    "preview": "export interface Track {\n    id: string;\n    name: string;\n    path: string;\n    duration: number;\n    bitRate: number;\n"
  },
  {
    "path": "web/player/src/api/utils.ts",
    "chars": 581,
    "preview": "export const jsonRequestParams = (method: string, body: Record<string, any>) => {\n    return {\n        method,\n        h"
  },
  {
    "path": "web/player/src/const.ts",
    "chars": 73,
    "preview": "export const DESKTOP_WIDTH = 1100;\nexport const MAX_HISTORY_LIMIT = 500;\n"
  },
  {
    "path": "web/player/src/index.css",
    "chars": 1249,
    "preview": ":root {\n    --bg-gradient-start: #29323c;\n    --bg-gradient-end: #485563;\n    --bg-icon: #a8a8a8;\n    --text-color: #fff"
  },
  {
    "path": "web/player/src/index.tsx",
    "chars": 185,
    "preview": "/* @refresh reload */\nimport { render } from 'solid-js/web'\nimport './index.css'\nimport App from './App.tsx'\n\nconst root"
  },
  {
    "path": "web/player/src/page/CurrentTrack.module.css",
    "chars": 433,
    "preview": ".box {\n    width: 100%;\n}\n\n.label,\n.offline_label {\n    display: flex;\n    justify-content: center;\n    align-items: cen"
  },
  {
    "path": "web/player/src/page/CurrentTrack.tsx",
    "chars": 1669,
    "preview": "import { onMount, Show } from \"solid-js\";\nimport { airstationAPI } from \"../api\";\nimport styles from \"./CurrentTrack.mod"
  },
  {
    "path": "web/player/src/page/History.module.css",
    "chars": 1121,
    "preview": ".history_icon {\n    width: 24px;\n    height: 24px;\n    cursor: pointer;\n    background-color: var(--bg-icon);\n    -webki"
  },
  {
    "path": "web/player/src/page/History.tsx",
    "chars": 2694,
    "preview": "import { Accessor, Component, createSignal, onMount } from \"solid-js\";\nimport styles from \"./History.module.css\";\nimport"
  },
  {
    "path": "web/player/src/page/ListenersCounter.module.css",
    "chars": 1033,
    "preview": ".counter {\n    color: var(--bg-gradient-end);\n    font-size: 0.9rem;\n}\n\n.box {\n    background-color: var(--bg-icon);\n   "
  },
  {
    "path": "web/player/src/page/ListenersCounter.tsx",
    "chars": 656,
    "preview": "import { createSignal, onMount } from \"solid-js\";\nimport { addEventListener, EVENTS } from \"../store/events\";\nimport sty"
  },
  {
    "path": "web/player/src/page/Page.module.css",
    "chars": 990,
    "preview": ".page {\n    display: flex;\n    flex-direction: column;\n    height: 100vh;\n}\n\n.header {\n    display: flex;\n    align-item"
  },
  {
    "path": "web/player/src/page/RadioButton.module.css",
    "chars": 976,
    "preview": "video {\n    display: none;\n}\n\n.container {\n    flex: 1;\n}\n\n.box {\n    width: 100%;\n    height: 100%;\n    display: flex;\n"
  },
  {
    "path": "web/player/src/page/RadioButton.tsx",
    "chars": 6348,
    "preview": "import HLS from \"hls.js\";\nimport styles from \"./RadioButton.module.css\";\nimport { setTrackStore, trackStore } from \"../s"
  },
  {
    "path": "web/player/src/page/StationInformation.module.css",
    "chars": 1864,
    "preview": ".info_menu {\n    position: fixed;\n    top: 0;\n    right: -500px;\n    width: 100%;\n    max-width: 500px;\n    height: 100v"
  },
  {
    "path": "web/player/src/page/StationInformation.tsx",
    "chars": 4023,
    "preview": "import { Accessor, Component, createSignal, onMount } from \"solid-js\";\nimport pageStyles from \"./Page.module.css\";\nimpor"
  },
  {
    "path": "web/player/src/page/index.tsx",
    "chars": 835,
    "preview": "import { onMount, onCleanup } from \"solid-js\";\nimport { CurrentTrack } from \"./CurrentTrack\";\nimport { ListenersCounter "
  },
  {
    "path": "web/player/src/store/events.ts",
    "chars": 1068,
    "preview": "import { createStore } from \"solid-js/store\";\nimport { API_HOST, API_PREFIX } from \"../api\";\n\nexport const EVENT_SOURCE_"
  },
  {
    "path": "web/player/src/store/history.ts",
    "chars": 254,
    "preview": "import { createSignal } from \"solid-js\";\nimport { PlaybackHistory } from \"../api/types\";\n\nexport const [history, setHist"
  },
  {
    "path": "web/player/src/store/track.ts",
    "chars": 146,
    "preview": "import { createStore } from \"solid-js/store\";\n\nexport const [trackStore, setTrackStore] = createStore({\n    trackName: \""
  },
  {
    "path": "web/player/src/utils/color.ts",
    "chars": 731,
    "preview": "export function getHueFromHex(hex: string) {\n    hex = hex.replace(\"#\", \"\");\n\n    const r = parseInt(hex.slice(0, 2), 16"
  },
  {
    "path": "web/player/src/utils/date.ts",
    "chars": 635,
    "preview": "export const formatDateToTimeFirst = (date: Date) => {\n    const timeParts = new Intl.DateTimeFormat(undefined, {\n      "
  },
  {
    "path": "web/player/src/utils/document.ts",
    "chars": 660,
    "preview": "export const setFavicon = (url: string) => {\n    let link = document.querySelector<HTMLLinkElement>(\"link[rel*='icon']\")"
  },
  {
    "path": "web/player/src/utils/url.ts",
    "chars": 210,
    "preview": "export const isValidURL = (str: string) => {\n    try {\n        const url = new URL(str);\n        return url.protocol ==="
  },
  {
    "path": "web/player/src/vite-env.d.ts",
    "chars": 38,
    "preview": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "web/player/tsconfig.app.json",
    "chars": 699,
    "preview": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2020\",\n"
  },
  {
    "path": "web/player/tsconfig.json",
    "chars": 119,
    "preview": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ]\n}\n"
  },
  {
    "path": "web/player/tsconfig.node.json",
    "chars": 593,
    "preview": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2022\","
  },
  {
    "path": "web/player/vite.config.ts",
    "chars": 3869,
    "preview": "import { defineConfig, loadEnv } from \"vite\";\nimport solid from \"vite-plugin-solid\";\nimport { VitePWA } from \"vite-plugi"
  },
  {
    "path": "web/studio/.gitignore",
    "chars": 262,
    "preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndis"
  },
  {
    "path": "web/studio/.prettierrc",
    "chars": 116,
    "preview": "{\n    \"trailingComma\": \"all\",\n    \"tabWidth\": 4,\n    \"semi\": true,\n    \"singleQuote\": false,\n    \"printWidth\": 120\n}"
  },
  {
    "path": "web/studio/README.md",
    "chars": 20,
    "preview": "# Airstation Studio\n"
  },
  {
    "path": "web/studio/index.html",
    "chars": 835,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\" />\n        <link rel=\"icon\" href=\"/favicon.svg"
  },
  {
    "path": "web/studio/package.json",
    "chars": 1069,
    "preview": "{\n    \"name\": \"studio\",\n    \"private\": true,\n    \"version\": \"0.0.0\",\n    \"type\": \"module\",\n    \"scripts\": {\n        \"dev"
  },
  {
    "path": "web/studio/src/App.tsx",
    "chars": 701,
    "preview": "import { MantineProvider } from \"@mantine/core\";\nimport { ModalsProvider } from \"@mantine/modals\";\nimport { Notification"
  },
  {
    "path": "web/studio/src/api/index.ts",
    "chars": 4515,
    "preview": "import { PlaybackState, Playlist, ResponseErr, ResponseOK, StationInfo, Track, TracksPage } from \"./types\";\nimport { jso"
  },
  {
    "path": "web/studio/src/api/types.ts",
    "chars": 829,
    "preview": "export interface Track {\n    id: string;\n    name: string;\n    path: string;\n    duration: number;\n    bitRate: number;\n"
  },
  {
    "path": "web/studio/src/api/utils.ts",
    "chars": 581,
    "preview": "export const jsonRequestParams = (method: string, body: Record<string, any>) => {\n    return {\n        method,\n        h"
  },
  {
    "path": "web/studio/src/components/AudioPlayer.tsx",
    "chars": 4647,
    "preview": "import React, { useEffect, useRef } from \"react\";\nimport { useThrottledState } from \"@mantine/hooks\";\nimport { ActionIco"
  },
  {
    "path": "web/studio/src/components/AuthGuard.tsx",
    "chars": 2849,
    "preview": "import { FC, JSX, useEffect, useState } from \"react\";\nimport { useDisclosure } from \"@mantine/hooks\";\nimport { Box, Butt"
  },
  {
    "path": "web/studio/src/components/EmptyLabel.tsx",
    "chars": 325,
    "preview": "import { Flex, Text } from \"@mantine/core\";\nimport { FC } from \"react\";\n\nexport const EmptyLabel: FC<{ label: string }> "
  },
  {
    "path": "web/studio/src/hooks/useIsMobile.ts",
    "chars": 248,
    "preview": "import { useViewportSize } from \"@mantine/hooks\";\n\nexport const MAX_MOBILE_WIDTH = 800;\n\nexport const useIsMobile = () ="
  },
  {
    "path": "web/studio/src/hooks/useThemeBlackColor.ts",
    "chars": 204,
    "preview": "import { useMantineColorScheme } from \"@mantine/core\";\n\nexport function useThemeBlackColor() {\n    const { colorScheme }"
  },
  {
    "path": "web/studio/src/icons/index.tsx",
    "chars": 10556,
    "preview": "import { FC } from \"react\";\nimport { IconProps } from \"./types\";\n\nexport const IconPlayerPlayFilled: FC<IconProps> = ({ "
  },
  {
    "path": "web/studio/src/icons/types.ts",
    "chars": 105,
    "preview": "export interface IconProps extends React.ComponentPropsWithoutRef<\"svg\"> {\n    size?: number | string;\n}\n"
  },
  {
    "path": "web/studio/src/index.css",
    "chars": 543,
    "preview": "* {\n    ::-webkit-scrollbar {\n        width: 7px;\n    }\n\n    ::-webkit-scrollbar-track {\n        background: #ffffff15;\n"
  },
  {
    "path": "web/studio/src/main.tsx",
    "chars": 241,
    "preview": "import { createRoot } from \"react-dom/client\";\nimport App from \"./App.tsx\";\n\nimport \"@mantine/core/styles.css\";\nimport \""
  },
  {
    "path": "web/studio/src/notifications/index.ts",
    "chars": 933,
    "preview": "import { notifications } from \"@mantine/notifications\";\nimport { handleErr } from \"../utils/error\";\n\nexport const errNot"
  },
  {
    "path": "web/studio/src/page/DesktopPage.tsx",
    "chars": 996,
    "preview": "import { Container, Flex, SimpleGrid } from \"@mantine/core\";\nimport { FC } from \"react\";\nimport { Playback } from \"./Pla"
  },
  {
    "path": "web/studio/src/page/MobileBar.tsx",
    "chars": 806,
    "preview": "import { Button, Flex } from \"@mantine/core\";\nimport { FC } from \"react\";\n\ninterface MobileBarProps {\n    activeBar: str"
  },
  {
    "path": "web/studio/src/page/MobilePage.tsx",
    "chars": 982,
    "preview": "import { Flex } from \"@mantine/core\";\nimport { useState } from \"react\";\nimport { MobileBar } from \"./MobileBar\";\nimport "
  },
  {
    "path": "web/studio/src/page/Playback.tsx",
    "chars": 8569,
    "preview": "import {\n    ActionIcon,\n    Box,\n    Flex,\n    MantineSize,\n    Paper,\n    Progress,\n    Space,\n    Text,\n    Tooltip,\n"
  },
  {
    "path": "web/studio/src/page/Playlists.tsx",
    "chars": 16653,
    "preview": "import { FC, useEffect, useState } from \"react\";\nimport { useDebouncedState, useDisclosure } from \"@mantine/hooks\";\nimpo"
  },
  {
    "path": "web/studio/src/page/Settings.tsx",
    "chars": 14288,
    "preview": "import { FC, useEffect, useState } from \"react\";\nimport { useDisclosure } from \"@mantine/hooks\";\nimport {\n    Accordion,"
  },
  {
    "path": "web/studio/src/page/TracksLibrary.tsx",
    "chars": 13130,
    "preview": "import { FC, useCallback, useEffect, useRef, useState } from \"react\";\nimport {\n    ActionIcon,\n    Box,\n    Button,\n    "
  },
  {
    "path": "web/studio/src/page/TracksQueue.tsx",
    "chars": 8535,
    "preview": "import { FC, useEffect, useState } from \"react\";\nimport {\n    ActionIcon,\n    Box,\n    Button,\n    CloseButton,\n    Flex"
  },
  {
    "path": "web/studio/src/page/index.tsx",
    "chars": 473,
    "preview": "import { lazy, Suspense } from \"react\";\nimport { useIsMobile } from \"../hooks/useIsMobile\";\n\nconst DesktopPage = lazy(()"
  },
  {
    "path": "web/studio/src/page/styles.module.css",
    "chars": 87,
    "preview": ".transparent_paper {\n    position: relative;\n    background-color: rgb(0 0 0 / 30%);\n}\n"
  },
  {
    "path": "web/studio/src/store/events.ts",
    "chars": 1224,
    "preview": "import { create } from \"zustand\";\nimport { API_HOST, API_PREFIX } from \"../api\";\n\nconst EVENT_SOURCE_URL = API_HOST + AP"
  },
  {
    "path": "web/studio/src/store/playback.ts",
    "chars": 2055,
    "preview": "import { create } from \"zustand\";\nimport { PlaybackState } from \"../api/types\";\nimport { airstationAPI } from \"../api\";\n"
  },
  {
    "path": "web/studio/src/store/playlists.ts",
    "chars": 1803,
    "preview": "import { create } from \"zustand\";\nimport { Playlist, ResponseOK } from \"../api/types\";\nimport { airstationAPI } from \".."
  },
  {
    "path": "web/studio/src/store/settings.ts",
    "chars": 412,
    "preview": "import { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\n\ninterface SettingsStore {\n    interface"
  },
  {
    "path": "web/studio/src/store/track-queue.ts",
    "chars": 1483,
    "preview": "import { create } from \"zustand\";\nimport { ResponseOK, Track } from \"../api/types\";\nimport { airstationAPI } from \"../ap"
  },
  {
    "path": "web/studio/src/store/tracks.ts",
    "chars": 1708,
    "preview": "import { create } from \"zustand\";\nimport { ResponseOK, Track } from \"../api/types\";\nimport { airstationAPI } from \"../ap"
  },
  {
    "path": "web/studio/src/theme.ts",
    "chars": 1065,
    "preview": "import { createTheme, LoadingOverlay, Overlay } from \"@mantine/core\";\n\nexport const theme = createTheme({\n    fontFamily"
  },
  {
    "path": "web/studio/src/types/index.ts",
    "chars": 108,
    "preview": "export interface DisclosureHandler {\n    open: () => void;\n    close: () => void;\n    toggle: () => void;\n}\n"
  },
  {
    "path": "web/studio/src/utils/array.ts",
    "chars": 665,
    "preview": "export const moveArrayItem = <T>(array: T[], fromIndex: number, toIndex: number): T[] => {\n    if (fromIndex < 0 || from"
  },
  {
    "path": "web/studio/src/utils/error.ts",
    "chars": 95,
    "preview": "export const handleErr = (err: unknown) => {\n    return String(err).replace(\"Error: \", \"\");\n};\n"
  },
  {
    "path": "web/studio/src/utils/json.ts",
    "chars": 173,
    "preview": "export const safeJsonParser = <T = any>(jsonString: string): T | null => {\n    try {\n        return JSON.parse(jsonStrin"
  },
  {
    "path": "web/studio/src/utils/time.ts",
    "chars": 629,
    "preview": "export const formatTime = (seconds: number): string => {\n    const hours = Math.floor(seconds / 3600);\n    const minutes"
  },
  {
    "path": "web/studio/src/vite-env.d.ts",
    "chars": 38,
    "preview": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "web/studio/tsconfig.app.json",
    "chars": 665,
    "preview": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2020\",\n"
  },
  {
    "path": "web/studio/tsconfig.json",
    "chars": 119,
    "preview": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ]\n}\n"
  },
  {
    "path": "web/studio/tsconfig.node.json",
    "chars": 593,
    "preview": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2022\","
  },
  {
    "path": "web/studio/vite.config.ts",
    "chars": 3027,
    "preview": "import { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport { VitePWA } from \"vite-plugin-pwa\""
  }
]

About this extraction

This page contains the full source code of the cheatsnake/airstation GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 142 files (278.3 KB), approximately 77.0k tokens, and a symbol index with 340 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!