[
  {
    "path": ".dockerignore",
    "content": "docs\nMakefile\nREADME.md\nDockerfile.multiarch\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: [cheatsnake]\nbuy_me_a_coffee: yurace\n"
  },
  {
    "path": ".github/workflows/docker-image.yml",
    "content": "name: Build and Push Docker Image\n\non:\n    push:\n        tags:\n            - \"*\"\n\njobs:\n    build:\n        runs-on: ubuntu-latest\n\n        steps:\n            - name: Checkout code\n              uses: actions/checkout@v3\n\n            - name: Set up Docker Buildx\n              uses: docker/setup-buildx-action@v3\n\n            - name: Log in to Docker Hub\n              uses: docker/login-action@v3\n              with:\n                  username: ${{ secrets.DOCKER_USERNAME }}\n                  password: ${{ secrets.DOCKER_PASSWORD }}\n\n            - name: Build and push Docker image\n              uses: docker/build-push-action@v5\n              with:\n                  context: .\n                  file: Dockerfile.multiarch\n                  push: true\n                  platforms: linux/amd64,linux/arm64\n                  tags: |\n                      cheatsnake/airstation:${{ github.ref_name }}\n                      cheatsnake/airstation:latest\n"
  },
  {
    "path": ".gitignore",
    "content": ".env\nstatic\n*.db*\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:22-alpine AS player\nWORKDIR /app\nCOPY ./web/player/package*.json ./\nRUN npm install\nCOPY ./web/player .\nARG AIRSTATION_PLAYER_TITLE\nENV AIRSTATION_PLAYER_TITLE=$AIRSTATION_PLAYER_TITLE\nRUN npm run build\n\nFROM node:22-alpine AS studio\nWORKDIR /app\nCOPY ./web/studio/package*.json ./\nRUN npm install\nCOPY ./web/studio .\nRUN npm run build\n\nFROM golang:1.26-alpine AS server\nWORKDIR /app\nCOPY go.mod go.sum ./\nRUN go mod download\nCOPY cmd/ ./cmd/\nCOPY internal/ ./internal/\nRUN CGO_ENABLED=0 GOOS=linux go build -ldflags=\"-s -w\" -o /app/bin/main ./cmd/main.go\n\nFROM alpine:latest\nWORKDIR /app\nRUN apk add --no-cache ffmpeg\nCOPY --from=server /app/bin/main .\nCOPY --from=player /app/dist ./web/player/dist\nCOPY --from=studio /app/dist ./web/studio/dist\nEXPOSE 7331\nENTRYPOINT [\"./main\"]\n"
  },
  {
    "path": "Dockerfile.multiarch",
    "content": "FROM node:22-alpine AS player\nWORKDIR /app\nCOPY ./web/player/package*.json ./\nRUN npm install\nCOPY ./web/player .\nRUN npm run build\n\nFROM node:22-alpine AS studio\nWORKDIR /app\nCOPY ./web/studio/package*.json ./\nRUN npm install\nCOPY ./web/studio .\nRUN npm run build\n\nFROM golang:1.26-alpine AS server\nARG TARGETOS\nARG TARGETARCH\nWORKDIR /app\nCOPY go.mod go.sum ./\nRUN go mod download\nCOPY cmd/ ./cmd/\nCOPY internal/ ./internal/\nRUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags=\"-s -w\" -o /app/bin/main ./cmd/main.go\n\nFROM alpine:latest\nWORKDIR /app\nRUN apk add --no-cache ffmpeg\nCOPY --from=server /app/bin/main .\nCOPY --from=player /app/dist ./web/player/dist\nCOPY --from=studio /app/dist ./web/studio/dist\nEXPOSE 7331\nENTRYPOINT [\"./main\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 cheatsnake\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": ".PHONY: build\n\ntest:\n\t@go test -cover -race ./... | grep -v '^?'\nfmt:\n\tgo fmt ./...\ncount-lines:\n\t@echo \"total code lines:\" && find . -name \"*.go\" -exec cat {} \\; | wc -l\n\nbuild:\n\t@echo \"⚙️  Installing web player dependencies...\"\n\t@npm ci --prefix ./web/player\n\t\n\t@echo \"⚙️  Installing web studio dependencies...\"\n\t@npm ci --prefix ./web/studio\n\t\n\t@echo \"🛠️  Building web player...\"\n\t@npm run build --prefix ./web/player\n\t\n\t@echo \"🛠️  Building web studio...\"\n\t@npm run build --prefix ./web/studio\n\t\n\t@echo \"🛠️ Building web server...\"\n\t@go build ./cmd/main.go\n\t\n\t@echo \"✅ Build completed successfully\""
  },
  {
    "path": "README.md",
    "content": "<br>\n<p align=\"center\">\n  <a href=\"https://github.com/cheatsnake/airstation\">\n    <img src=\"./docs/images/logo.png\" alt=\"logo\" height=\"128\">\n  </a>\n</p>\n\n<h2 align=\"center\">Airstation</h2>\n<p align=\"center\">Your own online radio station</p>\n<p align=\"center\">\n🔍 <a href=\"./docs/overview.md\">Overview</a>\n&nbsp; 💻 <a href=\"https://radio.yurace.pro/\">Demo</a>\n&nbsp; ⚙️ <a href=\"./docs/installation.md\">Installation</a>\n&nbsp; 🗺️ <a href=\"./docs/roadmap.md\">Roadmap</a>\n&nbsp; 🚨 <a href=\"https://github.com/cheatsnake/airstation/issues/new\">Bug report</a>\n</p>\n<br />\n\nAirstation 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.\n\n<img src=\"./docs/images/screenshot01.png\" alt=\"Web studio screenshot\"/>\n<img src=\"./docs/images/screenshot02.png\" alt=\"Web studio mobile screenshot\"/>\n<img src=\"./docs/images/screenshot03.png\" alt=\"Web player screenshot\"/>\n\n<p></p>\n<div align=\"center\">Made for fun</div>\n<div align=\"center\"><a href=\"./LICENSE\">LICENSE</a> 2025 - Present</div"
  },
  {
    "path": "cmd/main.go",
    "content": "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/config\"\n\t\"github.com/cheatsnake/airstation/internal/http\"\n\t\"github.com/cheatsnake/airstation/internal/logger\"\n\t\"github.com/cheatsnake/airstation/internal/pkg/fs\"\n\t\"github.com/cheatsnake/airstation/internal/storage\"\n\t\"github.com/cheatsnake/airstation/internal/storage/sqlite\"\n)\n\nfunc main() {\n\tconf := config.Load()\n\n\tfs.DeleteDirIfExists(conf.TmpDir)\n\tfs.MustDir(conf.TmpDir)\n\tfs.MustDir(conf.TracksDir)\n\tfs.MustDir(conf.DBDir)\n\n\tstopSignal := make(chan os.Signal, 1)\n\tsignal.Notify(stopSignal, os.Interrupt, syscall.SIGTERM)\n\n\tlog := logger.New()\n\tstore, err := sqlite.New(path.Join(conf.DBDir, conf.DBFile), log.WithGroup(\"storage\"))\n\tif err != nil {\n\t\tlog.Error(\"Failed connect to database: \" + err.Error())\n\t\tos.Exit(1)\n\t}\n\n\thttpServer := http.NewServer(store, conf, log)\n\tgo httpServer.Run()\n\n\t<-stopSignal\n\tshutdown(log, store)\n}\n\nfunc shutdown(log *slog.Logger, store storage.Storage) {\n\tprintln()\n\tlog.Info(\"Shutting down the app...\")\n\n\terr := store.Close()\n\tif err != nil {\n\t\tlog.Error(\"Failed to close database connection: \" + err.Error())\n\t}\n\n\tlog.Info(\"App gracefully stopped\")\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  app:\n    build:\n      context: .\n      dockerfile: Dockerfile\n      args:\n        - AIRSTATION_PLAYER_TITLE=${AIRSTATION_PLAYER_TITLE}\n    ports:\n      - \"7331:7331\"\n    volumes:\n      - database:/app/storage\n      - ./static:/app/static\n    restart: unless-stopped\n    env_file:\n      - .env\n    \nvolumes:\n  database:"
  },
  {
    "path": "docs/installation.md",
    "content": "# Installation\n\nTo 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.\n\n## Docker\n\n1.  Clone Airstation repository\n\n    ```sh\n    git clone https://github.com/cheatsnake/airstation.git\n    ```\n\n    ```sh\n    cd ./airstation\n    ```\n\n2.  Setup environment variables\n\n    Next you need an `.env` file with secret keys\n\n    ```sh\n    touch .env\n    ```\n\n    Inside this file you must define 2 variables:\n\n    ```\n    AIRSTATION_SECRET_KEY=\n    AIRSTATION_JWT_SIGN=\n    ```\n\n    > `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\n\n    > Use [random string generator](https://it-tools.tech/token-generator?length=20) with a length of at least 10 characters for these variables!\n\n3.  Build a docker image and start a new container\n\n    ```sh\n    docker compose up -d\n    ```\n\nAnd finally you can see:\n\n- Control panel on [http://localhost:7331/studio/](http://localhost:7331/studio/) (extra slash matters!)\n- Radio player on [http://localhost:7331](http://localhost:7331)\n\nTo stop the container, just type:\n\n```sh\ndocker compose down\n```\n\n### Docker Compose\n\nYou 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:\n\n```yml\n# docker-compose.yml\nservices:\n  airstation:\n    image: cheatsnake/airstation:latest\n    ports:\n      - \"7331:7331\"\n    volumes:\n      - airstation-data:/app/storage\n      - ./static:/app/static\n    restart: unless-stopped\n    environment:\n      AIRSTATION_SECRET_KEY: ${AIRSTATION_SECRET_KEY:-PASTE_YOUR_OWN_KEY}\n      AIRSTATION_JWT_SIGN: ${AIRSTATION_JWT_SIGN:-PASTE_RANDOM_STRING}\n    healthcheck:\n      test: [\"CMD\", \"wget\", \"--spider\", \"-q\", \"http://localhost:7331/\"]\n      interval: 10s\n      timeout: 5s\n      retries: 3\n      start_period: 10s\n\nvolumes:\n  airstation-data:\n```\n\n> 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`\n\n## Build from source\n\n1. Follow steps 1 and 2 from the previous section\n\n2. Install dependencies\n\n```sh\nnpm ci --prefix ./web/player\n```\n\n```sh\nnpm ci --prefix ./web/studio\n```\n\n3. Build web clients\n\n```sh\nnpm run build --prefix ./web/player\n```\n\n```sh\nnpm run build --prefix ./web/studio\n```\n\n4. Build server\n\n```sh\ngo build ./cmd/main.go\n```\n\n5. Run app\n\n```sh\n./main\n```\n\n> Make sure you have [FFmpeg](https://ffmpeg.org/) installed on your system.\n\nSee the result on [http://localhost:7331](http://localhost:7331) and [http://localhost:7331/studio/](http://localhost:7331/studio/) (extra slash matters!)\n\n## Development mode\n\nTo run the application in development mode, start each part of the application using the commands below:\n\n```sh\nnpm run dev --prefix ./web/player\n```\n\n```sh\nnpm run dev --prefix ./web/studio\n```\n\n```sh\ngo run ./cmd/main.go\n```\n"
  },
  {
    "path": "docs/overview.md",
    "content": "# Overview\n\nAirstation 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.\n\nLogically, 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.\n\nThe 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.\n\n<img src=\"./images/tech-stack.png\" alt=\"Technology stack\"/>\n\n## Main features\n\n- Permanent storage of tracks in the library\n- Ability to listen to added tracks\n- Ability to delete tracks from the library\n- Search for tracks in the library\n- Sort tracks by date added, name, duration.\n- Creating a track queue\n- Changing the current track queue\n- Cyclic queue mode\n- Possibility to randomly mix the queue\n- Possibility to temporarily stop the radio station\n- Playback history\n- Listener counter\n- Playlists mechanism\n- Player page customization\n\n## Deep under the hood\n\nThe 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:\n\n<img src=\"./images/audio-pipeline.png\" alt=\"Audio pipeline\"/>\n\n- First, as the station owner, you upload the track to the server. Typically, these are `mp3` or `aac` files with varying bitrates.\n- Next, all files are transcoded into a unified codec and bitrate, then stored permanently on the server.\n- 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.\n\nTo 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:\n\n<img src=\"./images/playback-lifecycle.png\" alt=\"Playback lifecycle\"/>\n\n- 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).\n- As soon as the track enters the playback state, playback time starts counting.\n- 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).\n- Each listener periodically requests the current chunks to maintain uninterrupted playback.\n\n## Who is this app suitable for?\n\nAnyone 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).\n"
  },
  {
    "path": "docs/roadmap.md",
    "content": "# 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---\n\n## 📝 Planned Features\n\n- [ ] Tags for tracks (as a grouping mechanism)\n- [ ] Ability to send voice messages recorded through the microphone\n- [ ] Scheduling mechanism for tracks/playlists (by [hjdx2009](https://github.com/cheatsnake/airstation/issues/7#issue-3059402373))\n\n---\n\n## 🌟 Long-Term Goals\n\n- [ ] Crossfade effect between tracks (by [rursache](https://github.com/cheatsnake/airstation/issues/5#issuecomment-2873728112))\n- [ ] Integration with online music services for downloading tracks directly to the library\n- [ ] Built-in tunneling feature (to share your local server with others over the Internet)\n- [ ] Integration with other music programs to synchronize track playback (by [swishkin](https://github.com/cheatsnake/airstation/issues/8#issue-3069650457))\n---\n\n## ✅ Done\n\n- [x] Theming for player page (by [ptolemaea](https://github.com/cheatsnake/airstation/issues/21))\n- [x] Custom station info (name, description, logo, favicon, links)\n- [x] Playlists (ability to pre-create and select already created playlists for playback)\n\n---\n\n## 💡 Suggestions?\n\nHave a feature request or idea? Feel free to open an [issue](https://github.com/cheatsnake/airstation/issues).\n"
  },
  {
    "path": "go.mod",
    "content": "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.11.1\n\nrequire (\n\tgithub.com/golang-jwt/jwt/v5 v5.3.1\n\tgithub.com/joho/godotenv v1.5.1\n\tmodernc.org/sqlite v1.48.1\n)\n\nrequire (\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/ncruces/go-strftime v1.0.0 // indirect\n\tgithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect\n\tgolang.org/x/sys v0.42.0 // indirect\n\tgolang.org/x/tools v0.43.0 // indirect\n\tmodernc.org/libc v1.70.0 // indirect\n\tmodernc.org/mathutil v1.7.1 // indirect\n\tmodernc.org/memory v1.11.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=\ngithub.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=\ngithub.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=\ngithub.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=\ngithub.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=\ngithub.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=\ngithub.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=\ngithub.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=\ngithub.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=\ngithub.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=\ngolang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=\ngolang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=\ngolang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=\ngolang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=\ngolang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=\ngolang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=\nmodernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=\nmodernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=\nmodernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=\nmodernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=\nmodernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=\nmodernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=\nmodernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=\nmodernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=\nmodernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=\nmodernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=\nmodernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=\nmodernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=\nmodernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=\nmodernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=\nmodernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=\nmodernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=\nmodernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=\nmodernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=\nmodernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=\nmodernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=\nmodernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=\nmodernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=\nmodernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA=\nmodernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=\nmodernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=\nmodernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=\nmodernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=\nmodernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=\n"
  },
  {
    "path": "internal/config/config.go",
    "content": "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 = 10\n\ntype Config struct {\n\tDBDir        string\n\tDBFile       string\n\tTracksDir    string\n\tTmpDir       string\n\tPlayerDir    string\n\tStudioDir    string\n\tHTTPPort     string\n\tJWTSign      string\n\tSecretKey    string\n\tSecureCookie bool\n}\n\nfunc Load() *Config {\n\t_ = godotenv.Load() // For development\n\n\treturn &Config{\n\t\tDBDir:        getEnv(\"AIRSTATION_DB_DIR\", filepath.Join(\"storage\")),\n\t\tDBFile:       getEnv(\"AIRSTATION_DB_FILE\", \"storage.db\"),\n\t\tTracksDir:    getEnv(\"AIRSTATION_TRACKS_DIR\", filepath.Join(\"static\", \"tracks\")),\n\t\tTmpDir:       getEnv(\"AIRSTATION_TMP_DIR\", filepath.Join(\"static\", \"tmp\")),\n\t\tPlayerDir:    getEnv(\"AIRSTATION_PLAYER_DIR\", filepath.Join(\"web\", \"player\", \"dist\")),\n\t\tStudioDir:    getEnv(\"AIRSTATION_STUDIO_DIR\", filepath.Join(\"web\", \"studio\", \"dist\")),\n\t\tHTTPPort:     getEnv(\"AIRSTATION_HTTP_PORT\", \"7331\"),\n\t\tJWTSign:      getSecret(\"AIRSTATION_JWT_SIGN\"),\n\t\tSecretKey:    getSecret(\"AIRSTATION_SECRET_KEY\"),\n\t\tSecureCookie: getEnvBool(\"AIRSTATION_SECURE_COOKIE\", false),\n\t}\n}\n\nfunc getEnv(key, defaultValue string) string {\n\tif value := os.Getenv(key); value != \"\" {\n\t\treturn value\n\t}\n\treturn defaultValue\n}\n\nfunc getEnvBool(key string, defaultValue bool) bool {\n\tval := os.Getenv(key)\n\tif val == \"\" {\n\t\treturn defaultValue\n\t}\n\n\tval = strings.ToLower(val)\n\treturn val == \"1\" || val == \"true\" || val == \"yes\" || val == \"on\"\n}\n\nfunc getSecret(key string) string {\n\tsecretKey := os.Getenv(key)\n\n\tif secretKey == \"\" {\n\t\tlog.Fatal(key + \" environment variable is not set\")\n\t}\n\n\tif len(secretKey) < minSecretLength {\n\t\tlog.Fatal(key + \" is too short\")\n\t}\n\n\treturn secretKey\n}\n"
  },
  {
    "path": "internal/http/consts.go",
    "content": "package http\n\nconst (\n\teventPlay           = \"play\"\n\teventPause          = \"pause\"\n\teventNewTrack       = \"new_track\"\n\teventLoadedTracks   = \"loaded_tracks\"\n\teventCountListeners = \"count_listeners\"\n\teventChangeTheme    = \"change_theme\"\n)\n"
  },
  {
    "path": "internal/http/handlers.go",
    "content": "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\"slices\"\n\t\"time\"\n\n\t\"github.com/cheatsnake/airstation/internal/pkg/sse\"\n\t\"github.com/cheatsnake/airstation/internal/station\"\n\t\"github.com/cheatsnake/airstation/internal/track\"\n\t\"github.com/golang-jwt/jwt/v5\"\n)\n\nconst multipartChunkLimit = 64 * 1024 * 1024 // 64 MB\nconst copyBufferSize = 256 * 1024            // 256 KB\n\nfunc (s *Server) handleHLSPlaylist(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"audio/mpegurl\")\n\n\tif s.playbackState.IsPlaying {\n\t\tfmt.Fprint(w, s.playbackState.PlaylistStr)\n\t}\n}\n\nfunc (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\tw.Header().Set(\"Connection\", \"keep-alive\")\n\n\teventChan := make(chan *sse.Event)\n\ts.eventsEmitter.Subscribe(eventChan)\n\n\tcloseNotify := r.Context().Done()\n\tgo func() {\n\t\t<-closeNotify\n\t\ts.eventsEmitter.Unsubscribe(eventChan)\n\t\tclose(eventChan)\n\t}()\n\n\t// Send current number of listeners immediately\n\tcountEvent := s.countListeners()\n\tfmt.Fprint(w, countEvent.Stringify())\n\tw.(http.Flusher).Flush()\n\n\tfor {\n\t\tevent, isOpen := <-eventChan\n\t\tif !isOpen {\n\t\t\tbreak\n\t\t}\n\n\t\tfmt.Fprint(w, event.Stringify())\n\t\tw.(http.Flusher).Flush()\n\t}\n}\n\nfunc (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {\n\tbody, err := parseJSONBody[struct {\n\t\tSecret string `json:\"secret\"`\n\t}](r)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Parsing request body failed.\")\n\t\treturn\n\t}\n\n\tisValidSecret := subtle.ConstantTimeCompare([]byte(body.Secret), []byte(s.config.SecretKey)) == 1\n\tif !isValidSecret {\n\t\tjsonForbidden(w, \"Wrong secret, access denied.\")\n\t\treturn\n\t}\n\n\texpirationTime := time.Now().Add(7 * 24 * time.Hour)\n\tclaims := jwt.MapClaims{\n\t\t\"iss\": \"airstation\",\n\t\t\"exp\": expirationTime.Unix(),\n\t}\n\n\ttoken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)\n\ttokenString, err := token.SignedString([]byte(s.config.JWTSign))\n\tif err != nil {\n\t\ts.logger.Debug(\"Failed to generate token: \" + err.Error())\n\t\tjsonInternalError(w, \"Failed to generate token.\")\n\t\treturn\n\t}\n\n\thttp.SetCookie(w, &http.Cookie{\n\t\tName:     \"jwt\",\n\t\tValue:    tokenString,\n\t\tExpires:  expirationTime,\n\t\tPath:     \"/\",\n\t\tHttpOnly: true,\n\t\tSecure:   s.config.SecureCookie,\n\t\tSameSite: http.SameSiteStrictMode,\n\t})\n\n\ts.logger.Info(fmt.Sprintf(\"New login succeed from %s with secureCookie=%v\", r.Host, s.config.SecureCookie))\n\n\tjsonOK(w, \"Login succeed.\")\n}\n\nfunc (s *Server) handleTracks(w http.ResponseWriter, r *http.Request) {\n\tqueries := r.URL.Query()\n\tpage := parseIntQuery(queries, \"page\", 1)\n\tlimit := parseIntQuery(queries, \"limit\", 20)\n\tsearch := queries.Get(\"search\")\n\tsortBy := queries.Get(\"sort_by\")\n\tsortOrder := queries.Get(\"sort_order\")\n\n\tresult, err := s.trackService.Tracks(page, limit, search, sortBy, sortOrder)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Tracks retrieving failed: \"+err.Error())\n\t\treturn\n\t}\n\n\tjsonResponse(w, result)\n}\n\nfunc (s *Server) handleTracksUpload(w http.ResponseWriter, r *http.Request) {\n\terr := r.ParseMultipartForm(multipartChunkLimit)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Failed to parse multipart form: \"+err.Error())\n\t\treturn\n\t}\n\n\tfiles := r.MultipartForm.File[\"tracks\"]\n\tif len(files) == 0 {\n\t\tjsonBadRequest(w, \"No files uploaded\")\n\t\treturn\n\t}\n\n\tfor _, fileHeader := range files {\n\t\t_, err := s.saveFile(fileHeader)\n\t\tif err != nil {\n\t\t\tjsonBadRequest(w, err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tgo s.trackService.LoadTracksFromDisk(s.config.TracksDir)\n\n\tmsg := fmt.Sprintf(\"%d track(s) uploaded successfully. They will be available in your library once processed.\", len(files))\n\tjsonOK(w, msg)\n}\n\nfunc (s *Server) handleDeleteTracks(w http.ResponseWriter, r *http.Request) {\n\tbody, err := parseJSONBody[track.BodyWithIDs](r)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Parsing request body failed: \"+err.Error())\n\t\treturn\n\t}\n\n\terr = s.trackService.DeleteTracks(body.IDs)\n\tif err != nil {\n\t\ts.logger.Debug(err.Error())\n\t\tjsonBadRequest(w, \"Deleting tracks failed\")\n\t\treturn\n\t}\n\n\tjsonOK(w, \"Tracks deleted\")\n}\n\nfunc (s *Server) handleQueue(w http.ResponseWriter, _ *http.Request) {\n\tqueue, err := s.queueService.Queue()\n\tif err != nil {\n\t\ts.logger.Debug(err.Error())\n\t\tjsonBadRequest(w, \"Queue retrieving failed: \"+err.Error())\n\t\treturn\n\t}\n\n\tjsonResponse(w, queue)\n}\n\nfunc (s *Server) handleAddToQueue(w http.ResponseWriter, r *http.Request) {\n\tbody, err := parseJSONBody[track.BodyWithIDs](r)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Parsing request body failed: \"+err.Error())\n\t\treturn\n\t}\n\n\ttracks, err := s.trackService.FindTracks(body.IDs)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Adding tracks to queue failed: \"+err.Error())\n\t\treturn\n\t}\n\n\terr = s.queueService.AddToQueue(tracks)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Adding tracks to queue failed: \"+err.Error())\n\t\treturn\n\t}\n\n\terr = s.playbackState.Reload()\n\tif err != nil {\n\t\ts.logger.Debug(\"Playback reload failed: \" + err.Error())\n\t}\n\n\tjsonOK(w, \"Tracks added\")\n}\n\nfunc (s *Server) handleReorderQueue(w http.ResponseWriter, r *http.Request) {\n\tbody, err := parseJSONBody[track.BodyWithIDs](r)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Parsing request body failed: \"+err.Error())\n\t\treturn\n\t}\n\n\terr = s.queueService.ReorderQueue(body.IDs)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Queue reordering failed: \"+err.Error())\n\t\treturn\n\t}\n\n\terr = s.playbackState.Reload()\n\tif err != nil {\n\t\ts.logger.Debug(\"Playback reload failed: \" + err.Error())\n\t}\n\n\tjsonOK(w, \"Queue reordered\")\n}\n\nfunc (s *Server) handleRemoveFromQueue(w http.ResponseWriter, r *http.Request) {\n\tbody, err := parseJSONBody[track.BodyWithIDs](r)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Parsing request body failed: \"+err.Error())\n\t\treturn\n\t}\n\n\tif s.playbackState.CurrentTrack != nil {\n\t\thasCurrent := slices.Contains(body.IDs, s.playbackState.CurrentTrack.ID)\n\t\tif hasCurrent {\n\t\t\ts.playbackState.Pause()\n\t\t}\n\t}\n\n\terr = s.queueService.RemoveFromQueue(body.IDs)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Removing from queue failed: \"+err.Error())\n\t\treturn\n\t}\n\n\terr = s.playbackState.Reload()\n\tif err != nil {\n\t\ts.logger.Debug(\"Playback reload failed: \" + err.Error())\n\t}\n\n\tjsonOK(w, \"Tracks removed\")\n}\n\nfunc (s *Server) handlePlaybackState(w http.ResponseWriter, _ *http.Request) {\n\tjsonResponse(w, s.playbackState)\n}\n\nfunc (s *Server) handlePausePlayback(w http.ResponseWriter, _ *http.Request) {\n\ts.playbackState.Pause()\n\tjsonResponse(w, s.playbackState)\n}\n\nfunc (s *Server) handlePlayPlayback(w http.ResponseWriter, _ *http.Request) {\n\terr := s.playbackState.Play()\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Playback failed to start: \"+err.Error())\n\t\treturn\n\t}\n\n\tjsonResponse(w, s.playbackState)\n}\n\nfunc (s *Server) handlePlaybackHistory(w http.ResponseWriter, r *http.Request) {\n\tqueries := r.URL.Query()\n\tlimit := parseIntQuery(queries, \"limit\", 50)\n\thistory, err := s.playbackService.RecentPlaybackHistory(limit)\n\tif err != nil {\n\t\ts.logger.Debug(err.Error())\n\t\tjsonBadRequest(w, \"Playback history retrieving failed\")\n\t\treturn\n\t}\n\n\tjsonResponse(w, history)\n}\n\nfunc (s *Server) handleAddPlaylist(w http.ResponseWriter, r *http.Request) {\n\tbody, err := parseJSONBody[struct {\n\t\tName        string   `json:\"name\"`\n\t\tDescription string   `json:\"description\"`\n\t\tTrackIDs    []string `json:\"trackIDs\"`\n\t}](r)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Parsing request body failed: \"+err.Error())\n\t\treturn\n\t}\n\n\tpl, err := s.playlistService.AddPlaylist(body.Name, body.Description, body.TrackIDs)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Playlist creation failed: \"+err.Error())\n\t\treturn\n\t}\n\n\tjsonResponse(w, pl)\n}\n\nfunc (s *Server) handlePlaylists(w http.ResponseWriter, r *http.Request) {\n\tpls, err := s.playlistService.Playlists()\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Playlists retrieving failed: \"+err.Error())\n\t}\n\n\tjsonResponse(w, pls)\n}\n\nfunc (s *Server) handlePlaylist(w http.ResponseWriter, r *http.Request) {\n\tid := r.PathValue(\"id\")\n\n\tpl, err := s.playlistService.Playlist(id)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Playlist retrieving failed: \"+err.Error())\n\t}\n\n\tjsonResponse(w, pl)\n}\n\nfunc (s *Server) handleEditPlaylist(w http.ResponseWriter, r *http.Request) {\n\tid := r.PathValue(\"id\")\n\n\tbody, err := parseJSONBody[struct {\n\t\tName        string   `json:\"name\"`\n\t\tDescription string   `json:\"description\"`\n\t\tTrackIDs    []string `json:\"trackIDs\"`\n\t}](r)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Parsing request body failed: \"+err.Error())\n\t\treturn\n\t}\n\n\terr = s.playlistService.EditPlaylist(id, body.Name, body.Description, body.TrackIDs)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Playlist creation failed: \"+err.Error())\n\t\treturn\n\t}\n\n\tjsonOK(w, \"Playlist updated\")\n}\n\nfunc (s *Server) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) {\n\tid := r.PathValue(\"id\")\n\n\terr := s.playlistService.DeletePlaylist(id)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Playlist deletion failed: \"+err.Error())\n\t}\n\n\tjsonOK(w, \"Playlist deleted\")\n}\n\nfunc (s *Server) handleStaticDir(prefix string, path string) http.Handler {\n\treturn http.StripPrefix(prefix, http.FileServer(http.Dir(path)))\n}\n\nfunc (s *Server) handleStaticDirWithoutCache(prefix string, path string) http.Handler {\n\tfileHandler := http.StripPrefix(prefix, http.FileServer(http.Dir(path)))\n\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\t\tfileHandler.ServeHTTP(w, r)\n\t})\n}\n\nfunc (s *Server) handleStationInfo(w http.ResponseWriter, _ *http.Request) {\n\tinfo, err := s.stationService.Info()\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Failed to get station info: \"+err.Error())\n\t\treturn\n\t}\n\n\tjsonResponse(w, info)\n}\n\nfunc (s *Server) handleEditStationInfo(w http.ResponseWriter, r *http.Request) {\n\tbody, err := parseJSONBody[station.Info](r)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Parsing request body failed: \"+err.Error())\n\t\treturn\n\t}\n\n\tinfo, err := s.stationService.EditInfo(body)\n\tif err != nil {\n\t\tjsonBadRequest(w, \"Station info editing failed: \"+err.Error())\n\t\treturn\n\t}\n\n\ts.eventsEmitter.RegisterEvent(eventChangeTheme, \" \")\n\n\tjsonResponse(w, info)\n}\n\nfunc (s *Server) saveFile(fileHeader *multipart.FileHeader) (string, error) {\n\tfile, err := fileHeader.Open()\n\tif err != nil {\n\t\tmsg := \"Failed to open file: \" + err.Error()\n\t\ts.logger.Debug(msg)\n\t\treturn \"\", errors.New(msg)\n\t}\n\n\tfileName := filepath.Base(fileHeader.Filename)\n\tfilePath := filepath.Join(s.config.TracksDir, fileName)\n\tdst, err := os.Create(filePath)\n\tif err != nil {\n\t\tmsg := \"Failed to create file on disk: \" + err.Error()\n\t\ts.logger.Debug(msg)\n\t\treturn \"\", errors.New(msg)\n\t}\n\n\t_, err = io.CopyBuffer(dst, file, make([]byte, copyBufferSize))\n\tif err != nil {\n\t\tmsg := \"Failed to save file: \" + err.Error()\n\t\ts.logger.Debug(msg)\n\t\treturn \"\", errors.New(msg)\n\t}\n\n\tfile.Close()\n\tdst.Close()\n\n\treturn filePath, nil\n}\n"
  },
  {
    "path": "internal/http/messages.go",
    "content": "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 jsonResponse(w http.ResponseWriter, data interface{}) {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\n\tif err := json.NewEncoder(w).Encode(data); err != nil {\n\t\thttp.Error(w, \"JSON encoding failed\", http.StatusInternalServerError)\n\t}\n}\n\nfunc jsonMessage(w http.ResponseWriter, code int, body string) {\n\tmsg := Message{Message: body}\n\n\tw.WriteHeader(code)\n\tjsonResponse(w, msg)\n}\n\nfunc jsonOK(w http.ResponseWriter, body string) {\n\tjsonMessage(w, http.StatusOK, body)\n}\n\nfunc jsonBadRequest(w http.ResponseWriter, body string) {\n\tjsonMessage(w, http.StatusBadRequest, body)\n}\n\nfunc jsonUnauthorized(w http.ResponseWriter, body string) {\n\tjsonMessage(w, http.StatusUnauthorized, body)\n}\n\nfunc jsonForbidden(w http.ResponseWriter, body string) {\n\tjsonMessage(w, http.StatusForbidden, body)\n}\n\nfunc jsonInternalError(w http.ResponseWriter, body string) {\n\tjsonMessage(w, http.StatusInternalServerError, body)\n}\n"
  },
  {
    "path": "internal/http/middlewares.go",
    "content": "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) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tcookie, err := r.Cookie(\"jwt\")\n\t\tif err != nil {\n\t\t\tjsonUnauthorized(w, \"Unauthorized, access denied.\")\n\t\t\treturn\n\t\t}\n\n\t\ttoken, err := jwt.Parse(cookie.Value, func(token *jwt.Token) (any, error) {\n\t\t\tif _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"unexpected signing method: %v\", token.Header[\"alg\"])\n\n\t\t\t}\n\t\t\treturn []byte(s.config.JWTSign), nil\n\t\t})\n\n\t\tif err != nil || !token.Valid {\n\t\t\tjsonUnauthorized(w, \"Invalid token.\")\n\t\t\treturn\n\t\t}\n\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n"
  },
  {
    "path": "internal/http/parser.go",
    "content": "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.Values, key string, defaultValue int) int {\n\tqueryValue := queries.Get(key)\n\tparsed, err := strconv.Atoi(queryValue)\n\tif err != nil {\n\t\tparsed = defaultValue\n\t}\n\n\treturn parsed\n}\n\nfunc parseJSONBody[T any](r *http.Request) (*T, error) {\n\trawBytes, err := io.ReadAll(r.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer r.Body.Close()\n\n\tif len(rawBytes) == 0 {\n\t\treturn nil, fmt.Errorf(\"request body is empty\")\n\t}\n\n\tvar jsonData T\n\tif err := json.Unmarshal(rawBytes, &jsonData); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &jsonData, nil\n}\n"
  },
  {
    "path": "internal/http/server.go",
    "content": "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/config\"\n\t\"github.com/cheatsnake/airstation/internal/pkg/ffmpeg\"\n\t\"github.com/cheatsnake/airstation/internal/pkg/hls\"\n\t\"github.com/cheatsnake/airstation/internal/pkg/sse\"\n\t\"github.com/cheatsnake/airstation/internal/playback\"\n\t\"github.com/cheatsnake/airstation/internal/playlist\"\n\t\"github.com/cheatsnake/airstation/internal/queue\"\n\t\"github.com/cheatsnake/airstation/internal/station\"\n\t\"github.com/cheatsnake/airstation/internal/storage\"\n\t\"github.com/cheatsnake/airstation/internal/track\"\n\t\"github.com/rs/cors\"\n)\n\ntype Server struct {\n\tplaybackState   *playback.State\n\teventsEmitter   *sse.Emitter\n\ttrackService    *track.Service\n\tqueueService    *queue.Service\n\tplaybackService *playback.Service\n\tplaylistService *playlist.Service\n\tstationService  *station.Service\n\tconfig          *config.Config\n\tlogger          *slog.Logger\n\trouter          *http.ServeMux\n}\n\nfunc NewServer(store storage.Storage, conf *config.Config, logger *slog.Logger) *Server {\n\tffmpegCLI := ffmpeg.NewCLI()\n\tts := track.NewService(store, ffmpegCLI, logger.WithGroup(\"trackservice\"))\n\tqs := queue.NewService(store)\n\tps := playback.NewService(store)\n\tpls := playlist.NewService(store)\n\tss := station.NewService(store)\n\tstate := playback.NewState(ts, qs, ps, conf.TmpDir, logger.WithGroup(\"playback\"))\n\n\treturn &Server{\n\t\tplaybackState:   state,\n\t\teventsEmitter:   sse.NewEmitter(),\n\t\ttrackService:    ts,\n\t\tqueueService:    qs,\n\t\tplaybackService: ps,\n\t\tplaylistService: pls,\n\t\tstationService:  ss,\n\t\tconfig:          conf,\n\t\tlogger:          logger.WithGroup(\"http\"),\n\t\trouter:          http.NewServeMux(),\n\t}\n}\n\nfunc (s *Server) Run() {\n\ts.registerMP2TMimeType()\n\n\t// Public handlers\n\ts.router.HandleFunc(\"GET /stream\", s.handleHLSPlaylist)\n\ts.router.HandleFunc(\"GET /api/v1/events\", s.handleEvents)\n\ts.router.HandleFunc(\"GET /api/v1/station/info\", s.handleStationInfo)\n\ts.router.HandleFunc(\"POST /api/v1/login\", s.handleLogin)\n\ts.router.Handle(\"GET /static/tmp/\", s.handleStaticDirWithoutCache(\"/static/tmp\", s.config.TmpDir))\n\ts.router.Handle(\"GET /api/v1/playback\", http.HandlerFunc(s.handlePlaybackState))\n\ts.router.Handle(\"GET /api/v1/playback/history\", http.HandlerFunc(s.handlePlaybackHistory))\n\n\t// Protected handlers\n\ts.router.Handle(\"POST /api/v1/tracks\", s.jwtAuth(http.HandlerFunc(s.handleTracksUpload)))\n\ts.router.Handle(\"GET /api/v1/tracks\", s.jwtAuth(http.HandlerFunc(s.handleTracks)))\n\ts.router.Handle(\"DELETE /api/v1/tracks\", s.jwtAuth(http.HandlerFunc(s.handleDeleteTracks)))\n\ts.router.Handle(\"GET /api/v1/queue\", s.jwtAuth(http.HandlerFunc(s.handleQueue)))\n\ts.router.Handle(\"POST /api/v1/queue\", s.jwtAuth(http.HandlerFunc(s.handleAddToQueue)))\n\ts.router.Handle(\"PUT /api/v1/queue\", s.jwtAuth(http.HandlerFunc(s.handleReorderQueue)))\n\ts.router.Handle(\"DELETE /api/v1/queue\", s.jwtAuth(http.HandlerFunc(s.handleRemoveFromQueue)))\n\ts.router.Handle(\"POST /api/v1/playback/pause\", s.jwtAuth(http.HandlerFunc(s.handlePausePlayback)))\n\ts.router.Handle(\"POST /api/v1/playback/play\", s.jwtAuth(http.HandlerFunc(s.handlePlayPlayback)))\n\ts.router.Handle(\"POST /api/v1/playlist\", s.jwtAuth(http.HandlerFunc(s.handleAddPlaylist)))\n\ts.router.Handle(\"GET /api/v1/playlists\", s.jwtAuth(http.HandlerFunc(s.handlePlaylists)))\n\ts.router.Handle(\"GET /api/v1/playlist/{id}/\", s.jwtAuth(http.HandlerFunc(s.handlePlaylist)))\n\ts.router.Handle(\"PUT /api/v1/playlist/{id}/\", s.jwtAuth(http.HandlerFunc(s.handleEditPlaylist)))\n\ts.router.Handle(\"DELETE /api/v1/playlist/{id}/\", s.jwtAuth(http.HandlerFunc(s.handleDeletePlaylist)))\n\ts.router.Handle(\"GET /static/tracks/\", s.jwtAuth(s.handleStaticDir(\"/static/tracks\", s.config.TracksDir)))\n\ts.router.Handle(\"PUT /api/v1/station/info\", s.jwtAuth(http.HandlerFunc(s.handleEditStationInfo)))\n\n\ts.router.Handle(\"GET /studio/\", s.handleStaticDir(\"/studio/\", s.config.StudioDir))\n\ts.router.Handle(\"GET /\", s.handleStaticDir(\"/\", s.config.PlayerDir))\n\n\ts.listenEvents()\n\n\terr := s.playbackState.Play()\n\tif err != nil {\n\t\ts.logger.Warn(\"Auto start playing failed: \" + err.Error())\n\t}\n\n\tgo s.playbackState.Run()\n\tgo s.trackService.LoadTracksFromDisk(s.config.TracksDir)\n\ts.playbackService.DeleteOldPlaybackHistory()\n\n\ts.logger.Info(\"Server starts on http://localhost:\" + s.config.HTTPPort)\n\terr = http.ListenAndServe(\":\"+s.config.HTTPPort, cors.Default().Handler(s.router))\n\tif err != nil {\n\t\ts.logger.Error(\"Listen and serve failed\", slog.String(\"info\", err.Error()))\n\t}\n}\n\nfunc (s *Server) registerMP2TMimeType() {\n\terr := mime.AddExtensionType(hls.SegmentExtension, \"video/mp2t\")\n\tif err != nil {\n\t\ts.logger.Error(\"MP2T mime type registration failed\", slog.String(\"info\", err.Error()))\n\t}\n}\n\nfunc (s *Server) countListeners() *sse.Event {\n\tcount := s.eventsEmitter.CountSubscribers()\n\treturn sse.NewEvent(eventCountListeners, strconv.Itoa(count))\n}\n\nfunc (s *Server) listenEvents() {\n\tcountConnectionTicker := time.Tick(5 * time.Second)\n\n\t// TODO: add context for gracefull shutdown\n\n\tgo func() {\n\t\tfor range countConnectionTicker {\n\t\t\tevent := s.countListeners()\n\t\t\ts.eventsEmitter.RegisterEvent(event.Name, event.Data)\n\t\t}\n\t}()\n\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-s.playbackState.PlayNotify:\n\t\t\t\ts.eventsEmitter.RegisterEvent(eventPlay, s.playbackState.CurrentTrack.Name)\n\t\t\tcase <-s.playbackState.PauseNotify:\n\t\t\t\ts.eventsEmitter.RegisterEvent(eventPause, \" \")\n\t\t\tcase trackName := <-s.playbackState.NewTrackNotify:\n\t\t\t\ts.eventsEmitter.RegisterEvent(eventNewTrack, trackName)\n\t\t\tcase loadedTracks := <-s.trackService.LoadedTracksNotify:\n\t\t\t\ts.eventsEmitter.RegisterEvent(eventLoadedTracks, strconv.Itoa(loadedTracks))\n\t\t\t}\n\t\t}\n\t}()\n}\n"
  },
  {
    "path": "internal/logger/logger.go",
    "content": "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 struct {\n\tTime    string         `json:\"time\"`\n\tLevel   string         `json:\"level\"`\n\tGroup   string         `json:\"group,omitempty\"`\n\tMessage string         `json:\"message\"`\n\tDetails map[string]any `json:\"details,omitempty\"`\n}\n\ntype customJSONHandler struct {\n\toutput io.Writer\n\topts   *slog.HandlerOptions\n\tgroup  string\n\tattrs  []slog.Attr\n}\n\nfunc (h *customJSONHandler) Enabled(ctx context.Context, level slog.Level) bool {\n\tif h.opts != nil && h.opts.Level != nil {\n\t\treturn level >= h.opts.Level.Level()\n\t}\n\treturn level >= slog.LevelInfo\n}\n\nfunc (h *customJSONHandler) Handle(ctx context.Context, r slog.Record) error {\n\tdetails := make(map[string]any)\n\n\tfor _, attr := range h.attrs {\n\t\tdetails[attr.Key] = attr.Value.Any()\n\t}\n\n\tr.Attrs(func(attr slog.Attr) bool {\n\t\tdetails[attr.Key] = attr.Value.Any()\n\t\treturn true\n\t})\n\n\tentry := logEntry{\n\t\tTime:    r.Time.Format(time.DateTime),\n\t\tLevel:   r.Level.String(),\n\t\tMessage: r.Message,\n\t\tGroup:   h.group,\n\t}\n\n\tif len(details) > 0 {\n\t\tentry.Details = details\n\t}\n\n\tvar buf bytes.Buffer\n\tbuf.WriteString(\"{\")\n\tfmt.Fprintf(&buf, `\"time\":\"%s\"`, entry.Time)\n\tfmt.Fprintf(&buf, `,\"level\":\"%s\"`, entry.Level)\n\tif entry.Group != \"\" {\n\t\tfmt.Fprintf(&buf, `,\"group\":\"%s\"`, entry.Group)\n\t}\n\tfmt.Fprintf(&buf, `,\"message\":\"%s\"`, jsonEscape(entry.Message))\n\n\tif len(details) > 0 {\n\t\tbuf.WriteString(`,\"details\":`)\n\t\tdetailsJSON, err := json.Marshal(details)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tbuf.Write(detailsJSON)\n\t}\n\n\tbuf.WriteString(\"}\\n\")\n\n\t_, err := h.output.Write(buf.Bytes())\n\treturn err\n}\n\nfunc jsonEscape(s string) string {\n\tb, _ := json.Marshal(s)\n\treturn string(b[1 : len(b)-1])\n}\n\nfunc (h *customJSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler {\n\tnewAttrs := make([]slog.Attr, len(h.attrs)+len(attrs))\n\tcopy(newAttrs, h.attrs)\n\tcopy(newAttrs[len(h.attrs):], attrs)\n\n\treturn &customJSONHandler{\n\t\toutput: h.output,\n\t\topts:   h.opts,\n\t\tgroup:  h.group,\n\t\tattrs:  newAttrs,\n\t}\n}\n\nfunc (h *customJSONHandler) WithGroup(name string) slog.Handler {\n\treturn &customJSONHandler{\n\t\toutput: h.output,\n\t\topts:   h.opts,\n\t\tgroup:  name,\n\t\tattrs:  h.attrs,\n\t}\n}\n\nfunc newCustomJSONHandler(w io.Writer, opts *slog.HandlerOptions) *customJSONHandler {\n\treturn &customJSONHandler{\n\t\toutput: w,\n\t\topts:   opts,\n\t\tattrs:  []slog.Attr{},\n\t}\n}\n\nfunc New() *slog.Logger {\n\thandler := newCustomJSONHandler(os.Stdout, &slog.HandlerOptions{\n\t\tLevel: slog.LevelDebug,\n\t})\n\n\treturn slog.New(handler)\n}\n"
  },
  {
    "path": "internal/pkg/ffmpeg/const.go",
    "content": "package ffmpeg\n\n// Constants defining the executable names.\nconst (\n\tffmpegBin  = \"ffmpeg\"\n\tffprobeBin = \"ffprobe\"\n)\n"
  },
  {
    "path": "internal/pkg/ffmpeg/ffmpeg.go",
    "content": "// Package ffmpeg provides a CLI wrapper for executing FFmpeg and FFprobe commands,\n// enabling audio processing functionalities.\npackage ffmpeg\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strconv\"\n\n\t\"github.com/cheatsnake/airstation/internal/pkg/fs\"\n\t\"github.com/cheatsnake/airstation/internal/pkg/ulid\"\n)\n\n// CLI represents a command-line interface for interacting with FFmpeg and FFprobe.\ntype CLI struct{}\n\n// NewCLI creates and returns a new instance of CLI.\nfunc NewCLI() *CLI {\n\treturn &CLI{}\n}\n\n// MakeHLSPlaylist converts an audio track into an HLS (HTTP Live Streaming) playlist with segmented files.\n// It generates a playlist (.m3u8) and segment files (.ts) in the specified output directory.\n//\n// Parameters:\n//   - trackPath: The path to the source audio file to be converted into HLS format.\n//   - outDir: The directory where the HLS playlist and segments will be stored.\n//   - segName: The base name for the segment files, which will be suffixed with an index.\n//   - segDuration: The duration (in seconds) of each segment.\n//\n// Returns:\n//   - An error if the input file does not exist, or if the HLS generation process fails.\nfunc (cli *CLI) MakeHLSPlaylist(trackPath, outDir, segName string, segDuration int) error {\n\tif err := fs.FileExists(trackPath); err != nil {\n\t\treturn err\n\t}\n\n\thlsTime := strconv.Itoa(segDuration)\n\thlsSegName := fmt.Sprintf(\"%s/%s\", outDir, segName) + \"%d.ts\"\n\thlsPlName := fmt.Sprintf(\"%s/%s\", outDir, segName) + \".m3u8\"\n\n\tcmd := exec.Command(\n\t\tffmpegBin,\n\t\t\"-i\", trackPath,\n\t\t\"-codec:\", \"copy\",\n\t\t\"-start_number\", \"0\",\n\t\t\"-hls_time\", hlsTime,\n\t\t\"-hls_playlist_type\", \"event\",\n\t\t\"-hls_segment_filename\", hlsSegName,\n\t\thlsPlName,\n\t)\n\n\tvar errBuf bytes.Buffer\n\tcmd.Stderr = &errBuf\n\n\terr := cmd.Run()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"hls playlist generation failed: %v\\n%s\", err, errBuf.String())\n\t}\n\n\treturn nil\n}\n\n// AudioMetadata extracts and returns metadata information from the specified audio file.\n// It uses ffprobe to retrieve details such as duration, bit rate, codec name, sample rate, and channel count.\n//\n// Parameters:\n//   - filePath: The path to the audio file whose metadata is to be retrieved.\n//\n// Returns:\n//   - AudioMetadata: A struct containing the extracted metadata (duration, bit rate, codec, sample rate, and channels).\n//   - An error if the file does not exist, ffprobe execution fails, or metadata parsing encounters an issue.\nfunc (cli *CLI) AudioMetadata(filePath string) (AudioMetadata, error) {\n\tmetadata := AudioMetadata{}\n\n\tif err := fs.FileExists(filePath); err != nil {\n\t\treturn metadata, err\n\t}\n\n\tcmd := exec.Command(\n\t\tffprobeBin,\n\t\t\"-i\", filePath,\n\t\t\"-v\", \"error\",\n\t\t\"-show_entries\", \"format=duration,bit_rate:stream=codec_name,sample_rate,channels:format_tags=title:stream_tags=title\",\n\t\t\"-of\", \"json\",\n\t)\n\n\tvar outBuf bytes.Buffer\n\tcmd.Stdout = &outBuf\n\tcmd.Stderr = &outBuf\n\n\terr := cmd.Run()\n\tif err != nil {\n\t\treturn metadata, fmt.Errorf(\"metadata retrieve failed: %v\\n%s\", err, outBuf.String())\n\t}\n\n\tvar rawMetadata rawAudioMetadata\n\n\tif err = json.Unmarshal(outBuf.Bytes(), &rawMetadata); err != nil {\n\t\treturn metadata, fmt.Errorf(\"parsing metadata retrieve failed: %v\", err)\n\t}\n\n\tname := rawMetadata.Format.Tags.Title\n\tduration, err := strconv.ParseFloat(rawMetadata.Format.Duration, 64)\n\tif err != nil {\n\t\treturn metadata, fmt.Errorf(\"parsing metadata duration failed: %v\", err)\n\t}\n\n\tbitRate, err := strconv.Atoi(rawMetadata.Format.BitRate)\n\tif err != nil {\n\t\treturn metadata, fmt.Errorf(\"parsing metadata bitrate failed: %v\", err)\n\t}\n\n\tif len(rawMetadata.Streams) == 0 {\n\t\treturn metadata, fmt.Errorf(\"couldn't extract all metadata\")\n\t}\n\n\tchannels := rawMetadata.Streams[0].Channels\n\tcodecName := rawMetadata.Streams[0].CodecName\n\tsampleRate, err := strconv.Atoi(rawMetadata.Streams[0].SampleRate)\n\tif err != nil {\n\t\treturn metadata, fmt.Errorf(\"parsing metadata sample rate failed: %v\", err)\n\t}\n\n\tmetadata.Name = name\n\tmetadata.Duration = duration\n\tmetadata.BitRate = int(bitRate / 1000)\n\tmetadata.ChannelCount = channels\n\tmetadata.CodecName = codecName\n\tmetadata.SampleRate = sampleRate\n\n\treturn metadata, nil\n}\n\n// PadAudio appends a period of silence to the given audio file, extending its duration by padDuration seconds.\n// It generates a silence file based on the provided audio metadata and concatenates it with the original file.\n//\n// Parameters:\n//   - filePath: The path to the audio file to be padded.\n//   - padDuration: The duration of silence (in seconds) to be added at the end of the audio.\n//   - meta: The metadata of the original audio file, including codec, bit rate, sample rate, and channel count.\n//\n// Returns:\n//   - An error if the file does not exist, silence generation fails, padding fails, or file operations encounter an issue.\nfunc (cli *CLI) PadAudio(filePath string, padDuration float64, meta AudioMetadata) error {\n\tif err := fs.FileExists(filePath); err != nil {\n\t\treturn err\n\t}\n\n\tdir, name := filepath.Split(filePath)\n\ttmpFilePath := filepath.Join(dir, \"xtmp-\"+name)\n\tsilenceFile := filepath.Join(dir, ulid.New()+\".\"+meta.CodecName)\n\n\tif err := cli.generateSilence(padDuration, meta.BitRate, meta.SampleRate, meta.ChannelCount, silenceFile); err != nil {\n\t\treturn err\n\t}\n\n\tcmd := exec.Command(\n\t\tffmpegBin,\n\t\t\"-i\", fmt.Sprintf(\"concat:%s|%s\", filePath, silenceFile),\n\t\t\"-c\", \"copy\",\n\t\ttmpFilePath,\n\t\t\"-y\",\n\t)\n\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"padding audio failed: %v\\nOutput: %s\", err, string(output))\n\t}\n\n\terr = fs.RenameFile(tmpFilePath, filePath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tgo fs.DeleteFile(tmpFilePath)\n\tgo fs.DeleteFile(silenceFile)\n\n\treturn nil\n}\n\n// TrimAudio trims the audio file at the specified filePath to the given totalDuration.\n// It creates a temporary file, processes the trimming using ffmpeg, and replaces the original file.\n//\n// Parameters:\n//   - filePath: The path to the audio file to be trimmed.\n//   - totalDuration: The desired duration of the trimmed audio in seconds.\n//\n// Returns:\n//   - An error if the file does not exist, the trimming process fails, or file operations encounter an issue.\nfunc (cli *CLI) TrimAudio(filePath string, totalDuration float64) error {\n\tif err := fs.FileExists(filePath); err != nil {\n\t\treturn err\n\t}\n\n\tdir, name := filepath.Split(filePath)\n\ttmpFilePath := filepath.Join(dir, \"xtmp-\"+name)\n\n\tcmd := exec.Command(\n\t\tffmpegBin,\n\t\t\"-i\", filePath,\n\t\t\"-t\", strconv.FormatFloat(totalDuration, 'f', 1, 64),\n\t\t\"-c:a\", \"copy\",\n\t\ttmpFilePath,\n\t\t\"-y\",\n\t)\n\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"triming audio failed: %v\\nOutput: %s\", err, string(output))\n\t}\n\n\terr = fs.RenameFile(tmpFilePath, filePath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfs.DeleteFile(tmpFilePath)\n\n\treturn nil\n}\n\n// ConvertAudioToAAC converts an audio file to AAC format.\n//\n// Parameters:\n//   - inputPath:  Path to the input audio file (supports various formats like MP3, WAV, etc.)\n//   - outputPath: Destination path for the converted AAC file (should end with .aac or .m4a)\n//   - bitRate:    Audio bitrate in kbps (e.g., 128 for 128kbps)\n//\n// Returns:\n//   - error: Returns nil on success, or an error if conversion fails. The error includes\n//     FFmpeg's output when available for debugging purposes.\nfunc (cli *CLI) ConvertAudioToAAC(inputPath, outputPath string, bitRate int) error {\n\tcmd := exec.Command(\n\t\tffmpegBin,\n\t\t\"-i\", inputPath,\n\t\t\"-vn\", // Ignore video streams\n\t\t\"-c:a\", \"aac\",\n\t\t\"-b:a\", strconv.Itoa(bitRate)+\"k\",\n\t\toutputPath,\n\t\t\"-y\",\n\t)\n\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"triming audio failed: %v\\nOutput: %s\", err, string(output))\n\t}\n\n\treturn nil\n}\n\n// generateSilence generates a silent audio file with the specified duration, bitrate, sample rate,\n// and number of channels. The resulting audio file is saved to the provided file path.\nfunc (cli *CLI) generateSilence(duration float64, bitRate, sampleRate, channelCount int, filePath string) error {\n\tlayout := \"stereo\"\n\tif channelCount == 1 {\n\t\tlayout = \"mono\"\n\t}\n\n\tcmd := exec.Command(\n\t\tffmpegBin,\n\t\t\"-f\", \"lavfi\",\n\t\t\"-i\", \"anullsrc=r=\"+strconv.Itoa(sampleRate)+\":cl=\"+layout,\n\t\t\"-t\", strconv.FormatFloat(duration, 'f', 1, 64),\n\t\t\"-b:a\", strconv.Itoa(bitRate)+\"k\",\n\t\t\"-ar\", strconv.Itoa(sampleRate),\n\t\t\"-ac\", strconv.Itoa(channelCount),\n\t\tfilePath,\n\t\t\"-y\",\n\t)\n\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"generating silence audio failed: %v\\nOutput: %s\", err, string(output))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/pkg/ffmpeg/types.go",
    "content": "package ffmpeg\n\n// AudioMetadata holds metadata information about an audio file.\ntype AudioMetadata struct {\n\tName         string  // The name of the audio\n\tDuration     float64 // The total duration of the audio file in seconds.\n\tBitRate      int     // The bit rate of the audio file in kbps (kilobits per second).\n\tCodecName    string  // The name of the codec used for encoding the audio.\n\tSampleRate   int     // The sample rate of the audio file in Hz (hertz).\n\tChannelCount int     // The number of audio channels (e.g., 1 for mono, 2 for stereo).\n}\n\ntype rawAudioMetadata struct {\n\tFormat struct {\n\t\tDuration string `json:\"duration\"`\n\t\tBitRate  string `json:\"bit_rate\"`\n\t\tTags     struct {\n\t\t\tTitle string `json:\"title\"`\n\t\t} `json:\"tags\"`\n\t} `json:\"format\"`\n\tStreams []struct {\n\t\tCodecName  string `json:\"codec_name\"`\n\t\tSampleRate string `json:\"sample_rate\"`\n\t\tChannels   int    `json:\"channels\"`\n\t} `json:\"streams\"`\n}\n"
  },
  {
    "path": "internal/pkg/fs/fs.go",
    "content": "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(dirPath, 0755)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n}\n\nfunc FileExists(filePath string) error {\n\t_, err := os.Stat(filePath)\n\tif os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"file does not exist: %v\", filePath)\n\t}\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"retrieving file info failed: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc DeleteFile(filePath string) error {\n\terr := os.Remove(filePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"deleting file failed: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc DeleteDirIfExists(path string) error {\n\t_, err := os.Stat(path)\n\tif os.IsNotExist(err) {\n\t\treturn nil\n\t} else if err != nil {\n\t\treturn fmt.Errorf(\"error checking directory: %v\", err)\n\t}\n\n\terr = os.RemoveAll(path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to delete directory: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc RenameFile(oldPath, newPath string) error {\n\terr := os.Rename(oldPath, newPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"renaming file failed: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc ListFilesFromDir(dirPath, fileExt string) ([]string, error) {\n\tvar filenames []string\n\n\tfileInfo, err := os.Stat(dirPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to access directory: %v\", err)\n\t}\n\tif !fileInfo.IsDir() {\n\t\treturn nil, fmt.Errorf(\"path is not a directory: %s\", dirPath)\n\t}\n\n\terr = filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !info.IsDir() {\n\t\t\text := strings.ToLower(filepath.Ext(path))\n\t\t\tif strings.Contains(ext, fileExt) {\n\t\t\t\trelPath, err := filepath.Rel(dirPath, path)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tfilenames = append(filenames, relPath)\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n\n\treturn filenames, err\n}\n"
  },
  {
    "path": "internal/pkg/hls/const.go",
    "content": "package hls\n\nconst SegmentExtension = \".ts\"\nconst timeFormat = \"2006-01-02T15:04:05.000Z\"\n\nconst (\n\tDefaultMaxSegmentDuration = 5\n\tDefaultLiveSegmentsAmount = 3\n)\n"
  },
  {
    "path": "internal/pkg/hls/playlist.go",
    "content": "// Package hls provides functionality for handling HTTP Live Streaming (HLS) playlists and segments.\npackage hls\n\nimport (\n\t\"math\"\n\t\"strconv\"\n\t\"time\"\n)\n\n// Playlist represents an HLS playlist structure.\ntype Playlist struct {\n\tLiveSegmentsAmount int // The number of live segments in the playlist.\n\tMaxSegmentDuration int // The maximum duration (in seconds) of a segment in the playlist.\n\n\tmediaSequence        int64\n\tdisconSequence       int64\n\tlastDisconUpdate     time.Time\n\tcurrentTrackSegments []*Segment\n\tnextTrackSegments    []*Segment\n\tcurrentSegmentPath   string\n}\n\n// NewPlaylist creates and returns a new Playlist instance with the provided current and next track segments.\n// It initializes the playlist with default values for live segments amount, max segment duration, media sequence,\n// discontinuity sequence, and last discontinuity update time.\n//\n// Parameters:\n//   - cur: The list of segments for the current track.\n//   - next: The list of segments for the next track.\n//\n// Returns:\n//   - A pointer to the newly created Playlist instance.\nfunc NewPlaylist(cur, next []*Segment) *Playlist {\n\treturn &Playlist{\n\t\tLiveSegmentsAmount: DefaultLiveSegmentsAmount,\n\t\tMaxSegmentDuration: DefaultMaxSegmentDuration,\n\n\t\tmediaSequence:    0,\n\t\tdisconSequence:   0,\n\t\tlastDisconUpdate: time.Now(),\n\n\t\tcurrentTrackSegments: cur,\n\t\tnextTrackSegments:    next,\n\n\t\tcurrentSegmentPath: \"\",\n\t}\n}\n\n// Generate constructs and returns the HLS playlist as a string based on the elapsed time.\n// It updates the discontinuity sequence, calculates the starting segment index, collects live segments,\n// and formats them into the HLS playlist format.\n//\n// Parameters:\n//   - elapsedTime: The elapsed time in seconds used to determine the current segment index.\n//\n// Returns:\n//   - A string representing the generated HLS playlist.\nfunc (p *Playlist) Generate(elapsedTime float64) string {\n\toffset := math.Mod(elapsedTime, float64(p.MaxSegmentDuration))\n\tliveSegments := p.currentSegments(elapsedTime)\n\tprevSegmentPath := p.currentSegmentPath\n\n\tif len(liveSegments) > 0 {\n\t\tp.currentSegmentPath = liveSegments[0].Path\n\t}\n\n\tp.UpdateDisconSequence(elapsedTime)\n\tif prevSegmentPath != p.currentSegmentPath {\n\t\tp.mediaSequence++\n\t}\n\n\tplaylist := hlsHeader(p.MaxSegmentDuration, p.mediaSequence, p.disconSequence, offset)\n\tfor _, seg := range liveSegments {\n\t\tplaylist += hlsSegment(seg.Duration, seg.Path, seg.IsFirst)\n\t}\n\n\treturn playlist\n}\n\n// Next updates the playlist by moving the next track segments to the current track segments\n// and assigning the provided segments as the new next track segments.\n//\n// Parameters:\n//   - next: The new list of segments to be set as the next track segments.\nfunc (p *Playlist) Next(next []*Segment) {\n\tp.currentTrackSegments = p.nextTrackSegments\n\tp.nextTrackSegments = next\n}\n\n// ChangeNext replays segments for the next track.\n//\n// Parameters:\n//   - next: The new list of segments to be set as the next track segments.\nfunc (p *Playlist) ChangeNext(next []*Segment) {\n\tp.nextTrackSegments = next\n}\n\n// AddSegments appends the provided segments to the next track segments list.\n//\n// Parameters:\n//   - segments: The list of segments to append to the next track segments.\nfunc (p *Playlist) AddSegments(segments []*Segment) {\n\tp.nextTrackSegments = append(p.nextTrackSegments, segments...)\n}\n\n// SetMediaSequence set a new sequence number for mediaSequence.\nfunc (p *Playlist) SetMediaSequence(sequence int64) {\n\tp.mediaSequence = sequence\n}\n\n// UpdateDisconSequence updates the discontinuity sequence if a discontinuity is detected.\n//\n// Parameters:\n//   - elapsedTime: The elapsed time in seconds used to calculate the current segment index.\nfunc (p *Playlist) UpdateDisconSequence(elapsedTime float64) {\n\telapsedFromLastUpdate := time.Until(p.lastDisconUpdate).Seconds()\n\tif math.Abs(elapsedFromLastUpdate) < float64(p.MaxSegmentDuration) {\n\t\treturn\n\t}\n\n\tindex := p.calcCurrentSegmentIndex(elapsedTime)\n\n\t// if the current track segment is the second and it is not the very first track,\n\t// there was a discontinuty, so we increment the discontinuty counter\n\tif index == 1 && p.mediaSequence > 1 {\n\t\tp.disconSequence++\n\t\tp.lastDisconUpdate = time.Now()\n\t}\n}\n\nfunc (p *Playlist) FirstNextTrackSegment() *Segment {\n\tif len(p.nextTrackSegments) > 0 {\n\t\treturn p.nextTrackSegments[0]\n\t}\n\n\treturn nil\n}\n\n// currentSegments gathers enough segments from current and next tracks to meet liveSegmentsAmount\nfunc (p *Playlist) currentSegments(elapsedTime float64) []*Segment {\n\tstartIndex := p.calcCurrentSegmentIndex(elapsedTime)\n\tliveSegments := make([]*Segment, 0, p.LiveSegmentsAmount)\n\n\tif startIndex < len(p.currentTrackSegments) {\n\t\tendIndex := startIndex + p.LiveSegmentsAmount\n\t\tif endIndex >= len(p.currentTrackSegments) {\n\t\t\tendIndex = len(p.currentTrackSegments)\n\t\t}\n\n\t\tliveSegments = append(liveSegments, p.currentTrackSegments[startIndex:endIndex]...)\n\t}\n\n\tif len(liveSegments) < p.LiveSegmentsAmount {\n\t\trequired := p.LiveSegmentsAmount - len(liveSegments)\n\t\tliveSegments = append(liveSegments, p.nextTrackSegments[:min(len(p.nextTrackSegments), required)]...)\n\t}\n\n\treturn liveSegments\n}\n\nfunc (p *Playlist) calcCurrentSegmentIndex(elapsedTime float64) int {\n\treturn int(math.Floor(elapsedTime / float64(p.MaxSegmentDuration)))\n}\n\n// hlsHeader generates the header string for an HLS playlist with the specified target duration.\nfunc hlsHeader(dur int, mediaSeq, disconSeq int64, offset float64) string {\n\tcurrentTime := time.Now().UTC().Round(time.Millisecond).Format(timeFormat)\n\treturn \"#EXTM3U\\n\" +\n\t\t\"#EXT-X-VERSION:6\\n\" +\n\t\t\"#EXT-X-PROGRAM-DATE-TIME:\" + currentTime + \"\\n\" +\n\t\t\"#EXT-X-TARGETDURATION:\" + strconv.Itoa(dur) + \"\\n\" +\n\t\t\"#EXT-X-MEDIA-SEQUENCE:\" + strconv.FormatInt(mediaSeq, 10) + \"\\n\" +\n\t\t\"#EXT-X-DISCONTINUITY-SEQUENCE:\" + strconv.FormatInt(disconSeq, 10) + \"\\n\" +\n\t\t\"#EXT-X-START:TIME-OFFSET=\" + strconv.FormatFloat(offset, 'f', 2, 64) + \"\\n\"\n}\n\n// hlsSegment generates an HLS segment entry with the specified duration and path.\nfunc hlsSegment(dur float64, path string, isDiscon bool) string {\n\tdisconTag := \"\"\n\n\tif isDiscon {\n\t\tdisconTag = \"#EXT-X-DISCONTINUITY\\n\"\n\t}\n\n\tduration := strconv.FormatFloat(dur, 'f', 2, 64)\n\treturn disconTag +\n\t\t\"#EXTINF:\" + duration + \",\\n\" +\n\t\tpath + \"\\n\"\n}\n"
  },
  {
    "path": "internal/pkg/hls/playlist_test.go",
    "content": "package hls\n\nimport (\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestNewPlaylist(t *testing.T) {\n\tcurrent := []*Segment{{Duration: 10.5, Path: \"segment1.ts\"}}\n\tnext := []*Segment{{Duration: 9.0, Path: \"segment2.ts\"}}\n\tplaylist := NewPlaylist(current, next)\n\n\tif playlist.MaxSegmentDuration != DefaultMaxSegmentDuration {\n\t\tt.Errorf(\"Expected maxSegmentDuration to be %d, got %d\", DefaultMaxSegmentDuration, playlist.MaxSegmentDuration)\n\t}\n\n\tif playlist.LiveSegmentsAmount != DefaultLiveSegmentsAmount {\n\t\tt.Errorf(\"Expected liveSegmentsAmount to be %d, got %d\", DefaultLiveSegmentsAmount, playlist.LiveSegmentsAmount)\n\t}\n\n\tif len(playlist.currentTrackSegments) != len(current) {\n\t\tt.Errorf(\"Expected %d segment in currentTrackSegments, got %d\", len(current), len(playlist.currentTrackSegments))\n\t}\n\n\tif len(playlist.nextTrackSegments) != len(next) {\n\t\tt.Errorf(\"Expected %d segment in nextTrackSegments, got %d\", len(next), len(playlist.nextTrackSegments))\n\t}\n}\n\nfunc TestGenerate(t *testing.T) {\n\tcases := []struct {\n\t\tname          string\n\t\tcurrent       []*Segment\n\t\tnext          []*Segment\n\t\telapsedTime   float64\n\t\texpectedPaths []string\n\t\tunexpected    []string\n\t}{\n\t\t{\n\t\t\tname: \"full from current track\",\n\t\t\tcurrent: []*Segment{\n\t\t\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t\t\t\t{Duration: 5.0, Path: \"segment3.ts\"},\n\t\t\t},\n\t\t\tnext: []*Segment{\n\t\t\t\t{Duration: 5.0, Path: \"segment4.ts\"},\n\t\t\t},\n\t\t\telapsedTime:   0.0,\n\t\t\texpectedPaths: []string{\"segment1.ts\", \"segment2.ts\", \"segment3.ts\"},\n\t\t\tunexpected:    []string{\"segment4.ts\"},\n\t\t},\n\t\t{\n\t\t\tname: \"partial from current and next track\",\n\t\t\tcurrent: []*Segment{\n\t\t\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t\t\t},\n\t\t\tnext: []*Segment{\n\t\t\t\t{Duration: 5.0, Path: \"segment3.ts\"},\n\t\t\t\t{Duration: 5.0, Path: \"segment4.ts\"},\n\t\t\t},\n\t\t\telapsedTime:   5.0,\n\t\t\texpectedPaths: []string{\"segment2.ts\", \"segment3.ts\", \"segment4.ts\"},\n\t\t\tunexpected:    []string{\"segment1.ts\"},\n\t\t},\n\t\t{\n\t\t\tname: \"full from next track\",\n\t\t\tcurrent: []*Segment{\n\t\t\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t\t},\n\t\t\tnext: []*Segment{\n\t\t\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t\t\t\t{Duration: 5.0, Path: \"segment3.ts\"},\n\t\t\t\t{Duration: 5.0, Path: \"segment4.ts\"},\n\t\t\t},\n\t\t\telapsedTime:   20.0,\n\t\t\texpectedPaths: []string{\"segment2.ts\", \"segment3.ts\", \"segment4.ts\"},\n\t\t\tunexpected:    []string{\"segment1.ts\"},\n\t\t},\n\t\t{\n\t\t\tname: \"not enough segments\",\n\t\t\tcurrent: []*Segment{\n\t\t\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t\t},\n\t\t\tnext: []*Segment{\n\t\t\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t\t\t},\n\t\t\telapsedTime:   0.0,\n\t\t\texpectedPaths: []string{\"segment1.ts\", \"segment2.ts\"},\n\t\t\tunexpected:    []string{\"segment3.ts\"},\n\t\t},\n\t\t{\n\t\t\tname:          \"empty tracks\",\n\t\t\tcurrent:       []*Segment{},\n\t\t\tnext:          []*Segment{},\n\t\t\telapsedTime:   0.0,\n\t\t\texpectedPaths: []string{},\n\t\t\tunexpected:    []string{\"segment1.ts\"},\n\t\t},\n\t\t{\n\t\t\tname: \"start index beyond current track\",\n\t\t\tcurrent: []*Segment{\n\t\t\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t\t},\n\t\t\tnext: []*Segment{\n\t\t\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t\t\t\t{Duration: 5.0, Path: \"segment3.ts\"}},\n\t\t\telapsedTime:   20.0,\n\t\t\texpectedPaths: []string{\"segment2.ts\", \"segment3.ts\"},\n\t\t\tunexpected:    []string{\"segment1.ts\"},\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\tplaylist := NewPlaylist(c.current, c.next)\n\t\t\tgot := playlist.Generate(c.elapsedTime)\n\n\t\t\tfor _, expected := range c.expectedPaths {\n\t\t\t\tif !strings.Contains(got, expected) {\n\t\t\t\t\tt.Errorf(\"Expected segment %s not found in playlist: %s\", expected, got)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfor _, path := range c.unexpected {\n\t\t\t\tif strings.Contains(got, path) {\n\t\t\t\t\tt.Errorf(\"Unexpected segment %s found in playlist: %s\", path, got)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif !strings.HasPrefix(got, \"#EXTM3U\") {\n\t\t\t\tt.Errorf(\"Playlist header is missing: %s\", got)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNext(t *testing.T) {\n\tcurrent := []*Segment{\n\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t}\n\tnext := []*Segment{\n\t\t{Duration: 5.0, Path: \"segment3.ts\"},\n\t}\n\n\tplaylist := NewPlaylist(current, next)\n\n\tplaylist.Next([]*Segment{{Duration: 8.0, Path: \"segment4.ts\"}})\n\n\tif len(playlist.currentTrackSegments) != 1 || playlist.currentTrackSegments[0].Path != \"segment3.ts\" {\n\t\tt.Errorf(\"Expected currentTrackSegments to contain nextTrackSegments, got: %v\", playlist.currentTrackSegments)\n\t}\n\n\tif len(playlist.nextTrackSegments) != 1 || playlist.nextTrackSegments[0].Path != \"segment4.ts\" {\n\t\tt.Errorf(\"Expected nextTrackSegments to be updated, got: %v\", playlist.nextTrackSegments)\n\t}\n}\n\nfunc TestAddSegments(t *testing.T) {\n\tcurrent := []*Segment{{Duration: 5.0, Path: \"segment1.ts\"}}\n\tnext := []*Segment{{Duration: 5.0, Path: \"segment2.ts\"}}\n\tplaylist := NewPlaylist(current, next)\n\n\tnewSegments := []*Segment{\n\t\t{Duration: 8.0, Path: \"segment3.ts\"},\n\t\t{Duration: 7.5, Path: \"segment4.ts\"},\n\t}\n\tplaylist.AddSegments(newSegments)\n\n\tif len(playlist.nextTrackSegments) != 3 {\n\t\tt.Errorf(\"Expected nextTrackSegments to contain 3 segments, got: %d\", len(playlist.nextTrackSegments))\n\t}\n\n\tif playlist.nextTrackSegments[2].Path != \"segment4.ts\" {\n\t\tt.Errorf(\"Expected last segment to be segment4.ts, got: %s\", playlist.nextTrackSegments[2].Path)\n\t}\n}\n\nfunc TestCollectLiveSegments(t *testing.T) {\n\tt.Run(\"full from current track\", func(t *testing.T) {\n\t\tcurrent := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t\t\t{Duration: 5.0, Path: \"segment3.ts\"},\n\t\t}\n\t\tnext := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment4.ts\"},\n\t\t}\n\n\t\tplaylist := NewPlaylist(current, next)\n\n\t\tgot := playlist.currentSegments(0)\n\t\texpected := current\n\n\t\tif !reflect.DeepEqual(got, expected) {\n\t\t\tt.Errorf(\"Expected %v, got %v\", expected, got)\n\t\t}\n\t})\n\n\tt.Run(\"partitial from current track\", func(t *testing.T) {\n\t\tcurrent := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t\t\t{Duration: 5.0, Path: \"segment3.ts\"},\n\t\t}\n\t\tnext := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment4.ts\"},\n\t\t}\n\t\tplaylist := NewPlaylist(current, next)\n\n\t\tgot := playlist.currentSegments(0)\n\t\texpected := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t\t\t{Duration: 5.0, Path: \"segment3.ts\"},\n\t\t}\n\n\t\tif !reflect.DeepEqual(got, expected) {\n\t\t\tt.Errorf(\"Expected %v, got %v\", expected, got)\n\t\t}\n\t})\n\n\tt.Run(\"partial from current and next track\", func(t *testing.T) {\n\t\tcurrent := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t\t}\n\t\tnext := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment3.ts\"},\n\t\t\t{Duration: 5.0, Path: \"segment4.ts\"},\n\t\t}\n\t\tplaylist := NewPlaylist(current, next)\n\n\t\tgot := playlist.currentSegments(1)\n\t\texpected := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t\t\t{Duration: 5.0, Path: \"segment3.ts\"},\n\t\t}\n\n\t\tif !reflect.DeepEqual(got, expected) {\n\t\t\tt.Errorf(\"Expected %v, got %v\", expected, got)\n\t\t}\n\t})\n\n\tt.Run(\"full from next track\", func(t *testing.T) {\n\t\tcurrent := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t}\n\t\tnext := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t\t\t{Duration: 5.0, Path: \"segment3.ts\"},\n\t\t\t{Duration: 5.0, Path: \"segment4.ts\"},\n\t\t}\n\t\tplaylist := NewPlaylist(current, next)\n\n\t\tgot := playlist.currentSegments(DefaultMaxSegmentDuration)\n\t\texpected := next\n\n\t\tif !reflect.DeepEqual(got, expected) {\n\t\t\tt.Errorf(\"Expected %v, got %v\", expected, got)\n\t\t}\n\t})\n\n\tt.Run(\"not enough segments\", func(t *testing.T) {\n\t\tcurrent := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t}\n\t\tnext := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t\t}\n\t\tplaylist := NewPlaylist(current, next)\n\n\t\tgot := playlist.currentSegments(0)\n\t\texpected := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t\t}\n\n\t\tif !reflect.DeepEqual(got, expected) {\n\t\t\tt.Errorf(\"Expected %v, got %v\", expected, got)\n\t\t}\n\t})\n\n\tt.Run(\"start index beyond current track\", func(t *testing.T) {\n\t\tcurrent := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment1.ts\"},\n\t\t}\n\t\tnext := []*Segment{\n\t\t\t{Duration: 5.0, Path: \"segment2.ts\"},\n\t\t\t{Duration: 5.0, Path: \"segment3.ts\"},\n\t\t}\n\t\tplaylist := NewPlaylist(current, next)\n\n\t\tgot := playlist.currentSegments(DefaultMaxSegmentDuration)\n\t\texpected := next\n\n\t\tif !reflect.DeepEqual(got, expected) {\n\t\t\tt.Errorf(\"Expected %v, got %v\", expected, got)\n\t\t}\n\t})\n\n\tt.Run(\"empty tracks\", func(t *testing.T) {\n\t\tcurrent := []*Segment{}\n\t\tnext := []*Segment{}\n\t\tplaylist := NewPlaylist(current, next)\n\n\t\tgot := playlist.currentSegments(0)\n\t\texpected := []*Segment{}\n\n\t\tif !reflect.DeepEqual(got, expected) {\n\t\t\tt.Errorf(\"Expected %v, got %v\", expected, got)\n\t\t}\n\t})\n}\n\nfunc TestHlsHeader(t *testing.T) {\n\tcases := []struct {\n\t\tname      string\n\t\tdur       int\n\t\tmediaSeq  int64\n\t\tdisconSeq int64\n\t\toffset    float64\n\t\texpected  []string\n\t}{\n\t\t{\n\t\t\tname:      \"Basic case\",\n\t\t\tdur:       10,\n\t\t\tmediaSeq:  1,\n\t\t\tdisconSeq: 0,\n\t\t\toffset:    0.0,\n\t\t\texpected: []string{\n\t\t\t\t\"#EXTM3U\\n\",\n\t\t\t\t\"#EXT-X-VERSION:6\\n\",\n\t\t\t\t\"#EXT-X-TARGETDURATION:10\\n\",\n\t\t\t\t\"#EXT-X-MEDIA-SEQUENCE:1\\n\",\n\t\t\t\t\"#EXT-X-DISCONTINUITY-SEQUENCE:0\\n\",\n\t\t\t\t\"#EXT-X-START:TIME-OFFSET=0.00\\n\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"Non-zero discontinuity sequence\",\n\t\t\tdur:       15,\n\t\t\tmediaSeq:  5,\n\t\t\tdisconSeq: 2,\n\t\t\toffset:    5.5,\n\t\t\texpected: []string{\n\t\t\t\t\"#EXTM3U\\n\",\n\t\t\t\t\"#EXT-X-VERSION:6\\n\",\n\t\t\t\t\"#EXT-X-TARGETDURATION:15\\n\",\n\t\t\t\t\"#EXT-X-MEDIA-SEQUENCE:5\\n\",\n\t\t\t\t\"#EXT-X-DISCONTINUITY-SEQUENCE:2\\n\",\n\t\t\t\t\"#EXT-X-START:TIME-OFFSET=5.50\\n\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"Zero values\",\n\t\t\tdur:       0,\n\t\t\tmediaSeq:  0,\n\t\t\tdisconSeq: 0,\n\t\t\toffset:    0.0,\n\t\t\texpected: []string{\n\t\t\t\t\"#EXTM3U\\n\",\n\t\t\t\t\"#EXT-X-VERSION:6\\n\",\n\t\t\t\t\"#EXT-X-TARGETDURATION:0\\n\",\n\t\t\t\t\"#EXT-X-MEDIA-SEQUENCE:0\\n\",\n\t\t\t\t\"#EXT-X-DISCONTINUITY-SEQUENCE:0\\n\",\n\t\t\t\t\"#EXT-X-START:TIME-OFFSET=0.00\\n\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"Large values\",\n\t\t\tdur:       999,\n\t\t\tmediaSeq:  123456789,\n\t\t\tdisconSeq: 987654321,\n\t\t\toffset:    123.45,\n\t\t\texpected: []string{\n\t\t\t\t\"#EXTM3U\\n\",\n\t\t\t\t\"#EXT-X-VERSION:6\\n\",\n\t\t\t\t\"#EXT-X-TARGETDURATION:999\\n\",\n\t\t\t\t\"#EXT-X-MEDIA-SEQUENCE:123456789\\n\",\n\t\t\t\t\"#EXT-X-DISCONTINUITY-SEQUENCE:987654321\\n\",\n\t\t\t\t\"#EXT-X-START:TIME-OFFSET=123.45\\n\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\tgot := hlsHeader(c.dur, c.mediaSeq, c.disconSeq, c.offset)\n\n\t\t\tfor _, expect := range c.expected {\n\t\t\t\tif !strings.Contains(got, expect) {\n\t\t\t\t\tt.Errorf(\"hlsHeader(%d, %d, %d, %.2f) = %q; want %q\", c.dur, c.mediaSeq, c.disconSeq, c.offset, got, c.expected)\n\t\t\t\t}\n\t\t\t}\n\n\t\t})\n\t}\n}\n\nfunc TestHlsSegment(t *testing.T) {\n\tcases := []struct {\n\t\tname     string\n\t\tdur      float64\n\t\tpath     string\n\t\tisDiscon bool\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"Basic segment without discontinuity\",\n\t\t\tdur:      5.5,\n\t\t\tpath:     \"segment0.ts\",\n\t\t\tisDiscon: false,\n\t\t\texpected: \"#EXTINF:5.50,\\nsegment0.ts\\n\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Segment with discontinuity\",\n\t\t\tdur:      8.333,\n\t\t\tpath:     \"segment1.ts\",\n\t\t\tisDiscon: true,\n\t\t\texpected: \"#EXT-X-DISCONTINUITY\\n#EXTINF:8.33,\\nsegment1.ts\\n\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Zero duration segment without discontinuity\",\n\t\t\tdur:      0,\n\t\t\tpath:     \"segment2.ts\",\n\t\t\tisDiscon: false,\n\t\t\texpected: \"#EXTINF:0.00,\\nsegment2.ts\\n\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Large duration segment with discontinuity\",\n\t\t\tdur:      1234.56789,\n\t\t\tpath:     \"segment3.ts\",\n\t\t\tisDiscon: true,\n\t\t\texpected: \"#EXT-X-DISCONTINUITY\\n#EXTINF:1234.57,\\nsegment3.ts\\n\",\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\tresult := hlsSegment(c.dur, c.path, c.isDiscon)\n\t\t\tif result != c.expected {\n\t\t\t\tt.Errorf(\"hlsSegment(%f, %q, %v) = %q; want %q\", c.dur, c.path, c.isDiscon, result, c.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/hls/segment.go",
    "content": "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.\ntype Segment struct {\n\tDuration float64 // The length of the segment in seconds.\n\tPath     string  // The file path or URL of the segment.\n\tIsFirst  bool    // A flag indicating whether this segment is the first segment in the track.\n}\n\n// NewSegment creates and returns a new Segment instance with the provided duration, path, and first segment flag.\n//\n// Parameters:\n//   - duration: The duration of the segment in seconds.\n//   - path: The file path or URL of the segment.\n//   - isFirst: A boolean flag indicating whether this segment is the first segment in the track.\n//\n// Returns:\n//   - A pointer to the newly created Segment instance.\nfunc NewSegment(duration float64, path string, isFirst bool) *Segment {\n\treturn &Segment{\n\t\tDuration: duration,\n\t\tPath:     path,\n\t\tIsFirst:  isFirst,\n\t}\n}\n\n// GenerateSegments creates a list of Segment instances for a given track based on its duration and segment duration.\n// It divides the track into segments of the specified duration and generates metadata for each segment.\n//\n// Parameters:\n//   - trackDuration: The total duration of the track in seconds.\n//   - segmentDuration: The desired duration of each segment in seconds.\n//   - trackID: The unique identifier for the track, used to generate segment names.\n//   - outDir: The output directory where the segments will be stored.\n//\n// Returns:\n//   - A slice of pointers to Segment instances representing the generated segments.\nfunc GenerateSegments(trackDuration float64, segmentDuration int, trackID, outDir string) []*Segment {\n\tif trackDuration <= 0 || segmentDuration <= 0 {\n\t\treturn []*Segment{}\n\t}\n\n\t// Calculate total possible number of segments (rounded up)\n\ttotalSegments := int(math.Round(trackDuration / float64(segmentDuration)))\n\tsegments := make([]*Segment, 0, totalSegments)\n\n\tremaining := trackDuration\n\tindex := 0\n\n\t// Generate segments until the entire track is covered\n\tfor remaining > 0 {\n\t\tsegName := trackID + strconv.Itoa(index) + SegmentExtension\n\t\tsegPath := filepath.Join(outDir, segName)\n\n\t\t// Use the smaller of the remaining or full segment duration\n\t\tduration := math.Min(remaining, float64(segmentDuration))\n\t\tisFirst := index == 0\n\t\tsegments = append(segments, NewSegment(duration, segPath, isFirst))\n\n\t\tremaining -= duration\n\t\tindex++\n\t}\n\n\treturn segments\n}\n"
  },
  {
    "path": "internal/pkg/hls/segment_test.go",
    "content": "package hls\n\nimport (\n\t\"testing\"\n)\n\nfunc TestNewSegment(t *testing.T) {\n\tcases := []struct {\n\t\tname     string\n\t\tduration float64\n\t\tpath     string\n\t\tisFirst  bool\n\t\texpected *Segment\n\t}{\n\t\t{\n\t\t\tname:     \"Basic segment\",\n\t\t\tduration: 5.5,\n\t\t\tpath:     \"segment0.ts\",\n\t\t\tisFirst:  false,\n\t\t\texpected: &Segment{\n\t\t\t\tDuration: 5.5,\n\t\t\t\tPath:     \"segment0.ts\",\n\t\t\t\tIsFirst:  false,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"First segment\",\n\t\t\tduration: 8.333,\n\t\t\tpath:     \"segment1.ts\",\n\t\t\tisFirst:  true,\n\t\t\texpected: &Segment{\n\t\t\t\tDuration: 8.333,\n\t\t\t\tPath:     \"segment1.ts\",\n\t\t\t\tIsFirst:  true,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"Zero duration\",\n\t\t\tduration: 0,\n\t\t\tpath:     \"segment2.ts\",\n\t\t\tisFirst:  false,\n\t\t\texpected: &Segment{\n\t\t\t\tDuration: 0,\n\t\t\t\tPath:     \"segment2.ts\",\n\t\t\t\tIsFirst:  false,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"Empty path\",\n\t\t\tduration: 10.0,\n\t\t\tpath:     \"\",\n\t\t\tisFirst:  false,\n\t\t\texpected: &Segment{\n\t\t\t\tDuration: 10.0,\n\t\t\t\tPath:     \"\",\n\t\t\t\tIsFirst:  false,\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\tgot := NewSegment(c.duration, c.path, c.isFirst)\n\n\t\t\tif got.Duration != c.expected.Duration {\n\t\t\t\tt.Errorf(\"Duration = %f; want %f\", got.Duration, c.expected.Duration)\n\t\t\t}\n\t\t\tif got.Path != c.expected.Path {\n\t\t\t\tt.Errorf(\"Path = %q; want %q\", got.Path, c.expected.Path)\n\t\t\t}\n\t\t\tif got.IsFirst != c.expected.IsFirst {\n\t\t\t\tt.Errorf(\"IsFirst = %v; want %v\", got.IsFirst, c.expected.IsFirst)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGenerateSegments(t *testing.T) {\n\tcases := []struct {\n\t\tname             string\n\t\ttrackDuration    float64\n\t\tsegmentDuration  int\n\t\ttrackID          string\n\t\toutDir           string\n\t\texpectedSegments []*Segment\n\t}{\n\t\t{\n\t\t\tname:            \"Basic case\",\n\t\t\ttrackDuration:   10.0,\n\t\t\tsegmentDuration: 3,\n\t\t\ttrackID:         \"track1\",\n\t\t\toutDir:          \"/out\",\n\t\t\texpectedSegments: []*Segment{\n\t\t\t\t{Duration: 3.0, Path: \"/out/track10.ts\", IsFirst: true},\n\t\t\t\t{Duration: 3.0, Path: \"/out/track11.ts\", IsFirst: false},\n\t\t\t\t{Duration: 3.0, Path: \"/out/track12.ts\", IsFirst: false},\n\t\t\t\t{Duration: 1.0, Path: \"/out/track13.ts\", IsFirst: false},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:             \"Zero track duration\",\n\t\t\ttrackDuration:    0,\n\t\t\tsegmentDuration:  3,\n\t\t\ttrackID:          \"track2\",\n\t\t\toutDir:           \"/out\",\n\t\t\texpectedSegments: nil,\n\t\t},\n\t\t{\n\t\t\tname:             \"Zero segment duration\",\n\t\t\ttrackDuration:    10.0,\n\t\t\tsegmentDuration:  0,\n\t\t\ttrackID:          \"track3\",\n\t\t\toutDir:           \"/out\",\n\t\t\texpectedSegments: nil,\n\t\t},\n\t\t{\n\t\t\tname:            \"Exact division of track duration\",\n\t\t\ttrackDuration:   9.0,\n\t\t\tsegmentDuration: 3,\n\t\t\ttrackID:         \"track4\",\n\t\t\toutDir:          \"/out\",\n\t\t\texpectedSegments: []*Segment{\n\t\t\t\t{Duration: 3.0, Path: \"/out/track40.ts\", IsFirst: true},\n\t\t\t\t{Duration: 3.0, Path: \"/out/track41.ts\", IsFirst: false},\n\t\t\t\t{Duration: 3.0, Path: \"/out/track42.ts\", IsFirst: false},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:            \"Large track duration\",\n\t\t\ttrackDuration:   25.0,\n\t\t\tsegmentDuration: 10,\n\t\t\ttrackID:         \"track5\",\n\t\t\toutDir:          \"/out\",\n\t\t\texpectedSegments: []*Segment{\n\t\t\t\t{Duration: 10.0, Path: \"/out/track50.ts\", IsFirst: true},\n\t\t\t\t{Duration: 10.0, Path: \"/out/track51.ts\", IsFirst: false},\n\t\t\t\t{Duration: 5.0, Path: \"/out/track52.ts\", IsFirst: false},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\tgot := GenerateSegments(c.trackDuration, c.segmentDuration, c.trackID, c.outDir)\n\n\t\t\tif len(got) != len(c.expectedSegments) {\n\t\t\t\tt.Fatalf(\"Expected %d segments, got %d\", len(c.expectedSegments), len(got))\n\t\t\t}\n\n\t\t\tfor i, gotSegment := range got {\n\t\t\t\tif gotSegment.Duration != c.expectedSegments[i].Duration {\n\t\t\t\t\tt.Errorf(\"Segment %d: expected duration %f, got %f\", i, c.expectedSegments[i].Duration, gotSegment.Duration)\n\t\t\t\t}\n\t\t\t\tif gotSegment.Path != c.expectedSegments[i].Path {\n\t\t\t\t\tt.Errorf(\"Segment %d: expected path %q, got %q\", i, c.expectedSegments[i].Path, gotSegment.Path)\n\t\t\t\t}\n\t\t\t\tif gotSegment.IsFirst != c.expectedSegments[i].IsFirst {\n\t\t\t\t\tt.Errorf(\"Segment %d: expected IsFirst %v, got %v\", i, c.expectedSegments[i].IsFirst, gotSegment.IsFirst)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/sql/sql.go",
    "content": "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 == 0 {\n\t\treturn fmt.Sprintf(\"%s IN ()\", column) // Edge case: Empty IN clause (should be avoided in real queries)\n\t}\n\n\tplaceholders := strings.Repeat(\"?,\", num)\n\tplaceholders = placeholders[:len(placeholders)-1] // Remove trailing comma\n\treturn fmt.Sprintf(\"%s IN (%s)\", column, placeholders)\n}\n\nfunc ColumnExists(db *sql.DB, tableName, columnName string) (bool, error) {\n\tquery := `\n        SELECT COUNT(*) > 0\n        FROM pragma_table_info(?)\n        WHERE name = ?\n    `\n\n\tvar exists bool\n\terr := db.QueryRow(query, tableName, columnName).Scan(&exists)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"failed to check column existence: %w\", err)\n\t}\n\n\treturn exists, nil\n}\n\nfunc TableExists(db *sql.DB, tableName string) (bool, error) {\n\tquery := `\n        SELECT COUNT(*) > 0\n        FROM sqlite_master\n        WHERE type = 'table' AND name = ?\n    `\n\n\tvar exists bool\n\terr := db.QueryRow(query, tableName).Scan(&exists)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"failed to check table existence: %w\", err)\n\t}\n\n\treturn exists, nil\n}\n"
  },
  {
    "path": "internal/pkg/sse/emitter.go",
    "content": "package sse\n\nimport (\n\t\"sync\"\n)\n\n// Emitter manages a set of subscribers and broadcasts events to them.\ntype Emitter struct {\n\tsubscribers sync.Map // A thread-safe map storing subscriber channels.\n}\n\n// NewEmitter creates and returns a new Emitter instance.\n//\n// Returns:\n//   - A pointer to a new Emitter.\nfunc NewEmitter() *Emitter {\n\treturn &Emitter{}\n}\n\n// RegisterEvent broadcasts an event with the specified name and data\n// to all currently subscribed channels.\n//\n// Parameters:\n//   - name: The event name/type.\n//   - data: The string payload of the event.\nfunc (ee *Emitter) RegisterEvent(name, data string) {\n\tevent := NewEvent(name, data)\n\tee.subscribers.Range(func(key, value any) bool {\n\t\teventChan := key.(chan *Event)\n\t\teventChan <- event\n\t\treturn true\n\t})\n}\n\n// CountSubscribers returns the number of currently active subscribers.\n//\n// Returns:\n//   - An integer count of subscriber channels.\nfunc (ee *Emitter) CountSubscribers() int {\n\tcount := 0\n\tee.subscribers.Range(func(key, value any) bool {\n\t\tcount++\n\t\treturn true\n\t})\n\treturn count\n}\n\n// Subscribe adds a new subscriber channel to receive events.\n//\n// Parameters:\n//   - eventChan: A channel to which events will be sent.\nfunc (ee *Emitter) Subscribe(eventChan chan *Event) {\n\tee.subscribers.Store(eventChan, true)\n}\n\n// Unsubscribe removes a previously added subscriber channel.\n//\n// Parameters:\n//   - eventChan: The channel to remove from the list of subscribers.\nfunc (ee *Emitter) Unsubscribe(eventChan chan *Event) {\n\tee.subscribers.Delete(eventChan)\n}\n"
  },
  {
    "path": "internal/pkg/sse/event.go",
    "content": "// Package events provides a lightweight structure and utilities for creating\n// and formatting Server-Sent Events (SSE) to be sent over HTTP connections.\npackage sse\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\n// Event represents a Server-Sent Event (SSE) with a name and data payload.\ntype Event struct {\n\tName string // The name/type of the event (used as \"event:\" in SSE format).\n\tData string // The data payload of the event (used as \"data:\" in SSE format).\n}\n\n// NewEvent creates a new Event instance with the given name and data.\n//\n// Parameters:\n//   - name: The name/type of the event.\n//   - data: The string data payload associated with the event.\n//\n// Returns:\n//   - A pointer to the newly created Event.\nfunc NewEvent(name, data string) *Event {\n\treturn &Event{\n\t\tName: name,\n\t\tData: data,\n\t}\n}\n\n// Stringify converts the Event into a string formatted for Server-Sent Events (SSE).\n// The format includes the \"event\" and \"data\" fields as per SSE specification,\n// followed by a double newline to indicate the end of the event.\n//\n// Returns:\n//   - A string representation of the event in SSE format.\nfunc (e *Event) Stringify() string {\n\tvar builder strings.Builder\n\n\tif e.Name != \"\" {\n\t\tbuilder.WriteString(fmt.Sprintf(\"event: %s\\n\", e.Name))\n\t}\n\n\tif e.Data != \"\" {\n\t\tbuilder.WriteString(fmt.Sprintf(\"data: %s\\n\", e.Data))\n\t}\n\n\tbuilder.WriteString(\"\\n\")\n\treturn builder.String()\n}\n"
  },
  {
    "path": "internal/pkg/ulid/ulid.go",
    "content": "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 = ulid.EncodedSize\n\nvar (\n\tonce     sync.Once\n\tinstance *generator\n)\n\ntype generator struct {\n\ttimestamp uint64\n\tentropy   *ulid.MonotonicEntropy\n}\n\nfunc New() string {\n\tu := initGenerator()\n\n\treturn strings.ToLower(ulid.MustNew(u.timestamp, u.entropy).String())\n}\n\nfunc Verify(s string) error {\n\t_, err := ulid.Parse(s)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"id %s is not allowed, must satisfy the ulid format\", s)\n\t}\n\treturn nil\n}\n\nfunc initGenerator() *generator {\n\tonce.Do(func() {\n\t\tinstance = &generator{\n\t\t\ttimestamp: ulid.Timestamp(time.Now()),\n\t\t\tentropy:   ulid.Monotonic(rand.Reader, 0),\n\t\t}\n\t})\n\n\treturn instance\n}\n"
  },
  {
    "path": "internal/playback/service.go",
    "content": "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 NewService(store Store) *Service {\n\treturn &Service{\n\t\tstore: store,\n\t}\n}\n\n// AddPlaybackHistory logs a playback event for a given track.\n//\n// Parameters:\n//   - trackName: The name of the track that was played.\nfunc (s *Service) AddPlaybackHistory(trackName string) {\n\terr := s.store.AddPlaybackHistory(time.Now().Unix(), trackName)\n\tif err != nil {\n\t\ts.log.Error(\"Failed to add playback history: \" + err.Error())\n\t}\n}\n\n// RecentPlaybackHistory retrieves the most recent playback history records.\n//\n// Parameters:\n//   - limit: The maximum number of history entries to retrieve.\n//\n// Returns:\n//   - A slice of PlaybackHistory pointers, or an error.\nfunc (s *Service) RecentPlaybackHistory(limit int) ([]*History, error) {\n\thistory, err := s.store.RecentPlaybackHistory(limit)\n\treturn history, err\n}\n\n// DeleteOldPlaybackHistory removes outdated playback history entries from the store.\nfunc (s *Service) DeleteOldPlaybackHistory() {\n\t_, err := s.store.DeleteOldPlaybackHistory()\n\tif err != nil {\n\t\ts.log.Warn(\"Failed to delete old playback history: \" + err.Error())\n\t}\n}\n"
  },
  {
    "path": "internal/playback/state.go",
    "content": "// Package playback manages audio playback state, track transitions, and HLS playlist generation.\n// It coordinates the timing and sequencing of audio tracks, maintaining synchronized state\n// for streaming playback, including current position, play/pause control, and playlist updates.\n// This package interacts with the track service to load tracks, generate segments, and handle\n// queue changes in a thread-safe manner.\npackage playback\n\nimport (\n\t\"errors\"\n\t\"log/slog\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/cheatsnake/airstation/internal/pkg/hls\"\n\t\"github.com/cheatsnake/airstation/internal/queue\"\n\t\"github.com/cheatsnake/airstation/internal/track\"\n)\n\n// State represents the current playback state of the application, including the currently playing track,\n// elapsed playback time, playlist management, and synchronization tools for safe concurrent access.\ntype State struct {\n\tCurrentTrack        *track.Track `json:\"currentTrack\"`        // The currently playing track\n\tCurrentTrackElapsed float64      `json:\"currentTrackElapsed\"` // Seconds elapsed since the current track started playing\n\tIsPlaying           bool         `json:\"isPlaying\"`           // Whether a track is currently playing\n\tUpdatedAt           int64        `json:\"updatedAt\"`           // Unix timestamp of the last state update\n\n\tNewTrackNotify chan string `json:\"-\"` // Channel to notify when a new track starts playing\n\tPlayNotify     chan bool   `json:\"-\"` // Channel to notify when playback starts\n\tPauseNotify    chan bool   `json:\"-\"` // Channel to notify when playback is paused\n\n\tPlaylistStr string        `json:\"-\"` // Current HLS playlist as a string\n\tplaylist    *hls.Playlist // Internal representation of the HLS playlist\n\tplaylistDir string        // Directory where HLS playlist segments are stored\n\n\trefreshCount    int64   // Number of state refresh cycles completed\n\trefreshInterval float64 // Time interval (in seconds) between state updates\n\n\ttrackService    *track.Service\n\tqueueService    *queue.Service\n\tplaybackService *Service\n\n\tlog   *slog.Logger\n\tmutex sync.Mutex\n}\n\n// NewState creates and initializes a new playback State instance.\nfunc NewState(ts *track.Service, qs *queue.Service, ps *Service, tmpDir string, log *slog.Logger) *State {\n\treturn &State{\n\t\tCurrentTrack:        nil,\n\t\tCurrentTrackElapsed: 0,\n\t\tIsPlaying:           false,\n\t\tUpdatedAt:           time.Now().Unix(),\n\n\t\tNewTrackNotify: make(chan string),\n\t\tPlayNotify:     make(chan bool),\n\t\tPauseNotify:    make(chan bool),\n\n\t\ttrackService:    ts,\n\t\tqueueService:    qs,\n\t\tplaybackService: ps,\n\n\t\trefreshCount:    0,\n\t\tplaylistDir:     tmpDir,\n\t\trefreshInterval: 1,\n\n\t\tlog: log,\n\t}\n}\n\n// Run starts the state update loop which refreshes playback progress and switches tracks when needed.\nfunc (s *State) Run() {\n\tticker := time.NewTicker(time.Duration(s.refreshInterval) * time.Second)\n\tdefer ticker.Stop()\n\n\tfor range ticker.C {\n\t\tif !s.IsPlaying {\n\t\t\tcontinue\n\t\t}\n\n\t\ts.mutex.Lock()\n\t\ts.CurrentTrackElapsed += s.refreshInterval\n\t\ts.refreshCount++\n\n\t\tif s.CurrentTrackElapsed >= s.CurrentTrack.Duration {\n\t\t\terr := s.loadNextTrack()\n\t\t\tif err != nil {\n\t\t\t\ts.log.Error(err.Error())\n\t\t\t}\n\n\t\t\tgo s.queueService.CleanupHLSPlaylists(s.playlistDir)\n\t\t\tgo s.playbackService.AddPlaybackHistory(s.CurrentTrack.Name)\n\t\t}\n\n\t\ts.PlaylistStr = s.playlist.Generate(s.CurrentTrackElapsed)\n\t\ts.UpdatedAt = time.Now().Unix()\n\t\ts.mutex.Unlock()\n\t}\n}\n\n// Play starts playback by loading the current and next tracks into the HLS playlist.\nfunc (s *State) Play() error {\n\tcurrent, next, err := s.queueService.CurrentAndNextTrack()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif current == nil {\n\t\treturn errors.New(\"playback queue is empty\")\n\t}\n\n\terr = s.initHLSPlaylist(current, next)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ts.mutex.Lock()\n\ts.CurrentTrack = current\n\ts.PlaylistStr = s.playlist.Generate(s.CurrentTrackElapsed)\n\ts.UpdatedAt = time.Now().Unix()\n\ts.IsPlaying = true\n\ts.mutex.Unlock()\n\n\ts.PlayNotify <- true\n\tgo s.playbackService.AddPlaybackHistory(current.Name)\n\n\treturn nil\n}\n\n// Pause stops playback, clears current playback state and playlist.\nfunc (s *State) Pause() {\n\ts.mutex.Lock()\n\ts.CurrentTrack = nil\n\ts.CurrentTrackElapsed = 0\n\ts.playlist = nil\n\ts.PlaylistStr = \"\"\n\ts.IsPlaying = false\n\ts.UpdatedAt = time.Now().Unix()\n\ts.mutex.Unlock()\n\n\ts.PauseNotify <- false\n}\n\n// Reload refreshes the current playlist based on updated queue state, used after queue changes.\nfunc (s *State) Reload() error {\n\tif !s.IsPlaying {\n\t\treturn nil\n\t}\n\n\tcurrent, next, err := s.queueService.CurrentAndNextTrack()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tisCurrentTrackChanged := current != nil && s.CurrentTrack.ID != current.ID\n\tif isCurrentTrackChanged { // Restart if current track changed\n\t\ts.Pause()\n\t\terr = s.Play()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tsegment := s.playlist.FirstNextTrackSegment()\n\tisNextTrackChanged := segment != nil && !strings.Contains(segment.Path, next.ID)\n\tif isNextTrackChanged { // Change segments for next track if it changed\n\t\tnextSeg, err := s.makeHLSSegments(next, s.playlistDir)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ts.mutex.Lock()\n\t\ts.playlist.ChangeNext(nextSeg)\n\t\ts.mutex.Unlock()\n\t}\n\n\treturn nil\n}\n\n// initHLSPlaylist prepares HLS segments for the current and next tracks, initializing a new playlist.\nfunc (s *State) initHLSPlaylist(current, next *track.Track) error {\n\tcurrentSeg, err := s.makeHLSSegments(current, s.playlistDir)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tnextSeg, err := s.makeHLSSegments(next, s.playlistDir)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ts.mutex.Lock()\n\ts.playlist = hls.NewPlaylist(currentSeg, nextSeg)\n\ts.UpdatedAt = time.Now().Unix()\n\ts.mutex.Unlock()\n\n\treturn nil\n}\n\n// loadNextTrack advances the queue, resets elapsed time, and updates playlist with next segments.\nfunc (s *State) loadNextTrack() error {\n\ts.CurrentTrackElapsed = 0\n\terr := s.queueService.SpinQueue()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcurrent, next, err := s.queueService.CurrentAndNextTrack()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ts.CurrentTrack = current\n\tnextTrackSegments, err := s.makeHLSSegments(next, s.playlistDir)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ts.NewTrackNotify <- current.Name\n\ts.playlist.Next(nextTrackSegments)\n\treturn nil\n}\n\n// makeHLSSegments generates HLS segments for a given track.\nfunc (s *State) makeHLSSegments(track *track.Track, dir string) ([]*hls.Segment, error) {\n\tif track == nil {\n\t\treturn []*hls.Segment{}, nil\n\t}\n\n\terr := s.trackService.MakeHLSPlaylist(track.Path, dir, track.ID, hls.DefaultMaxSegmentDuration)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsegments := hls.GenerateSegments(\n\t\ttrack.Duration,\n\t\thls.DefaultMaxSegmentDuration,\n\t\ttrack.ID,\n\t\tdir,\n\t)\n\n\treturn segments, nil\n}\n"
  },
  {
    "path": "internal/playback/types.go",
    "content": "package playback\n\ntype History struct {\n\tID        int    `json:\"id\"`\n\tPlayedAt  int64  `json:\"playedAt\"`\n\tTrackName string `json:\"trackName\"`\n}\n\ntype Store interface {\n\tAddPlaybackHistory(playedAt int64, trackName string) error\n\tRecentPlaybackHistory(limit int) ([]*History, error)\n\tDeleteOldPlaybackHistory() (int64, error)\n}\n"
  },
  {
    "path": "internal/playlist/consts.go",
    "content": "package playlist\n\nconst (\n\tminNameLen  = 3\n\tmaxNameLen  = 128\n\tmaxDescrLen = 4096\n\tmaxTracks   = 100\n)\n"
  },
  {
    "path": "internal/playlist/service.go",
    "content": "package playlist\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n)\n\ntype Service struct {\n\tstore Store\n}\n\nfunc NewService(store Store) *Service {\n\treturn &Service{\n\t\tstore: store,\n\t}\n}\n\nfunc (s *Service) AddPlaylist(name, description string, trackIDs []string) (*Playlist, error) {\n\terr := validateName(name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = validateDescr(description)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = validateTracks(trackIDs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tisExists, err := s.store.IsPlaylistExists(name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif isExists {\n\t\treturn nil, fmt.Errorf(\"playlist with this name already exists\")\n\t}\n\n\tpl, err := s.store.AddPlaylist(name, description, trackIDs)\n\treturn pl, err\n}\n\nfunc (s *Service) Playlists() ([]*Playlist, error) {\n\tpls, err := s.store.Playlists()\n\treturn pls, err\n}\n\nfunc (s *Service) Playlist(id string) (*Playlist, error) {\n\tpl, err := s.store.Playlist(id)\n\treturn pl, err\n}\n\nfunc (s *Service) EditPlaylist(id, name, description string, trackIDs []string) error {\n\terr := validateName(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = validateDescr(description)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = validateTracks(trackIDs)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = s.store.EditPlaylist(id, name, description, trackIDs)\n\treturn err\n}\n\nfunc (s *Service) DeletePlaylist(id string) error {\n\terr := s.store.DeletePlaylist(id)\n\treturn err\n}\n\nfunc validateName(name string) error {\n\tif len(name) < minNameLen {\n\t\treturn fmt.Errorf(\"name must be at least %d characters\", minNameLen)\n\t}\n\tif len(name) > maxNameLen {\n\t\treturn fmt.Errorf(\"name must be at most %d characters\", maxNameLen)\n\t}\n\treturn nil\n}\n\nfunc validateDescr(descr string) error {\n\tif len(descr) > maxDescrLen {\n\t\treturn fmt.Errorf(\"description must be at most %d characters\", maxDescrLen)\n\t}\n\treturn nil\n}\n\nfunc validateTracks(trackIDs []string) error {\n\tif len(trackIDs) > maxTracks {\n\t\treturn fmt.Errorf(\"playlist cannot have more than %d tracks\", maxTracks)\n\t}\n\n\tseen := make(map[string]struct{}, len(trackIDs))\n\tfor _, id := range trackIDs {\n\t\tif id == \"\" {\n\t\t\treturn errors.New(\"track ID cannot be empty\")\n\t\t}\n\t\tif _, exists := seen[id]; exists {\n\t\t\treturn fmt.Errorf(\"duplicate track ID found: %s\", id)\n\t\t}\n\t\tseen[id] = struct{}{}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/playlist/types.go",
    "content": "package playlist\n\nimport \"github.com/cheatsnake/airstation/internal/track\"\n\ntype Playlist struct {\n\tID          string         `json:\"id\"`\n\tName        string         `json:\"name\"`\n\tDescription string         `json:\"description\"`\n\tTracks      []*track.Track `json:\"tracks\"`\n\tTrackCount  int            `json:\"trackCount\"`\n}\n\ntype Store interface {\n\tAddPlaylist(name, description string, trackIDs []string) (*Playlist, error)\n\tPlaylists() ([]*Playlist, error)\n\tPlaylist(id string) (*Playlist, error)\n\tIsPlaylistExists(name string) (bool, error)\n\tEditPlaylist(id, name, description string, trackIDs []string) error\n\tDeletePlaylist(id string) error\n}\n"
  },
  {
    "path": "internal/queue/service.go",
    "content": "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/cheatsnake/airstation/internal/pkg/hls\"\n\t\"github.com/cheatsnake/airstation/internal/track\"\n)\n\ntype Service struct {\n\tstore Store\n}\n\nfunc NewService(store Store) *Service {\n\treturn &Service{\n\t\tstore: store,\n\t}\n}\n\n// Queue retrieves the current playback queue.\n//\n// Returns:\n//   - A slice of Track pointers or an error.\nfunc (s *Service) Queue() ([]*track.Track, error) {\n\tq, err := s.store.Queue()\n\treturn q, err\n}\n\n// AddToQueue adds one or more tracks to the playback queue.\n//\n// Parameters:\n//   - tracks: A slice of Track pointers to add.\n//\n// Returns:\n//   - An error if the operation fails.\nfunc (s *Service) AddToQueue(tracks []*track.Track) error {\n\terr := s.store.AddToQueue(tracks)\n\treturn err\n}\n\n// ReorderQueue updates the order of tracks in the playback queue.\n//\n// Parameters:\n//   - ids: A slice of strings contains track IDs.\n//\n// Returns:\n//   - An error if reordering fails.\nfunc (s *Service) ReorderQueue(ids []string) error {\n\terr := s.store.ReorderQueue(ids)\n\treturn err\n}\n\n// RemoveFromQueue removes specific tracks from the playback queue.\n//\n// Parameters:\n//   - ids: A slice of strings contains track IDs.\n//\n// Returns:\n//   - An error if removal fails.\nfunc (s *Service) RemoveFromQueue(ids []string) error {\n\terr := s.store.RemoveFromQueue(ids)\n\treturn err\n}\n\n// SpinQueue rotates the playback queue, moving the current track to the end.\n//\n// Returns:\n//   - An error if the operation fails.\nfunc (s *Service) SpinQueue() error {\n\terr := s.store.SpinQueue()\n\treturn err\n}\n\n// CurrentAndNextTrack retrieves the currently playing track and the next track in the queue.\n//\n// Returns:\n//   - Pointers to the current and next tracks, and an error if retrieval fails.\nfunc (s *Service) CurrentAndNextTrack() (*track.Track, *track.Track, error) {\n\tcurrent, next, err := s.store.CurrentAndNextTrack()\n\treturn current, next, err\n}\n\n// CleanupHLSPlaylists removes old HLS playlist files that are no longer needed.\n//\n// Parameters:\n//   - dirPath: Directory containing the HLS playlist files.\n//\n// Returns:\n//   - An error if file cleanup fails.\nfunc (s *Service) CleanupHLSPlaylists(dirPath string) error {\n\t// waiting for all the listeners to listen to the last segments of ended track\n\ttime.Sleep(hls.DefaultMaxSegmentDuration * 2 * time.Second)\n\tcurrent, next, err := s.store.CurrentAndNextTrack()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tutilized := []string{current.ID, next.ID}\n\ttmpFiles, err := fs.ListFilesFromDir(dirPath, \"\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, tmpFile := range tmpFiles {\n\t\tkeep := false\n\t\tfor _, prefix := range utilized {\n\t\t\tif strings.HasPrefix(tmpFile, prefix) {\n\t\t\t\tkeep = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !keep {\n\t\t\tfs.DeleteFile(path.Join(dirPath, tmpFile))\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/queue/types.go",
    "content": "package queue\n\nimport \"github.com/cheatsnake/airstation/internal/track\"\n\ntype Store interface {\n\tQueue() ([]*track.Track, error)\n\tAddToQueue(tracks []*track.Track) error\n\tRemoveFromQueue(trackIDs []string) error\n\tReorderQueue(trackIDs []string) error\n\tSpinQueue() error\n\tCurrentAndNextTrack() (*track.Track, *track.Track, error)\n}\n"
  },
  {
    "path": "internal/station/const.go",
    "content": "package station\n\nconst (\n\tpropName        = \"name\"\n\tpropDescription = \"description\"\n\tpropFaviconURL  = \"faviconURL\"\n\tpropLogoURL     = \"logoURL\"\n\tpropLocation    = \"location\"\n\tpropTimezone    = \"timezone\"\n\tpropLinks       = \"links\"\n\tpropTheme       = \"theme\"\n)\n"
  },
  {
    "path": "internal/station/service.go",
    "content": "package station\n\ntype Service struct {\n\tstore Store\n}\n\nfunc NewService(store Store) *Service {\n\treturn &Service{\n\t\tstore: store,\n\t}\n}\n\nfunc (s *Service) Info() (*Info, error) {\n\trawProps, err := s.store.StationProperties()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tinfo := &Info{}\n\n\tfor _, prop := range rawProps {\n\t\tswitch prop.Key {\n\t\tcase propName:\n\t\t\tinfo.Name = prop.Value\n\t\tcase propDescription:\n\t\t\tinfo.Description = prop.Value\n\t\tcase propFaviconURL:\n\t\t\tinfo.FaviconURL = prop.Value\n\t\tcase propLogoURL:\n\t\t\tinfo.LogoURL = prop.Value\n\t\tcase propLocation:\n\t\t\tinfo.Location = prop.Value\n\t\tcase propTimezone:\n\t\t\tinfo.Timezone = prop.Value\n\t\tcase propLinks:\n\t\t\tinfo.Links = prop.Value\n\t\tcase propTheme:\n\t\t\tinfo.Theme = prop.Value\n\t\t}\n\t}\n\n\treturn info, nil\n}\n\nfunc (s *Service) EditInfo(editedInfo *Info) (*Info, error) {\n\tcurrentInfo, err := s.Info()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif currentInfo.Name != editedInfo.Name {\n\t\tif _, err := s.store.UpsertStationProperty(propName, editedInfo.Name); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif currentInfo.Description != editedInfo.Description {\n\t\tif _, err := s.store.UpsertStationProperty(propDescription, editedInfo.Description); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif currentInfo.FaviconURL != editedInfo.FaviconURL {\n\t\tif _, err := s.store.UpsertStationProperty(propFaviconURL, editedInfo.FaviconURL); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif currentInfo.LogoURL != editedInfo.LogoURL {\n\t\tif _, err := s.store.UpsertStationProperty(propLogoURL, editedInfo.LogoURL); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif currentInfo.Location != editedInfo.Location {\n\t\tif _, err := s.store.UpsertStationProperty(propLocation, editedInfo.Location); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif currentInfo.Timezone != editedInfo.Timezone {\n\t\tif _, err := s.store.UpsertStationProperty(propTimezone, editedInfo.Timezone); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif currentInfo.Links != editedInfo.Links {\n\t\tif _, err := s.store.UpsertStationProperty(propLinks, editedInfo.Links); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif currentInfo.Theme != editedInfo.Theme {\n\t\tif _, err := s.store.UpsertStationProperty(propTheme, editedInfo.Theme); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tfreshInfo, err := s.Info()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn freshInfo, nil\n}\n"
  },
  {
    "path": "internal/station/types.go",
    "content": "package station\n\ntype Info struct {\n\tName        string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tFaviconURL  string `json:\"faviconURL\"`\n\tLogoURL     string `json:\"logoURL\"`\n\tLocation    string `json:\"location\"`\n\tTimezone    string `json:\"timezone\"`\n\tLinks       string `json:\"links\"`\n\tTheme       string `json:\"theme\"`\n}\n\ntype Property struct {\n\tKey   string\n\tValue string\n}\n\ntype Store interface {\n\tStationProperties() ([]*Property, error)\n\tUpsertStationProperty(key, value string) (*Property, error)\n\tDeleteStationProperty(key string) error\n}\n"
  },
  {
    "path": "internal/storage/sqlite/migrations/migrations.go",
    "content": "package migrations\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n)\n\ntype Migration struct {\n\tVersion int\n\tName    string\n\tUp      func(*sql.Tx) error\n\tDown    func(*sql.Tx) error\n}\n\nvar migrations = []Migration{\n\t{\n\t\tVersion: 1,\n\t\tName:    \"create_main_tables\",\n\t\tUp: func(tx *sql.Tx) error {\n\t\t\tqueries := []string{\n\t\t\t\t`CREATE TABLE IF NOT EXISTS migrations (\n\t\t\t\t    version INTEGER PRIMARY KEY,\n\t\t\t\t    name TEXT NOT NULL,\n\t\t\t\t    applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))\n\t\t\t\t);`,\n\t\t\t\t`CREATE TABLE IF NOT EXISTS tracks (\n                    id TEXT PRIMARY KEY,\n                    name TEXT NOT NULL UNIQUE,\n                    path TEXT NOT NULL,\n                    duration REAL NOT NULL,\n                    bitRate INTEGER NOT NULL\n                );`,\n\t\t\t\t`CREATE TABLE IF NOT EXISTS queue (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    track_id TEXT NOT NULL UNIQUE,\n                    FOREIGN KEY (track_id) REFERENCES tracks (id)\n                );`,\n\t\t\t\t`CREATE TABLE IF NOT EXISTS playback_history (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    played_at INTEGER NOT NULL,\n                    track_name TEXT NOT NULL\n                );`,\n\t\t\t\t`CREATE TABLE IF NOT EXISTS playlist (\n                    id TEXT PRIMARY KEY,\n                    name TEXT NOT NULL UNIQUE,\n                    description TEXT\n                );`,\n\t\t\t\t`CREATE TABLE IF NOT EXISTS playlist_track (\n                    playlist_id TEXT NOT NULL,\n                    track_id TEXT NOT NULL,\n                    position INTEGER NOT NULL,\n                    FOREIGN KEY (playlist_id) REFERENCES playlist (id) ON DELETE CASCADE,\n                    FOREIGN KEY (track_id) REFERENCES tracks (id),\n                    PRIMARY KEY (playlist_id, position),\n                    UNIQUE (playlist_id, track_id)\n                );`,\n\t\t\t\t`CREATE TABLE IF NOT EXISTS station_properties (\n                    key VARCHAR(100) PRIMARY KEY,\n                    value TEXT,\n                    created_at INTEGER DEFAULT (strftime('%s', 'now')),\n                    updated_at INTEGER DEFAULT (strftime('%s', 'now'))\n                );`,\n\t\t\t}\n\n\t\t\tfor _, query := range queries {\n\t\t\t\tif _, err := tx.Exec(query); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to execute query: %w, query: %s\", err, query)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t},\n\t{\n\t\tVersion: 2,\n\t\tName:    \"create_main_indexes\",\n\t\tUp: func(tx *sql.Tx) error {\n\t\t\tindexes := []string{\n\t\t\t\t`CREATE INDEX IF NOT EXISTS idx_tracks_name ON tracks (name COLLATE NOCASE);`,\n\t\t\t\t`CREATE INDEX IF NOT EXISTS idx_playback_history_played_at ON playback_history(played_at);`,\n\t\t\t\t`CREATE INDEX IF NOT EXISTS idx_playlist_track_ids ON playlist_track (playlist_id, track_id);`,\n\t\t\t}\n\n\t\t\tfor _, query := range indexes {\n\t\t\t\tif _, err := tx.Exec(query); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to create index: %w, query: %s\", err, query)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t},\n}\n"
  },
  {
    "path": "internal/storage/sqlite/migrations/runner.go",
    "content": "package migrations\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log/slog\"\n\n\tsqltool \"github.com/cheatsnake/airstation/internal/pkg/sql\"\n)\n\nfunc RunMigrations(db *sql.DB, log *slog.Logger) error {\n\tmigrationTableExists, err := sqltool.TableExists(db, \"migrations\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar currentVersion int\n\n\tif migrationTableExists {\n\t\terr = db.QueryRow(\"SELECT COALESCE(MAX(version), 0) FROM migrations\").Scan(&currentVersion)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get current version: %w\", err)\n\t\t}\n\t} else {\n\t\tcurrentVersion = 0\n\t\tlog.Info(\"Fresh database, starting migrations from the beginning\")\n\t}\n\n\tfor _, migration := range migrations {\n\t\tif migration.Version > currentVersion {\n\t\t\tlog.Info(\"Applying migration\", \"version\", migration.Version, \"name\", migration.Name)\n\n\t\t\ttx, err := db.Begin()\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to begin transaction for migration %d: %w\",\n\t\t\t\t\tmigration.Version, err)\n\t\t\t}\n\n\t\t\tdefer func() {\n\t\t\t\tif tx != nil {\n\t\t\t\t\ttx.Rollback()\n\t\t\t\t}\n\t\t\t}()\n\n\t\t\tif err := migration.Up(tx); err != nil {\n\t\t\t\treturn fmt.Errorf(\"migration %d (%s) failed: %w\",\n\t\t\t\t\tmigration.Version, migration.Name, err)\n\t\t\t}\n\n\t\t\tif migrationTableExists || migration.Version >= 1 {\n\t\t\t\t_, err = tx.Exec(\n\t\t\t\t\t\"INSERT INTO migrations (version, name) VALUES (?, ?)\",\n\t\t\t\t\tmigration.Version, migration.Name,\n\t\t\t\t)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to record migration %d: %w\", migration.Version, err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif err := tx.Commit(); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to commit migration %d: %w\", migration.Version, err)\n\t\t\t}\n\n\t\t\ttx = nil // Remove defer rollback after successful commit\n\n\t\t\tlog.Info(\"Migration applied successfully\",\n\t\t\t\t\"version\", migration.Version,\n\t\t\t\t\"name\", migration.Name)\n\t\t}\n\t}\n\n\tvar finalVersion int\n\n\terr = db.QueryRow(\"SELECT COALESCE(MAX(version), 0) FROM migrations\").Scan(&finalVersion)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get final version: %w\", err)\n\t}\n\n\tif finalVersion > currentVersion {\n\t\tlog.Info(\"Database migration completed\",\n\t\t\t\"from_version\", currentVersion,\n\t\t\t\"to_version\", finalVersion,\n\t\t\t\"migrations_applied\", finalVersion-currentVersion)\n\t} else {\n\t\tlog.Info(\"Database is up to date\", \"version\", finalVersion)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/storage/sqlite/playback.go",
    "content": "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 PlaybackStore struct {\n\tdb    *sql.DB\n\tmutex *sync.Mutex\n}\n\nfunc NewPlaybackStore(db *sql.DB, mutex *sync.Mutex) PlaybackStore {\n\treturn PlaybackStore{\n\t\tdb:    db,\n\t\tmutex: mutex,\n\t}\n}\n\nfunc (ps *PlaybackStore) AddPlaybackHistory(playedAt int64, trackName string) error {\n\tps.mutex.Lock()\n\tdefer ps.mutex.Unlock()\n\n\tquery := `INSERT INTO playback_history (played_at, track_name) VALUES (?, ?)`\n\n\t_, err := ps.db.Exec(query, playedAt, trackName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to insert playback entry: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (ps *PlaybackStore) RecentPlaybackHistory(limit int) ([]*playback.History, error) {\n\tps.mutex.Lock()\n\tdefer ps.mutex.Unlock()\n\n\tquery := `\n\t\tSELECT id, played_at, track_name \n\t\tFROM playback_history \n\t\tORDER BY played_at DESC`\n\n\tquery += fmt.Sprintf(\" LIMIT %d\", limit)\n\n\trows, err := ps.db.Query(query)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar history []*playback.History\n\tfor rows.Next() {\n\t\tvar item playback.History\n\t\tif err := rows.Scan(&item.ID, &item.PlayedAt, &item.TrackName); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thistory = append(history, &item)\n\t}\n\treturn history, nil\n}\n\nfunc (ps *PlaybackStore) DeleteOldPlaybackHistory() (int64, error) {\n\tps.mutex.Lock()\n\tdefer ps.mutex.Unlock()\n\n\tquery := `\n\t\tDELETE FROM playback_history \n\t\tWHERE played_at < (strftime('%s', 'now') - 30 * 24 * 60 * 60)`\n\n\tresult, err := ps.db.Exec(query)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to delete old entries: %v\", err)\n\t}\n\n\trowsAffected, err := result.RowsAffected()\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to get rows affected: %v\", err)\n\t}\n\n\treturn rowsAffected, nil\n}\n"
  },
  {
    "path": "internal/storage/sqlite/playlist.go",
    "content": "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.com/cheatsnake/airstation/internal/playlist\"\n\t\"github.com/cheatsnake/airstation/internal/track\"\n)\n\ntype PlaylistStore struct {\n\tdb    *sql.DB\n\tmutex *sync.Mutex\n}\n\nfunc NewPlaylistStore(db *sql.DB, mutex *sync.Mutex) PlaylistStore {\n\treturn PlaylistStore{\n\t\tdb:    db,\n\t\tmutex: mutex,\n\t}\n}\n\n// AddPlaylist inserts a new playlist and associates tracks\nfunc (ps *PlaylistStore) AddPlaylist(name, description string, trackIDs []string) (*playlist.Playlist, error) {\n\tid := ulid.New()\n\n\ttx, err := ps.db.Begin()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer tx.Rollback()\n\n\t_, err = tx.Exec(`INSERT INTO playlist (id, name, description) VALUES (?, ?, ?)`, id, name, description)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, trackID := range trackIDs {\n\t\t_, err = tx.Exec(`INSERT OR IGNORE INTO playlist_track (playlist_id, track_id) VALUES (?, ?)`, id, trackID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif err := tx.Commit(); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ps.Playlist(id)\n}\n\n// Playlists returns all playlists without tracks\nfunc (ps *PlaylistStore) Playlists() ([]*playlist.Playlist, error) {\n\tquery := `\n\t\tSELECT p.id, p.name, p.description, COUNT(pt.track_id) as track_count\n\t\tFROM playlist p\n\t\tLEFT JOIN playlist_track pt ON p.id = pt.playlist_id\n\t\tGROUP BY p.id\n\t`\n\n\trows, err := ps.db.Query(query)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tplaylists := make([]*playlist.Playlist, 0)\n\n\tfor rows.Next() {\n\t\tvar p playlist.Playlist\n\t\tif err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.TrackCount); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tp.Tracks = []*track.Track{}\n\t\tplaylists = append(playlists, &p)\n\t}\n\n\treturn playlists, nil\n}\n\n// Playlist returns a playlist with all its tracks\nfunc (ps *PlaylistStore) Playlist(id string) (*playlist.Playlist, error) {\n\tp := playlist.Playlist{Tracks: make([]*track.Track, 0)}\n\n\terr := ps.db.QueryRow(`SELECT id, name, description FROM playlist WHERE id = ?`, id).\n\t\tScan(&p.ID, &p.Name, &p.Description)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trows, err := ps.db.Query(`\n\t\tSELECT t.id, t.name, t.path, t.bitRate, t.duration\n\t\tFROM playlist_track pt\n\t\tJOIN tracks t ON pt.track_id = t.id\n\t\tWHERE pt.playlist_id = ?\n\t\tORDER BY pt.position\n\t`, id)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to query playlist tracks: %w\", err)\n\t}\n\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar t track.Track\n\t\tif err := rows.Scan(&t.ID, &t.Name, &t.Path, &t.BitRate, &t.Duration); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to scan track: %w\", err)\n\t\t}\n\t\tp.Tracks = append(p.Tracks, &t)\n\t}\n\n\tp.TrackCount = len(p.Tracks)\n\n\treturn &p, nil\n}\n\n// IsPlaylistExists checks if playlist with provided name exists\nfunc (ps *PlaylistStore) IsPlaylistExists(name string) (bool, error) {\n\tvar exists bool\n\n\terr := ps.db.QueryRow(`\n        SELECT EXISTS(\n            SELECT 1 FROM playlist WHERE name = ?\n        )\n    `, name).Scan(&exists)\n\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\treturn exists, nil\n}\n\n// EditPlaylist updates playlist and its tracks\nfunc (ps *PlaylistStore) EditPlaylist(id, name, description string, trackIDs []string) error {\n\ttx, err := ps.db.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer tx.Rollback()\n\n\t_, err = tx.Exec(`UPDATE playlist SET name = ?, description = ? WHERE id = ?`, name, description, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = tx.Exec(`DELETE FROM playlist_track WHERE playlist_id = ?`, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor position, trackID := range trackIDs {\n\t\t_, err = tx.Exec(\n\t\t\t`INSERT OR IGNORE INTO playlist_track (playlist_id, track_id, position) VALUES (?, ?, ?)`,\n\t\t\tid, trackID, position,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn tx.Commit()\n}\n\n// DeletePlaylist deletes playlist and its track associations\nfunc (ps *PlaylistStore) DeletePlaylist(id string) error {\n\ttx, err := ps.db.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer tx.Rollback()\n\n\t_, err = tx.Exec(`DELETE FROM playlist_track WHERE playlist_id = ?`, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = tx.Exec(`DELETE FROM playlist WHERE id = ?`, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n"
  },
  {
    "path": "internal/storage/sqlite/queue.go",
    "content": "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\ntype QueueStore struct {\n\tdb    *sql.DB\n\tmutex *sync.Mutex\n}\n\nfunc NewQueueStore(db *sql.DB, mutex *sync.Mutex) QueueStore {\n\treturn QueueStore{\n\t\tdb:    db,\n\t\tmutex: mutex,\n\t}\n}\n\nfunc (qs *QueueStore) Queue() ([]*track.Track, error) {\n\tqs.mutex.Lock()\n\tdefer qs.mutex.Unlock()\n\n\ttracks := make([]*track.Track, 0, 10)\n\n\tquery := `\n\t\tSELECT t.id, t.name, t.path, t.duration, t.bitRate\n\t\tFROM tracks t\n\t\tJOIN queue q ON t.id = q.track_id\n\t\tORDER BY q.id ASC`\n\trows, err := qs.db.Query(query)\n\tif err != nil {\n\t\treturn tracks, fmt.Errorf(\"failed to query tracks in queue: %w\", err)\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar track track.Track\n\t\terr := rows.Scan(&track.ID, &track.Name, &track.Path, &track.Duration, &track.BitRate)\n\t\tif err != nil {\n\t\t\treturn tracks, fmt.Errorf(\"failed to scan track: %w\", err)\n\t\t}\n\t\ttracks = append(tracks, &track)\n\t}\n\n\tif err = rows.Err(); err != nil {\n\t\treturn tracks, fmt.Errorf(\"error iterating over rows: %w\", err)\n\t}\n\n\treturn tracks, nil\n}\n\nfunc (qs *QueueStore) AddToQueue(tracks []*track.Track) error {\n\tqs.mutex.Lock()\n\tdefer qs.mutex.Unlock()\n\n\tquery := `\n\t\t\tINSERT INTO queue (track_id)\n\t\t\tVALUES (?)\n\t\t\tON CONFLICT (track_id) DO NOTHING\n\t\t`\n\n\tfor _, track := range tracks {\n\t\t_, err := qs.db.Exec(query, track.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to add track to queue: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qs *QueueStore) RemoveFromQueue(trackIDs []string) error {\n\tqs.mutex.Lock()\n\tdefer qs.mutex.Unlock()\n\n\tquery := `DELETE FROM queue WHERE track_id = ?`\n\tfor _, id := range trackIDs {\n\t\t_, err := qs.db.Exec(query, id)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to remove track from queue: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qs *QueueStore) ReorderQueue(trackIDs []string) error {\n\tqs.mutex.Lock()\n\tdefer qs.mutex.Unlock()\n\n\t_, err := qs.db.Exec(`DELETE FROM queue`)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to clear queue: %w\", err)\n\t}\n\n\tquery := `INSERT INTO queue (track_id) VALUES (?)`\n\tfor _, id := range trackIDs {\n\t\t_, err := qs.db.Exec(query, id)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to reorder queue: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qs *QueueStore) CurrentAndNextTrack() (*track.Track, *track.Track, error) {\n\tqs.mutex.Lock()\n\tdefer qs.mutex.Unlock()\n\n\tquery := `\n\tSELECT t.id, t.name, t.path, t.duration, t.bitRate\n\tFROM tracks t\n\tJOIN queue q ON t.id = q.track_id\n\tORDER BY q.id ASC\n\tLIMIT 2`\n\trows, err := qs.db.Query(query)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to query first and second tracks: %w\", err)\n\t}\n\tdefer rows.Close()\n\n\tvar firstTrack, secondTrack track.Track\n\tcount := 0\n\n\tfor rows.Next() {\n\t\tif count == 0 {\n\t\t\terr := rows.Scan(&firstTrack.ID, &firstTrack.Name, &firstTrack.Path, &firstTrack.Duration, &firstTrack.BitRate)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, nil, fmt.Errorf(\"failed to scan first track: %w\", err)\n\t\t\t}\n\t\t} else if count == 1 {\n\t\t\terr := rows.Scan(&secondTrack.ID, &secondTrack.Name, &secondTrack.Path, &secondTrack.Duration, &secondTrack.BitRate)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, nil, fmt.Errorf(\"failed to scan second track: %w\", err)\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\n\tif err = rows.Err(); err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"error iterating over rows: %w\", err)\n\t}\n\n\tif count == 0 {\n\t\treturn nil, nil, nil\n\t} else if count == 1 {\n\t\treturn &firstTrack, &firstTrack, nil\n\t}\n\n\treturn &firstTrack, &secondTrack, nil\n}\n\nfunc (qs *QueueStore) SpinQueue() error {\n\tqs.mutex.Lock()\n\tdefer qs.mutex.Unlock()\n\n\ttx, err := qs.db.Begin()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to begin transaction: %w\", err)\n\t}\n\tdefer tx.Rollback()\n\n\tvar firstTrackID string\n\tvar firstTrackQueueID int\n\n\tquery := `SELECT id, track_id FROM queue ORDER BY id ASC LIMIT 1`\n\terr = tx.QueryRow(query).Scan(&firstTrackQueueID, &firstTrackID)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil // Queue is empty\n\t\t}\n\t\treturn fmt.Errorf(\"failed to get first track: %w\", err)\n\t}\n\n\tvar maxID int\n\n\terr = tx.QueryRow(`SELECT MAX(id) FROM queue`).Scan(&maxID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get max ID: %w\", err)\n\t}\n\n\tquery = `UPDATE queue SET id = ? WHERE id = ?`\n\t_, err = tx.Exec(query, maxID+1, firstTrackQueueID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to update first track ID: %w\", err)\n\t}\n\n\terr = tx.Commit()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to commit transaction: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/storage/sqlite/sqlite.go",
    "content": "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/sqlite/migrations\"\n\t_ \"modernc.org/sqlite\"\n)\n\ntype Instance struct {\n\tTrackStore\n\tQueueStore\n\tPlaybackStore\n\tPlaylistStore\n\tStationStore\n\n\tdb    *sql.DB\n\tlog   *slog.Logger\n\tmutex sync.Mutex\n}\n\nfunc New(dbPath string, log *slog.Logger) (*Instance, error) {\n\tdb, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to open database: %w\", err)\n\t}\n\n\t_, err = db.Exec(\"PRAGMA foreign_keys = ON\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to enable foreign keys: %w\", err)\n\t}\n\n\tdb.SetMaxOpenConns(1)\n\t_, _ = db.Exec(\"PRAGMA journal_mode = WAL\")\n\t_, _ = db.Exec(\"PRAGMA synchronous = NORMAL\")\n\n\tlog.Info(\"Sqlite database connected\")\n\n\terr = migrations.RunMigrations(db, log)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to run migrations: %w\", err)\n\t}\n\n\tinstance := &Instance{\n\t\tdb:  db,\n\t\tlog: log,\n\t}\n\n\tinstance.TrackStore = NewTrackStore(db, &instance.mutex)\n\tinstance.QueueStore = NewQueueStore(db, &instance.mutex)\n\tinstance.PlaybackStore = NewPlaybackStore(db, &instance.mutex)\n\tinstance.PlaylistStore = NewPlaylistStore(db, &instance.mutex)\n\tinstance.StationStore = NewStationStore(db, &instance.mutex)\n\n\treturn instance, nil\n}\n\nfunc (ins *Instance) Close() error {\n\tins.mutex.Lock()\n\n\tif _, err := ins.db.Exec(\"PRAGMA wal_checkpoint(TRUNCATE)\"); err != nil {\n\t\tins.mutex.Unlock()\n\t\treturn fmt.Errorf(\"failed to run wal checkpoint: %w\", err)\n\t}\n\n\terr := ins.db.Close()\n\tins.mutex.Unlock()\n\n\treturn err\n}\n"
  },
  {
    "path": "internal/storage/sqlite/station.go",
    "content": "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 StationStore struct {\n\tdb    *sql.DB\n\tmutex *sync.Mutex\n}\n\nfunc NewStationStore(db *sql.DB, mutex *sync.Mutex) StationStore {\n\treturn StationStore{\n\t\tdb:    db,\n\t\tmutex: mutex,\n\t}\n}\n\nfunc (ss *StationStore) StationProperties() ([]*station.Property, error) {\n\tquery := `\n\t\tSELECT key, value\n\t\tFROM station_properties\n\t\tORDER BY key\n\t`\n\n\trows, err := ss.db.Query(query)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar properties []*station.Property\n\tfor rows.Next() {\n\t\tvar prop station.Property\n\t\tif err := rows.Scan(&prop.Key, &prop.Value); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tproperties = append(properties, &prop)\n\t}\n\n\tif err = rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn properties, nil\n}\n\nfunc (ss *StationStore) UpsertStationProperty(key, value string) (*station.Property, error) {\n\tif key == \"\" {\n\t\treturn nil, errors.New(\"key cannot be empty\")\n\t}\n\n\tquery := `\n\t\tINSERT INTO station_properties (key, value)\n\t\tVALUES (?, ?)\n\t\tON CONFLICT(key) DO UPDATE SET\n\t\t\tvalue = excluded.value,\n\t\t\tupdated_at = strftime('%s', 'now')\n\t`\n\n\t_, err := ss.db.Exec(query, key, value)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &station.Property{Key: key, Value: value}, nil\n}\n\nfunc (ss *StationStore) DeleteStationProperty(key string) error {\n\tif key == \"\" {\n\t\treturn errors.New(\"key cannot be empty\")\n\t}\n\n\tquery := \"DELETE FROM station_properties WHERE key = ?\"\n\n\tresult, err := ss.db.Exec(query, key)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trowsAffected, err := result.RowsAffected()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif rowsAffected == 0 {\n\t\treturn errors.New(\"property not found\")\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/storage/sqlite/track.go",
    "content": "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/internal/pkg/sql\"\n\t\"github.com/cheatsnake/airstation/internal/pkg/ulid\"\n\t\"github.com/cheatsnake/airstation/internal/track\"\n)\n\ntype TrackStore struct {\n\tdb    *sql.DB\n\tmutex *sync.Mutex\n}\n\nfunc NewTrackStore(db *sql.DB, mutex *sync.Mutex) TrackStore {\n\treturn TrackStore{\n\t\tdb:    db,\n\t\tmutex: mutex,\n\t}\n}\n\nfunc (ts *TrackStore) Tracks(page, limit int, search, sortBy, sortOrder string) ([]*track.Track, int, error) {\n\tts.mutex.Lock()\n\tdefer ts.mutex.Unlock()\n\n\tcountQuery := \"SELECT COUNT(*) FROM tracks\"\n\tif search != \"\" {\n\t\tcountQuery += \" WHERE LOWER(name) LIKE LOWER(?)\"\n\t}\n\n\tvar total int\n\tvar err error\n\ttracks := make([]*track.Track, 0, limit)\n\n\tif search != \"\" {\n\t\terr = ts.db.QueryRow(countQuery, \"%\"+search+\"%\").Scan(&total)\n\t} else {\n\t\terr = ts.db.QueryRow(countQuery).Scan(&total)\n\t}\n\tif err != nil {\n\t\treturn tracks, 0, fmt.Errorf(\"failed to get total track count: %w\", err)\n\t}\n\n\tquery := \"SELECT id, name, path, duration, bitRate FROM tracks\"\n\tif search != \"\" {\n\t\tquery += \" WHERE name LIKE ?\"\n\t}\n\tquery += fmt.Sprintf(\" ORDER BY %s %s LIMIT ? OFFSET ?\", sortBy, sortOrder)\n\n\tvar rows *sql.Rows\n\toffset := (page - 1) * limit\n\tif search != \"\" {\n\t\trows, err = ts.db.Query(query, \"%\"+strings.ToLower(search)+\"%\", limit, offset)\n\t} else {\n\t\trows, err = ts.db.Query(query, limit, offset)\n\t}\n\tif err != nil {\n\t\treturn tracks, 0, fmt.Errorf(\"failed to query tracks: %w\", err)\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar track track.Track\n\t\terr := rows.Scan(&track.ID, &track.Name, &track.Path, &track.Duration, &track.BitRate)\n\t\tif err != nil {\n\t\t\treturn tracks, 0, fmt.Errorf(\"failed to scan track: %w\", err)\n\t\t}\n\t\ttracks = append(tracks, &track)\n\t}\n\n\tif err = rows.Err(); err != nil {\n\t\treturn tracks, 0, fmt.Errorf(\"error iterating over rows: %w\", err)\n\t}\n\n\treturn tracks, total, nil\n}\n\nfunc (ts *TrackStore) AddTrack(name, path string, duration float64, bitRate int) (*track.Track, error) {\n\tts.mutex.Lock()\n\tdefer ts.mutex.Unlock()\n\n\tid := ulid.New()\n\ttrack := &track.Track{\n\t\tID:       id,\n\t\tName:     name,\n\t\tPath:     path,\n\t\tDuration: duration,\n\t\tBitRate:  bitRate,\n\t}\n\n\tquery := `INSERT INTO tracks (id, name, path, duration, bitRate) VALUES (?, ?, ?, ?, ?)`\n\t_, err := ts.db.Exec(query, track.ID, track.Name, track.Path, track.Duration, track.BitRate)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to insert track: %w\", err)\n\t}\n\n\treturn track, nil\n}\n\nfunc (ts *TrackStore) DeleteTracks(IDs []string) error {\n\tts.mutex.Lock()\n\tdefer ts.mutex.Unlock()\n\n\tquery := `DELETE FROM tracks WHERE id = ?`\n\tfor _, id := range IDs {\n\t\t_, err := ts.db.Exec(query, id)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to delete track with ID %s: %w\", id, err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (ts *TrackStore) EditTrack(track *track.Track) (*track.Track, error) {\n\tts.mutex.Lock()\n\tdefer ts.mutex.Unlock()\n\n\tquery := `\n\tUPDATE tracks\n\tSET name = ?,\n\t\tpath = ?,\n\t\tduration = ?,\n\t\tbitRate = ?\n\tWHERE id = ?`\n\t_, err := ts.db.Exec(query, track.Name, track.Path, track.Duration, track.BitRate, track.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to update track: %w\", err)\n\t}\n\n\treturn track, nil\n}\n\nfunc (ts *TrackStore) TrackByID(ID string) (*track.Track, error) {\n\tts.mutex.Lock()\n\tdefer ts.mutex.Unlock()\n\n\tquery := `SELECT id, name, path, duration, bitRate FROM tracks WHERE id = ?`\n\trow := ts.db.QueryRow(query, ID)\n\n\tvar track track.Track\n\terr := row.Scan(&track.ID, &track.Name, &track.Path, &track.Duration, &track.BitRate)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, fmt.Errorf(\"track with ID %s not found\", ID)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to scan track: %w\", err)\n\t}\n\n\treturn &track, nil\n}\n\nfunc (ts *TrackStore) TracksByIDs(IDs []string) ([]*track.Track, error) {\n\tts.mutex.Lock()\n\tdefer ts.mutex.Unlock()\n\n\ttracks := make([]*track.Track, 0, len(IDs))\n\n\twhereClause := sqltool.BuildInClause(\"id\", len(IDs))\n\tquery := fmt.Sprintf(\"SELECT id, name, path, duration, bitRate FROM tracks WHERE %s\", whereClause)\n\targs := make([]interface{}, len(IDs))\n\tfor i, id := range IDs {\n\t\targs[i] = id\n\t}\n\n\trows, err := ts.db.Query(query, args...)\n\tif err != nil {\n\t\treturn tracks, fmt.Errorf(\"failed to query tracks: %w\", err)\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar track track.Track\n\t\terr := rows.Scan(&track.ID, &track.Name, &track.Path, &track.Duration, &track.BitRate)\n\t\tif err != nil {\n\t\t\treturn tracks, fmt.Errorf(\"failed to scan track: %w\", err)\n\t\t}\n\t\ttracks = append(tracks, &track)\n\t}\n\n\tif err = rows.Err(); err != nil {\n\t\treturn tracks, fmt.Errorf(\"error iterating over rows: %w\", err)\n\t}\n\n\treturn tracks, nil\n}\n"
  },
  {
    "path": "internal/storage/storage.go",
    "content": "package storage\n\nimport (\n\t\"github.com/cheatsnake/airstation/internal/playback\"\n\t\"github.com/cheatsnake/airstation/internal/playlist\"\n\t\"github.com/cheatsnake/airstation/internal/queue\"\n\t\"github.com/cheatsnake/airstation/internal/station\"\n\t\"github.com/cheatsnake/airstation/internal/track\"\n)\n\ntype Storage interface {\n\ttrack.Store\n\tqueue.Store\n\tplayback.Store\n\tplaylist.Store\n\tstation.Store\n\n\tClose() error\n}\n"
  },
  {
    "path": "internal/track/consts.go",
    "content": "package track\n\nimport \"github.com/cheatsnake/airstation/internal/pkg/hls\"\n\nconst (\n\tminAllowedTrackDuration = hls.DefaultMaxSegmentDuration * hls.DefaultLiveSegmentsAmount\n\tmaxAllowedTrackDuration = 36000 // 10 hours (just an adequate barrier)\n\tdefaultAudioBitRate     = 192   // best balance between quallity and size\n)\n\nconst (\n\tm4aExtension  = \"m4a\"\n\tmp3Extension  = \"mp3\"\n\taacExtension  = \"aac\"\n\twavExtension  = \"wav\"\n\tflacExtension = \"flac\"\n)\n"
  },
  {
    "path": "internal/track/service.go",
    "content": "// Package trackservice provides services related to audio track management.\npackage track\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"math\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/cheatsnake/airstation/internal/pkg/ffmpeg\"\n\t\"github.com/cheatsnake/airstation/internal/pkg/fs\"\n\t\"github.com/cheatsnake/airstation/internal/pkg/hls\"\n)\n\n// Service provides audio processing functionalities by interacting with a database and the FFmpeg CLI.\ntype Service struct {\n\tstore     Store       // An instance of Storage for managing audio file storage.\n\tffmpegCLI *ffmpeg.CLI // A pointer to the FFmpeg CLI wrapper for executing media processing commands.\n\tlog       *slog.Logger\n\n\tLoadedTracksNotify chan int // Notification of the number of loaded tracks\n}\n\n// New creates and returns a new instance of Service.\n//\n// Parameters:\n//   - store: An implementation of TrackStore for managing audio file storage.\n//   - ffmpegCLI: A pointer to the FFmpeg CLI wrapper for executing media processing commands.\n//\n// Returns:\n//   - A pointer to an initialized Service instance.\nfunc NewService(store Store, ffmpegCLI *ffmpeg.CLI, log *slog.Logger) *Service {\n\treturn &Service{\n\t\tstore:     store,\n\t\tffmpegCLI: ffmpegCLI,\n\t\tlog:       log,\n\n\t\tLoadedTracksNotify: make(chan int),\n\t}\n}\n\n// AddTrack adds a new audio track to the database, extracting metadata and modifying its duration if necessary.\n//\n// Parameters:\n//   - name: The name to assign to the new track.\n//   - path: The file path of the audio track to be added.\n//\n// Returns:\n//   - A pointer to the newly added Track, or an error if any step in the process fails.\nfunc (s *Service) AddTrack(name, path string) (*Track, error) {\n\tmetadata, err := s.ffmpegCLI.AudioMetadata(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tmodDuration, err := s.modifyTrackDuration(path, metadata)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif modDuration < minAllowedTrackDuration {\n\t\treturn nil, fmt.Errorf(\"%s is too short for streaming\", name)\n\t}\n\n\tif modDuration > maxAllowedTrackDuration {\n\t\treturn nil, fmt.Errorf(\"%s is too long for streaming\", name)\n\t}\n\n\ttrackName := defineTrackName(name, metadata.Name)\n\tnewTrack, err := s.store.AddTrack(trackName, path, modDuration, metadata.BitRate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn newTrack, nil\n}\n\n// PrepareTrack converts the audio file at filePath to AAC format with a fixed bitrate,\n// saving the output to a new file with an .m4a extension.\n//\n// Parameters:\n//   - filePath: The full path of the original audio file.\n//\n// Returns:\n//   - The path to the converted .m4a file, or an error if the conversion fails.\nfunc (s *Service) PrepareTrack(filePath string) (string, error) {\n\tnewPath := replaceExtension(filePath, m4aExtension)\n\terr := s.ffmpegCLI.ConvertAudioToAAC(filePath, newPath, defaultAudioBitRate)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn newPath, nil\n}\n\n// Tracks retrieves a paginated list of tracks from the store, applying optional search, sort, and order.\n//\n// Parameters:\n//   - page: The page number of results.\n//   - limit: The number of results per page.\n//   - search: A string to filter track names.\n//   - sortBy: The field to sort by (id, name, or duration).\n//   - sortOrder: The order of sorting (asc or desc).\n//\n// Returns:\n//   - A TracksPage object with paginated track data, or an error.\nfunc (s *Service) Tracks(page, limit int, search, sortBy, sortOrder string) (*Page, error) {\n\tif sortBy != \"id\" && sortBy != \"name\" && sortBy != \"duration\" {\n\t\tsortBy = \"id\"\n\t}\n\n\tif sortOrder != \"asc\" {\n\t\tsortOrder = \"desc\"\n\t}\n\n\ttracks, total, err := s.store.Tracks(page, limit, search, sortBy, sortOrder)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &Page{\n\t\tTracks: tracks,\n\t\tPage:   page,\n\t\tLimit:  limit,\n\t\tTotal:  total,\n\t}, nil\n}\n\n// DeleteTracks deletes tracks from the database and also removes their files from disk.\n//\n// Parameters:\n//   - ids: A slice of strings contains track IDs.\n//\n// Returns:\n//   - An error if deletion fails.\nfunc (s *Service) DeleteTracks(ids []string) error {\n\ttracks, err := s.store.TracksByIDs(ids)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = s.store.DeleteTracks(ids)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, t := range tracks {\n\t\terr := fs.DeleteFile(t.Path)\n\t\tif err != nil {\n\t\t\ts.log.Warn(\"Failed to delete track from disk: \" + err.Error())\n\t\t}\n\t}\n\n\treturn err\n}\n\n// FindTracks fetches track records by their IDs.\n//\n// Parameters:\n//   - ids: A slice of strings contains track IDs.\n//\n// Returns:\n//   - A slice of Track pointers or an error.\nfunc (s *Service) FindTracks(ids []string) ([]*Track, error) {\n\ttracks, err := s.store.TracksByIDs(ids)\n\treturn tracks, err\n}\n\n// MakeHLSPlaylist generates an HLS playlist for streaming using FFmpeg.\n//\n// Parameters:\n//   - trackPath: The path of the audio track to segment.\n//   - outDir: Output directory for the HLS segments and playlist.\n//   - segName: Prefix for the segment files.\n//   - segDuration: Duration of each HLS segment in seconds.\n//\n// Returns:\n//   - An error if playlist generation fails.\nfunc (s *Service) MakeHLSPlaylist(trackPath string, outDir string, segName string, segDuration int) error {\n\terr := s.ffmpegCLI.MakeHLSPlaylist(trackPath, outDir, segName, segDuration)\n\treturn err\n}\n\n// LoadTracksFromDisk scans a directory for audio files, converts them if needed,\n// adds them to the store, and deletes the original copies.\n//\n// Parameters:\n//   - tracksDir: Directory path to load tracks from.\n//\n// Returns:\n//   - A slice of loaded Track pointers, or an error.\nfunc (s *Service) LoadTracksFromDisk(tracksDir string) ([]*Track, error) {\n\ttracks := make([]*Track, 0)\n\n\tmp3Filenames, err := fs.ListFilesFromDir(tracksDir, mp3Extension)\n\tif err != nil {\n\t\treturn tracks, err\n\t}\n\n\taacFilenames, err := fs.ListFilesFromDir(tracksDir, aacExtension)\n\tif err != nil {\n\t\treturn tracks, err\n\t}\n\n\twavFilenames, err := fs.ListFilesFromDir(tracksDir, wavExtension)\n\tif err != nil {\n\t\treturn tracks, err\n\t}\n\n\tflacFilenames, err := fs.ListFilesFromDir(tracksDir, flacExtension)\n\tif err != nil {\n\t\treturn tracks, err\n\t}\n\n\ttrackFilenames := make([]string, 0, len(mp3Filenames)+len(aacFilenames)+len(wavFilenames)+len(flacFilenames))\n\ttrackFilenames = append(trackFilenames, mp3Filenames...)\n\ttrackFilenames = append(trackFilenames, aacFilenames...)\n\ttrackFilenames = append(trackFilenames, wavFilenames...)\n\ttrackFilenames = append(trackFilenames, flacFilenames...)\n\n\tfor _, trackFilename := range trackFilenames {\n\t\ttrackPath := filepath.Join(tracksDir, trackFilename)\n\t\tpreparedTrackPath, err := s.PrepareTrack(trackPath)\n\t\tif err != nil {\n\t\t\ts.log.Warn(\"Failed to prepare a track for streaming: \" + err.Error())\n\t\t\treturn tracks, err\n\t\t}\n\n\t\ttrack, err := s.AddTrack(trackFilename, preparedTrackPath)\n\t\tif err != nil {\n\t\t\ts.log.Warn(\"Failed to save track to database: \" + err.Error())\n\t\t\treturn tracks, err\n\t\t}\n\n\t\terr = fs.DeleteFile(trackPath)\n\t\tif err != nil {\n\t\t\ts.log.Warn(\"Failed to delete original copy of prepared track: \" + err.Error())\n\t\t}\n\n\t\ttracks = append(tracks, track)\n\t}\n\n\tif len(tracks) > 0 {\n\t\ts.log.Info(fmt.Sprintf(\"Loaded %d new track(s) from disk.\", len(tracks)))\n\t\ts.LoadedTracksNotify <- len(tracks)\n\t}\n\n\treturn tracks, nil\n}\n\n// modifyTrackDuration changes the original track duration (slightly) to avoid small HLS segments.\nfunc (s *Service) modifyTrackDuration(path string, metadata ffmpeg.AudioMetadata) (float64, error) {\n\troundDur := roundDuration(metadata.Duration, hls.DefaultMaxSegmentDuration)\n\troundDur -= 0.001 // need to avoid extra ms after padding/trimming\n\n\tif err := s.ffmpegCLI.TrimAudio(path, roundDur); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn roundDur, nil\n}\n\n// roundDuration define proper track length to be multiple for segment duration.\nfunc roundDuration(trackDuration, segmentDuration float64) float64 {\n\tremainder := math.Mod(trackDuration, segmentDuration)\n\n\t// if the difference is not significant (less than 1.2 second), just crop it\n\tif remainder < 1.2 {\n\t\treturn math.Floor(trackDuration - remainder)\n\t}\n\n\t// padding := segmentDuration - remainder\n\t// return math.Floor(trackDuration + padding)\n\treturn math.Floor(trackDuration)\n}\n\nfunc defineTrackName(fileName, metaName string) string {\n\tif len(metaName) != 0 {\n\t\treturn metaName\n\t}\n\n\tname := strings.ReplaceAll(fileName, \".mp3\", \"\")\n\tname = strings.ReplaceAll(name, \".aac\", \"\")\n\tname = strings.ReplaceAll(name, \".wav\", \"\")\n\tname = strings.ReplaceAll(name, \".flac\", \"\")\n\tname = strings.ReplaceAll(name, \"_\", \" \")\n\n\treturn name\n}\n\nfunc replaceExtension(path string, newExt string) string {\n\tif newExt != \"\" && !strings.HasPrefix(newExt, \".\") {\n\t\tnewExt = \".\" + newExt\n\t}\n\n\text := filepath.Ext(path)\n\tname := path[:len(path)-len(ext)]\n\n\treturn name + newExt\n}\n"
  },
  {
    "path": "internal/track/types.go",
    "content": "package track\n\n// Track represents an audio track with its associated metadata.\ntype Track struct {\n\tID       string  `json:\"id\"`       // A unique identifier for the track, typically generated using ULID.\n\tName     string  `json:\"name\"`     // The name of the audio track.\n\tPath     string  `json:\"path\"`     // The file path of the audio track.\n\tDuration float64 `json:\"duration\"` // The duration of the audio track in seconds.\n\tBitRate  int     `json:\"bitRate\"`  // The bit rate of the audio track in kilobits per second (kbps).\n}\n\ntype Store interface {\n\tTracks(page, limit int, search, sortBy, sortOrder string) ([]*Track, int, error)\n\tTrackByID(ID string) (*Track, error)\n\tTracksByIDs(IDs []string) ([]*Track, error)\n\tAddTrack(name, path string, duration float64, bitRate int) (*Track, error)\n\tDeleteTracks(IDs []string) error\n\tEditTrack(track *Track) (*Track, error)\n}\n\n// Page represents a paginated response containing a list of audio tracks.\ntype Page struct {\n\tTracks []*Track `json:\"tracks\"` // A slice of Track pointers returned for the current page.\n\tPage   int      `json:\"page\"`   // The current page number in the pagination result.\n\tLimit  int      `json:\"limit\"`  // The maximum number of tracks per page.\n\tTotal  int      `json:\"total\"`  // The total number of tracks matching the query.\n}\n\ntype BodyWithIDs struct {\n\tIDs []string `json:\"ids\"`\n}\n"
  },
  {
    "path": "web/player/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\ndev-dist\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "web/player/.prettierrc",
    "content": "{\n    \"trailingComma\": \"all\",\n    \"tabWidth\": 4,\n    \"semi\": true,\n    \"singleQuote\": false,\n    \"printWidth\": 120\n}"
  },
  {
    "path": "web/player/README.md",
    "content": "# Airstation Player"
  },
  {
    "path": "web/player/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\" />\n        <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n        <meta name=\"msapplication-TileColor\" content=\"#29323c\" />\n        <meta name=\"theme-color\" content=\"#29323c\" />\n        <title>%AIRSTATION_PLAYER_TITLE%</title>\n    </head>\n    <body>\n        <div id=\"root\"></div>\n        <script type=\"module\" src=\"/src/index.tsx\"></script>\n    </body>\n</html>\n"
  },
  {
    "path": "web/player/package.json",
    "content": "{\n    \"name\": \"player\",\n    \"private\": true,\n    \"version\": \"0.0.0\",\n    \"type\": \"module\",\n    \"scripts\": {\n        \"dev\": \"vite\",\n        \"build\": \"tsc -b && vite build\",\n        \"preview\": \"vite preview\"\n    },\n    \"dependencies\": {\n        \"hls.js\": \"^1.6.15\",\n        \"solid-js\": \"^1.9.12\"\n    },\n    \"devDependencies\": {\n        \"@types/node\": \"^22.15.29\",\n        \"typescript\": \"^6.0.2\",\n        \"vite\": \"^8.0.3\",\n        \"vite-plugin-pwa\": \"^1.2.0\",\n        \"vite-plugin-solid\": \"^2.11.11\"\n    },\n    \"overrides\": {\n        \"vite-plugin-pwa\": {\n            \"vite\": \"$vite\"\n        }\n    }\n}\n"
  },
  {
    "path": "web/player/src/App.tsx",
    "content": "import { Page } from \"./page\";\n\nconst App = () => {\n    return <Page />;\n};\n\nexport default App;\n"
  },
  {
    "path": "web/player/src/api/index.ts",
    "content": "import { PlaybackHistory, PlaybackState, ResponseErr, StationInfo } from \"./types\";\nimport { queryParams } from \"./utils\";\n\nexport const API_HOST = \"\";\nexport const API_PREFIX = \"/api/v1\";\n\nclass AirstationAPI {\n    private host: string;\n    private prefix: string;\n    private url: () => string;\n\n    constructor(host: string, prefix: string) {\n        this.host = host;\n        this.prefix = prefix;\n        this.url = () => `${this.host + this.prefix}`;\n    }\n\n    async getPlayback() {\n        const url = `${this.url()}/playback`;\n        return await this.makeRequest<PlaybackState>(url);\n    }\n\n    async getPlaybackHistory(limit?: number) {\n        let url = `${this.url()}/playback/history`;\n        if (limit) url += `?${queryParams({ limit })}`;\n        return await this.makeRequest<PlaybackHistory[]>(url);\n    }\n\n    async getStationInfo() {\n        const url = `${this.url()}/station/info`;\n        return await this.makeRequest<StationInfo>(url);\n    }\n\n    private async makeRequest<T>(url: string, params: RequestInit = {}): Promise<T> {\n        const resp = await fetch(url, params);\n        if (!resp.ok) {\n            const body: ResponseErr = await resp.json();\n            throw new Error(body.message);\n        }\n\n        return resp.json();\n    }\n}\n\nexport const airstationAPI = new AirstationAPI(API_HOST, API_PREFIX);\n"
  },
  {
    "path": "web/player/src/api/types.ts",
    "content": "export interface Track {\n    id: string;\n    name: string;\n    path: string;\n    duration: number;\n    bitRate: number;\n}\n\nexport interface PlaybackState {\n    currentTrack: Track | null;\n    currentTrackElapsed: number;\n    isPlaying: boolean;\n}\n\nexport interface PlaybackHistory {\n    id: number;\n    playedAt: number;\n    trackName: string;\n}\n\nexport interface StationInfo {\n    name: string;\n    description: string;\n    faviconURL: string;\n    logoURL: string;\n    location: string;\n    timezone: string;\n    links: string;\n    theme: string;\n}\n\nexport interface ResponseErr {\n    message: string;\n}\n\nexport interface ResponseOK {\n    message: string;\n}\n"
  },
  {
    "path": "web/player/src/api/utils.ts",
    "content": "export const jsonRequestParams = (method: string, body: Record<string, any>) => {\n    return {\n        method,\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify(body),\n    };\n};\n\nexport const queryParams = (params: Record<string, any>) => {\n    removeEmptyFields(params);\n    return new URLSearchParams(params).toString();\n};\n\nconst removeEmptyFields = (obj: Record<string, any>) => {\n    for (const key in obj) {\n        if (obj.hasOwnProperty(key) && [undefined, null, \"\"].includes(obj[key])) {\n            delete obj[key];\n        }\n    }\n};\n"
  },
  {
    "path": "web/player/src/const.ts",
    "content": "export const DESKTOP_WIDTH = 1100;\nexport const MAX_HISTORY_LIMIT = 500;\n"
  },
  {
    "path": "web/player/src/index.css",
    "content": ":root {\n    --bg-gradient-start: #29323c;\n    --bg-gradient-end: #485563;\n    --bg-icon: #a8a8a8;\n    --text-color: #ffffff;\n\n    --text-dimmed-color: color-mix(in srgb, var(--text-color) 70%, transparent);\n    --sidebar-color: color-mix(in srgb, var(--bg-gradient-start) 80%, black 20%);\n    --scrollbar-color: color-mix(in srgb, var(--bg-gradient-end) 90%, black 10%);\n}\n\n* {\n    font-family: \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n    margin: 0;\n    padding: 0;\n    box-sizing: border-box;\n    color: var(--text-color);\n}\n\n::-webkit-scrollbar {\n    width: 8px;\n}\n\n::-webkit-scrollbar-track {\n    background: color-mix(in srgb, var(--bg-gradient-start) 80%, black 20%);\n}\n\n::-webkit-scrollbar-thumb {\n    background: var(--scrollbar-color);\n}\n\n::-webkit-scrollbar-thumb:hover {\n    background: var(--scrollbar-color);\n}\n\nbody {\n    background: var(--bg-gradient-end);\n    background: -webkit-linear-gradient(to top, var(--bg-gradient-start), var(--bg-gradient-end));\n    background: linear-gradient(to top, var(--bg-gradient-start), var(--bg-gradient-end));\n    background-size: cover;\n    background-position: center;\n    background-repeat: no-repeat;\n    overflow: hidden;\n}\n\n.empty_icon {\n    width: 24px;\n    height: 24px;\n}\n"
  },
  {
    "path": "web/player/src/index.tsx",
    "content": "/* @refresh reload */\nimport { render } from 'solid-js/web'\nimport './index.css'\nimport App from './App.tsx'\n\nconst root = document.getElementById('root')\n\nrender(() => <App />, root!)\n"
  },
  {
    "path": "web/player/src/page/CurrentTrack.module.css",
    "content": ".box {\n    width: 100%;\n}\n\n.label,\n.offline_label {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    gap: 0.5rem;\n    font-size: 0.9rem;\n    cursor: pointer;\n    padding: 1rem 0.5rem;\n}\n\n.offline_label {\n    user-select: none;\n    cursor: default;\n    color: var(--text-dimmed-color);\n}\n\n.offline_label_icon {\n    width: 10px;\n    height: 10px;\n    border-radius: 50%;\n    background: rgb(240, 78, 78);\n}\n"
  },
  {
    "path": "web/player/src/page/CurrentTrack.tsx",
    "content": "import { onMount, Show } from \"solid-js\";\nimport { airstationAPI } from \"../api\";\nimport styles from \"./CurrentTrack.module.css\";\nimport { addEventListener, EVENTS } from \"../store/events\";\nimport { setTrackStore, trackStore } from \"../store/track\";\nimport { addHistory } from \"../store/history\";\nimport { getUnixTime } from \"../utils/date\";\n\nexport const CurrentTrack = () => {\n    onMount(async () => {\n        try {\n            const cs = await airstationAPI.getPlayback();\n            if (cs.isPlaying && cs.currentTrack) setTrackStore(\"trackName\", cs.currentTrack.name);\n        } catch (error) {\n            console.log(error);\n        }\n\n        addEventListener(EVENTS.newTrack, (e: MessageEvent<string>) => {\n            const unixTime = getUnixTime();\n            setTrackStore(\"trackName\", e.data);\n            addHistory({ id: unixTime, playedAt: unixTime, trackName: e.data });\n        });\n    });\n\n    const copyToClipboard = async () => {\n        try {\n            await navigator.clipboard.writeText(trackStore.trackName);\n        } catch (error) {\n            console.log(error);\n        }\n    };\n\n    return (\n        <div class={styles.box}>\n            <Show when={trackStore.trackName.length > 0} fallback={<OfflineLabel />}>\n                <div onClick={copyToClipboard} class={styles.label}>\n                    {trackStore.trackName}\n                </div>\n            </Show>\n        </div>\n    );\n};\n\nconst OfflineLabel = () => {\n    return (\n        <div class={styles.offline_label}>\n            <div class={styles.offline_label_icon}></div>\n            <div class={styles.offline_label_title}>Stream offline</div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "web/player/src/page/History.module.css",
    "content": ".history_icon {\n    width: 24px;\n    height: 24px;\n    cursor: pointer;\n    background-color: var(--bg-icon);\n    -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>');\n    mask: -webkit-mask;\n}\n\n.history_menu {\n    position: fixed;\n    top: 0;\n    left: -500px;\n    width: 100%;\n    max-width: 500px;\n    height: 100vh;\n    padding: 0.5rem;\n    transition: left 0.3s ease;\n    overflow-y: auto;\n    z-index: 10;\n}\n\n.history_open {\n    left: 0;\n}\n\n.history {\n    display: flex;\n    flex-direction: column;\n    gap: 1rem;\n}\n\n.history_item {\n    font-size: 0.9rem;\n    cursor: pointer;\n}\n\n.history_timestamp {\n    color: var(--text-dimmed-color);\n}\n\n.load_more_btn {\n    background: transparent;\n    border: none;\n    cursor: pointer;\n    color: var(--text-dimmed-color);\n}\n\n.load_more_btn:hover {\n    opacity: 1;\n}\n"
  },
  {
    "path": "web/player/src/page/History.tsx",
    "content": "import { Accessor, Component, createSignal, onMount } from \"solid-js\";\nimport styles from \"./History.module.css\";\nimport pageStyles from \"./Page.module.css\";\nimport { airstationAPI } from \"../api\";\nimport { formatDateToTimeFirst } from \"../utils/date\";\nimport { history, setHistory } from \"../store/history\";\nimport { DESKTOP_WIDTH, MAX_HISTORY_LIMIT } from \"../const\";\n\nexport const History = () => {\n    const [isOpen, setIsOpen] = createSignal(false);\n    const open = () => setIsOpen(true);\n    const close = () => setIsOpen(false);\n\n    return (\n        <>\n            <div\n                tabIndex={0}\n                role=\"button\"\n                class={`${isOpen() ? \"empty_icon\" : styles.history_icon}`}\n                onClick={open}\n            ></div>\n            <Menu isOpen={isOpen} close={close} />\n        </>\n    );\n};\n\nconst Menu: Component<{ isOpen: Accessor<boolean>; close: () => void }> = ({ isOpen, close }) => {\n    const [hideLoadMore, setHideLoadMore] = createSignal(false);\n    const loadHistory = async (limit?: number) => {\n        try {\n            const h = await airstationAPI.getPlaybackHistory(limit);\n            setHistory(h);\n        } catch (error) {\n            console.log(error);\n        }\n    };\n\n    const loadMoreHistory = () => {\n        loadHistory(MAX_HISTORY_LIMIT);\n        setHideLoadMore(true);\n    };\n\n    const copyToClipboard = async (text: string) => {\n        try {\n            await navigator.clipboard.writeText(text);\n        } catch (error) {\n            console.log(error);\n        }\n    };\n\n    onMount(() => {\n        loadHistory();\n    });\n\n    return (\n        <div\n            class={`${styles.history_menu} ${isOpen() ? styles.history_open : \"\"} ${\n                window.screen.width > DESKTOP_WIDTH ? pageStyles.menu_desktop : pageStyles.menu_mobile\n            }`}\n        >\n            <div class={pageStyles.menu_header}>\n                <div tabIndex={0} role=\"button\" class={pageStyles.close_icon} onClick={close}></div>\n            </div>\n            <div class={styles.history}>\n                {history().map((h) => (\n                    <div class={styles.history_item} onClick={() => copyToClipboard(h.trackName)}>\n                        <div class={styles.history_name}>{h.trackName}</div>\n                        <div class={styles.history_timestamp}>{formatDateToTimeFirst(new Date(h.playedAt * 1000))}</div>\n                    </div>\n                ))}\n                {hideLoadMore() ? null : (\n                    <button class={styles.load_more_btn} onClick={loadMoreHistory}>\n                        Load more\n                    </button>\n                )}\n            </div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "web/player/src/page/ListenersCounter.module.css",
    "content": ".counter {\n    color: var(--bg-gradient-end);\n    font-size: 0.9rem;\n}\n\n.box {\n    background-color: var(--bg-icon);\n    display: flex;\n    gap: 0.3rem;\n    align-items: center;\n    justify-content: center;\n    padding: 0.05rem 0.45rem;\n    border-radius: 0.25rem;\n}\n\n.icon {\n    width: 16px;\n    height: 16px;\n    background-color: var(--bg-gradient-end);\n    -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>');\n    mask: -webkit-mask;\n}\n\n.number {\n    color: var(--bg-gradient-end);\n    user-select: none;\n    line-height: 0rem;\n    font-weight: 700;\n    font-size: 1rem;\n}\n"
  },
  {
    "path": "web/player/src/page/ListenersCounter.tsx",
    "content": "import { createSignal, onMount } from \"solid-js\";\nimport { addEventListener, EVENTS } from \"../store/events\";\nimport styles from \"./ListenersCounter.module.css\";\n\nexport const ListenersCounter = () => {\n    const [count, setCount] = createSignal(0);\n\n    onMount(() => {\n        addEventListener(EVENTS.countListeners, (e: MessageEvent<string>) => {\n            setCount(+e.data);\n        });\n    });\n\n    return (\n        <div class={styles.counter}>\n            <div class={styles.box}>\n                <div class={styles.icon}></div>\n                <div class={styles.number}>{!count() ? \"\" : count()}</div>\n            </div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "web/player/src/page/Page.module.css",
    "content": ".page {\n    display: flex;\n    flex-direction: column;\n    height: 100vh;\n}\n\n.header {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    padding: 0.25rem 0.5rem;\n}\n\n.menu_mobile {\n    background-color: var(--sidebar-color);\n}\n\n.menu_desktop {\n    background-color: color-mix(in srgb, var(--sidebar-color) 60%, transparent);\n}\n\n.menu_header {\n    width: 100%;\n    display: flex;\n    justify-content: flex-end;\n}\n\n.menu_title {\n    font-size: 1rem;\n    user-select: none;\n}\n\n.close_icon {\n    width: 18px;\n    height: 18px;\n    cursor: pointer;\n    background-color: var(--text-color);\n    -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>');\n    mask: -webkit-mask;\n}\n"
  },
  {
    "path": "web/player/src/page/RadioButton.module.css",
    "content": "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    justify-content: center;\n    align-items: center;\n}\n\n.pause_icon {\n    margin: 0px auto 0;\n    width: 74px;\n    height: 74px;\n    box-sizing: border-box;\n    border-color: transparent transparent transparent var(--text-color);\n    background-color: var(--text-color);\n    border-radius: 0.2rem;\n    transition:\n        transform 0.05s ease-out,\n        background-color 0.3s,\n        box-shadow 0.3s;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    overflow: hidden;\n}\n\n.play_icon {\n    width: 74px;\n    height: 74px;\n    box-sizing: border-box;\n    border-style: solid;\n    border-width: 36px 0px 36px 74px;\n    border-color: transparent transparent transparent var(--text-color);\n    transition: all 200ms ease-in-out;\n    border-radius: 0.2rem;\n}\n\n.pause_icon,\n.play_icon {\n    cursor: pointer;\n    user-select: none;\n}\n"
  },
  {
    "path": "web/player/src/page/RadioButton.tsx",
    "content": "import HLS from \"hls.js\";\nimport styles from \"./RadioButton.module.css\";\nimport { setTrackStore, trackStore } from \"../store/track\";\nimport { Component, onCleanup, onMount } from \"solid-js\";\nimport { addEventListener, EVENTS } from \"../store/events\";\nimport { getUnixTime } from \"../utils/date\";\nimport { addHistory } from \"../store/history\";\nimport { getCssVariable } from \"../utils/document\";\nimport { getHueFromHex } from \"../utils/color\";\n\nconst STREAM_SOURCE = \"/stream\";\n\nexport const RadioButton = () => {\n    let videoRef: HTMLAudioElement | undefined;\n    let hls: HLS | undefined;\n\n    const initStream = () => {\n        if (!trackStore.isPlay && HLS.isSupported()) {\n            hls = new HLS();\n            hls.loadSource(STREAM_SOURCE);\n            hls.attachMedia(videoRef as unknown as HTMLMediaElement);\n        }\n    };\n\n    const handlePlay = () => {\n        initStream();\n        if (!trackStore.trackName) return;\n        setTrackStore(\"isPlay\", true);\n    };\n\n    const handlePause = () => {\n        setTrackStore(\"isPlay\", false);\n        hls?.destroy();\n    };\n\n    onMount(() => {\n        addEventListener(EVENTS.pause, (_e: MessageEvent<string>) => {\n            setTrackStore(\"trackName\", \"\");\n            (() => videoRef?.pause())();\n        });\n\n        addEventListener(EVENTS.play, (e: MessageEvent<string>) => {\n            const unixTime = getUnixTime();\n            setTrackStore(\"trackName\", e.data);\n            addHistory({ id: unixTime, playedAt: unixTime, trackName: e.data });\n\n            if (trackStore.isPlay) (() => videoRef?.pause())();\n            (() => videoRef?.play())();\n        });\n\n        document.body.addEventListener(\"keydown\", (event) => {\n            if (event.key === \" \") {\n                event.preventDefault();\n                trackStore.isPlay ? videoRef?.pause() : videoRef?.play();\n            }\n        });\n    });\n\n    return (\n        <div class={styles.container}>\n            <audio id=\"video\" ref={videoRef} onPause={handlePause} onPlay={handlePlay}></audio>\n            <div class={styles.box}>\n                {trackStore.isPlay ? (\n                    <AnimatedPauseButton pause={() => videoRef?.pause()} media={videoRef} />\n                ) : (\n                    <div class={styles.play_icon} tabIndex={0} role=\"button\" onClick={() => videoRef?.play()}></div>\n                )}\n            </div>\n        </div>\n    );\n};\n\nlet audioSource: MediaElementAudioSourceNode | null = null;\nlet audioContext: AudioContext | null = null;\n\nconst AnimatedPauseButton: Component<{ pause: () => void; media?: HTMLAudioElement }> = (props) => {\n    let pauseIconRef: HTMLDivElement | undefined;\n    let analyser: AnalyserNode | null = null;\n    let dataArray: Uint8Array | null = null;\n    let animationId: number | null = null;\n    let gainNode: GainNode | null = null;\n    let accentHue: number | null = null;\n    let currentHue = 0;\n    let currentSaturation = 50;\n    let currentLightness = 60;\n\n    const loadAccentColor = () => {\n        const accentColor = getCssVariable(\"--accent-color\");\n        accentHue = accentColor ? getHueFromHex(accentColor) : null;\n\n        currentHue = accentHue !== null ? accentHue : 0;\n        currentSaturation = accentHue !== null ? 100 : 50;\n    };\n\n    onMount(async () => {\n        loadAccentColor();\n        setInterval(loadAccentColor, 1000); // Need for hot reload\n\n        if (!pauseIconRef || !props.media) return;\n        await initAudio();\n        draw();\n    });\n\n    onCleanup(async () => {\n        if (animationId !== null) {\n            cancelAnimationFrame(animationId);\n            animationId = null;\n        }\n\n        if (gainNode) {\n            gainNode.disconnect();\n            gainNode = null;\n        }\n\n        if (analyser) {\n            analyser.disconnect();\n            analyser = null;\n        }\n\n        dataArray = null;\n\n        if (pauseIconRef) {\n            pauseIconRef.style.transform = \"scale(1)\";\n            pauseIconRef.style.backgroundColor = \"white\";\n            pauseIconRef.style.boxShadow = \"none\";\n        }\n    });\n\n    const initAudio = async () => {\n        try {\n            if (!props.media) return;\n            if (!audioContext) audioContext = new window.AudioContext();\n\n            analyser = audioContext.createAnalyser();\n            analyser.fftSize = 256;\n            gainNode = audioContext.createGain();\n            gainNode.gain.value = 1;\n\n            if (!audioSource) audioSource = audioContext.createMediaElementSource(props.media);\n            audioSource.connect(gainNode);\n            gainNode.connect(analyser);\n            analyser.connect(audioContext.destination);\n\n            const bufferLength = analyser.frequencyBinCount;\n            dataArray = new Uint8Array(bufferLength);\n        } catch (err) {\n            console.error(\"Error initializing audio:\", err);\n        }\n    };\n\n    const draw = () => {\n        if (!pauseIconRef || !analyser || !dataArray) return;\n\n        animationId = requestAnimationFrame(draw);\n        analyser.getByteFrequencyData(dataArray as Uint8Array<ArrayBuffer>);\n\n        let bass = 0;\n        let treble = 0;\n        const bassEnd = Math.floor(dataArray.length * 0.3);\n        const trebleStart = Math.floor(dataArray.length * 0.6);\n\n        for (let i = 0; i < dataArray.length; i++) {\n            if (i < bassEnd) bass += dataArray[i];\n            else if (i > trebleStart) treble += dataArray[i];\n        }\n\n        bass /= bassEnd;\n        treble /= dataArray.length - trebleStart;\n\n        const scale = 1 + bass / 300;\n        const jump = (bass / 300) * 20;\n\n        pauseIconRef.style.transform = `translateY(${-jump}px) scale(${scale})`;\n\n        const bassImpact = bass / 255;\n        const trebleImpact = treble / 255;\n\n        if (accentHue == null) {\n            currentHue += (Math.random() - 0.5) * bassImpact * 120;\n            currentHue += trebleImpact * 2;\n            currentHue = (currentHue + 360) % 360;\n        }\n\n        const color = `hsl(${currentHue}, ${currentSaturation}%, ${currentLightness}%)`;\n        pauseIconRef.style.backgroundColor = color;\n\n        const glowIntensity = bass / 2 + 20;\n        pauseIconRef.style.boxShadow = `0 0 ${glowIntensity}px ${color}`;\n    };\n\n    return <div ref={pauseIconRef} tabIndex={0} role=\"button\" class={styles.pause_icon} onClick={props.pause}></div>;\n};\n"
  },
  {
    "path": "web/player/src/page/StationInformation.module.css",
    "content": ".info_menu {\n    position: fixed;\n    top: 0;\n    right: -500px;\n    width: 100%;\n    max-width: 500px;\n    height: 100vh;\n    transition: right 0.3s ease;\n    overflow-y: hidden;\n    z-index: 10;\n}\n\n.info_icon {\n    background-color: var(--bg-icon);\n    -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>');\n    mask: -webkit-mask;\n    width: 24px;\n    height: 24px;\n    cursor: pointer;\n}\n\n.info_open {\n    right: 0;\n}\n\n.header {\n    position: absolute;\n    padding: 0.5rem;\n    display: flex;\n    align-items: center;\n}\n\n.content {\n    padding: 1rem 0.5rem;\n}\n\n.logo {\n    max-width: 100%;\n    object-fit: contain;\n    display: block;\n    margin-left: auto;\n    margin-right: auto;\n}\n\n.title {\n    margin: 0;\n    font-size: 1.3rem;\n    font-weight: 600;\n    text-align: center;\n}\n\n.metadata {\n    margin-top: 0.25rem;\n    display: flex;\n    justify-content: center;\n    gap: 0.5rem;\n}\n\n.location,\n.timezone {\n    color: var(--text-dimmed-color);\n}\n\n.description {\n    margin-top: 1rem;\n}\n\n.footer {\n    margin-top: 1rem;\n    display: flex;\n    gap: 0.25rem;\n    align-items: center;\n    flex-direction: column;\n    justify-content: space-between;\n}\n\n.footer a {\n    color: var(--text-dimmed-color);\n    text-decoration: none;\n    transition: 0.2s all;\n}\n\n.footer a:hover {\n    text-decoration: underline;\n}\n"
  },
  {
    "path": "web/player/src/page/StationInformation.tsx",
    "content": "import { Accessor, Component, createSignal, onMount } from \"solid-js\";\nimport pageStyles from \"./Page.module.css\";\nimport styles from \"./StationInformation.module.css\";\nimport { airstationAPI } from \"../api\";\nimport { DESKTOP_WIDTH } from \"../const\";\nimport { StationInfo } from \"../api/types\";\nimport { isValidURL } from \"../utils/url\";\nimport { isValidHexColor } from \"../utils/color\";\nimport { setCssVariable, setFavicon, setPageTitle } from \"../utils/document\";\nimport { addEventListener, EVENTS } from \"../store/events\";\n\nexport const StationInformation = () => {\n    const [isOpen, setIsOpen] = createSignal(false);\n    const open = () => setIsOpen(true);\n    const close = () => setIsOpen(false);\n\n    return (\n        <>\n            <div role=\"button\" class={`${isOpen() ? \"empty_icon\" : styles.info_icon}`} onClick={open} />\n            <Card isOpen={isOpen} close={close} />\n        </>\n    );\n};\n\nconst parseLinks = (rawLinks: string): { title: string; url: string }[] => {\n    const regex = /\\[([^\\]]+)]\\((https?:\\/\\/[^\\s)]+)\\)/g;\n    return Array.from(rawLinks.matchAll(regex), (m) => ({\n        title: m[1],\n        url: m[2],\n    }));\n};\n\nconst parseTheme = (rawTheme: string) => {\n    const [bgStart, bgEnd, bgIcon, text, accent, bgImage] = rawTheme.split(\";\");\n\n    if (bgStart && isValidHexColor(bgStart)) setCssVariable(\"--bg-gradient-start\", bgStart);\n    if (bgEnd && isValidHexColor(bgEnd)) setCssVariable(\"--bg-gradient-end\", bgEnd);\n    if (bgIcon && isValidHexColor(bgIcon)) setCssVariable(\"--bg-icon\", bgIcon);\n    if (text && isValidHexColor(text)) setCssVariable(\"--text-color\", text);\n\n    if (accent && isValidHexColor(accent)) {\n        setCssVariable(\"--accent-color\", accent);\n    } else {\n        setCssVariable(\"--accent-color\", \"\");\n    }\n\n    if (bgImage && isValidURL(bgImage)) {\n        document.body.style.backgroundImage = `url(${bgImage})`;\n    } else {\n        document.body.style.backgroundImage = \"\";\n    }\n};\n\nconst Card: Component<{ isOpen: Accessor<boolean>; close: () => void }> = ({ isOpen, close }) => {\n    const [info, setInfo] = createSignal<StationInfo | null>(null);\n\n    const loadInfo = async () => {\n        try {\n            const h = await airstationAPI.getStationInfo();\n            setInfo(h);\n            if (h.name) setPageTitle(h.name);\n            if (isValidURL(h.faviconURL)) setFavicon(h.faviconURL);\n            if (h.theme) parseTheme(h.theme);\n        } catch (error) {\n            console.log(error);\n        }\n    };\n\n    onMount(() => {\n        loadInfo();\n\n        addEventListener(EVENTS.changeTheme, (_e: MessageEvent<string>) => {\n            loadInfo();\n        });\n    });\n\n    return (\n        <div\n            class={`${styles.info_menu} ${isOpen() ? styles.info_open : \"\"} ${\n                window.screen.width > DESKTOP_WIDTH ? pageStyles.menu_desktop : pageStyles.menu_mobile\n            }`}\n        >\n            <div class={styles.header}>\n                <div role=\"button\" class={pageStyles.close_icon} onClick={close}></div>\n            </div>\n\n            {info()?.logoURL && <img src={info()?.logoURL} alt={info?.name} class={styles.logo} />}\n\n            <div class={styles.content}>\n                <div class={styles.title}>{info()?.name}</div>\n\n                <div class={styles.metadata}>\n                    {info()?.location && <span class={styles.location}>{info()!.location}</span>}\n                    {info()?.timezone && <span class={styles.timezone}>{info()!.timezone}</span>}\n                </div>\n\n                <div class={styles.description} innerHTML={info()?.description} />\n\n                {info()?.links && (\n                    <div class={styles.footer}>\n                        {parseLinks(info()?.links!).map((link) => (\n                            <a href={link.url} target=\"_blank\" rel=\"noreferrer\">\n                                {link.title}\n                            </a>\n                        ))}\n                    </div>\n                )}\n            </div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "web/player/src/page/index.tsx",
    "content": "import { onMount, onCleanup } from \"solid-js\";\nimport { CurrentTrack } from \"./CurrentTrack\";\nimport { ListenersCounter } from \"./ListenersCounter\";\nimport { RadioButton } from \"./RadioButton\";\nimport { closeEventSource, initEventSource } from \"../store/events\";\nimport { History } from \"./History\";\nimport styles from \"./Page.module.css\";\nimport { StationInformation } from \"./StationInformation\";\n\nexport const Page = () => {\n    onMount(() => {\n        initEventSource();\n    });\n\n    onCleanup(() => {\n        closeEventSource();\n    });\n\n    return (\n        <div class={styles.page}>\n            <div class={styles.header}>\n                <History />\n                <ListenersCounter />\n                <StationInformation />\n            </div>\n            <RadioButton />\n            <CurrentTrack />\n        </div>\n    );\n};\n"
  },
  {
    "path": "web/player/src/store/events.ts",
    "content": "import { createStore } from \"solid-js/store\";\nimport { API_HOST, API_PREFIX } from \"../api\";\n\nexport const EVENT_SOURCE_URL = API_HOST + API_PREFIX + \"/events\";\nexport const EVENTS = {\n    newTrack: \"new_track\",\n    changeTheme: \"change_theme\",\n    countListeners: \"count_listeners\",\n    pause: \"pause\",\n    play: \"play\",\n};\n\nconst [eventSourceStore, setEventSourceStore] = createStore<{ eventSource: EventSource | null }>({\n    eventSource: null,\n});\n\nexport const initEventSource = () => {\n    if (eventSourceStore.eventSource) eventSourceStore.eventSource.close();\n\n    const es = new EventSource(EVENT_SOURCE_URL);\n    setEventSourceStore(\"eventSource\", es);\n};\n\nexport const addEventListener = (event: string, listener: (event: MessageEvent) => void) => {\n    if (eventSourceStore.eventSource) {\n        eventSourceStore.eventSource.addEventListener(event, listener);\n    }\n};\n\nexport const closeEventSource = () => {\n    if (eventSourceStore.eventSource) {\n        eventSourceStore.eventSource.close();\n        setEventSourceStore(\"eventSource\", null);\n    }\n};\n"
  },
  {
    "path": "web/player/src/store/history.ts",
    "content": "import { createSignal } from \"solid-js\";\nimport { PlaybackHistory } from \"../api/types\";\n\nexport const [history, setHistory] = createSignal<PlaybackHistory[]>([]);\nexport const addHistory = (h: PlaybackHistory) => {\n    setHistory([h, ...history()]);\n};\n"
  },
  {
    "path": "web/player/src/store/track.ts",
    "content": "import { createStore } from \"solid-js/store\";\n\nexport const [trackStore, setTrackStore] = createStore({\n    trackName: \"\",\n    isPlay: false,\n});\n"
  },
  {
    "path": "web/player/src/utils/color.ts",
    "content": "export function getHueFromHex(hex: string) {\n    hex = hex.replace(\"#\", \"\");\n\n    const r = parseInt(hex.slice(0, 2), 16) / 255;\n    const g = parseInt(hex.slice(2, 4), 16) / 255;\n    const b = parseInt(hex.slice(4, 6), 16) / 255;\n\n    const max = Math.max(r, g, b);\n    const min = Math.min(r, g, b);\n    const d = max - min;\n\n    if (d === 0) return 0;\n\n    let h;\n    switch (max) {\n        case r:\n            h = ((g - b) / d) % 6;\n            break;\n        case g:\n            h = (b - r) / d + 2;\n            break;\n        default:\n            h = (r - g) / d + 4;\n    }\n\n    return Math.round(h * 60 < 0 ? h * 60 + 360 : h * 60);\n}\n\nexport function isValidHexColor(hex: string) {\n    return /^#[0-9a-f]{6}$/.test(hex);\n}\n"
  },
  {
    "path": "web/player/src/utils/date.ts",
    "content": "export const formatDateToTimeFirst = (date: Date) => {\n    const timeParts = new Intl.DateTimeFormat(undefined, {\n        hour: \"2-digit\",\n        minute: \"2-digit\",\n        second: \"2-digit\",\n        hour12: false,\n    }).formatToParts(date);\n\n    const dateParts = new Intl.DateTimeFormat(undefined, {\n        day: \"2-digit\",\n        month: \"2-digit\",\n        year: \"numeric\",\n    }).formatToParts(date);\n\n    const time = timeParts.map((p) => p.value).join(\"\");\n    const dateStr = dateParts.map((p) => p.value).join(\"\");\n\n    return `${time} ${dateStr}`;\n};\n\nexport const getUnixTime = (): number => Math.floor(Date.now() / 1000);\n"
  },
  {
    "path": "web/player/src/utils/document.ts",
    "content": "export const setFavicon = (url: string) => {\n    let link = document.querySelector<HTMLLinkElement>(\"link[rel*='icon']\") || document.createElement(\"link\");\n    link.type = \"image/png\";\n    link.rel = \"icon\";\n    link.href = url;\n    document.getElementsByTagName(\"head\")[0].appendChild(link);\n};\n\nexport const setPageTitle = (title: string) => {\n    document.title = title;\n};\n\nexport const getCssVariable = (name: string): string => {\n    return getComputedStyle(document.documentElement).getPropertyValue(name).trim();\n};\n\nexport const setCssVariable = (name: string, value: string): void => {\n    document.documentElement.style.setProperty(name, value);\n};\n"
  },
  {
    "path": "web/player/src/utils/url.ts",
    "content": "export const isValidURL = (str: string) => {\n    try {\n        const url = new URL(str);\n        return url.protocol === \"http:\" || url.protocol === \"https:\";\n    } catch (err) {\n        return false;\n    }\n};\n"
  },
  {
    "path": "web/player/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "web/player/tsconfig.app.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"isolatedModules\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"jsx\": \"preserve\",\n    \"jsxImportSource\": \"solid-js\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "web/player/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ]\n}\n"
  },
  {
    "path": "web/player/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2022\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"isolatedModules\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "web/player/vite.config.ts",
    "content": "import { defineConfig, loadEnv } from \"vite\";\nimport solid from \"vite-plugin-solid\";\nimport { VitePWA } from \"vite-plugin-pwa\";\nimport path from \"path\";\n\nexport default defineConfig(({ mode }) => {\n    const globalEnv = loadEnv(mode, path.join(process.cwd(), \"..\", \"..\"), \"\");\n    const localEnv = loadEnv(mode, process.cwd(), \"\");\n    const appTitle = globalEnv.AIRSTATION_PLAYER_TITLE || localEnv.AIRSTATION_PLAYER_TITLE || \"Radio\";\n    return {\n        plugins: [\n            solid(),\n            VitePWA({\n                scope: \"/\",\n                registerType: \"autoUpdate\",\n                workbox: {\n                    cleanupOutdatedCaches: true,\n                    navigateFallback: \"/index.html\",\n                    navigateFallbackDenylist: [/^\\/studio\\//],\n                },\n                devOptions: {\n                    enabled: true,\n                },\n                manifest: {\n                    scope: \"/\",\n                    start_url: \"/\",\n                    lang: \"en\",\n                    name: \"Radio\",\n                    short_name: \"Radio\",\n                    icons: [\n                        {\n                            src: \"icon48.png\",\n                            sizes: \"48x48\",\n                            type: \"image/png\",\n                            purpose: \"maskable any\",\n                        },\n                        {\n                            src: \"icon72.png\",\n                            sizes: \"72x72\",\n                            type: \"image/png\",\n                            purpose: \"maskable any\",\n                        },\n                        {\n                            src: \"icon96.png\",\n                            sizes: \"96x96\",\n                            type: \"image/png\",\n                            purpose: \"maskable any\",\n                        },\n                        {\n                            src: \"icon128.png\",\n                            sizes: \"128x128\",\n                            type: \"image/png\",\n                            purpose: \"maskable any\",\n                        },\n                        {\n                            src: \"icon144.png\",\n                            sizes: \"144x144\",\n                            type: \"image/png\",\n                            purpose: \"maskable any\",\n                        },\n                        {\n                            src: \"icon152.png\",\n                            sizes: \"152x152\",\n                            type: \"image/png\",\n                            purpose: \"maskable any\",\n                        },\n                        {\n                            src: \"icon192.png\",\n                            sizes: \"192x192\",\n                            type: \"image/png\",\n                            purpose: \"maskable any\",\n                        },\n                        {\n                            src: \"icon256.png\",\n                            sizes: \"256x256\",\n                            type: \"image/png\",\n                            purpose: \"maskable any\",\n                        },\n                        {\n                            src: \"icon512.png\",\n                            sizes: \"512x512\",\n                            type: \"image/png\",\n                            purpose: \"maskable any\",\n                        },\n                    ],\n                },\n            }),\n        ],\n        server: {\n            proxy: {\n                \"/api\": { target: \"http://localhost:7331\", changeOrigin: true },\n                \"/stream\": { target: \"http://localhost:7331\", changeOrigin: true },\n                \"/static\": { target: \"http://localhost:7331\", changeOrigin: true },\n            },\n        },\n        envPrefix: \"AIRSTATION_PLAYER_\",\n        define: {\n            \"import.meta.env.AIRSTATION_PLAYER_TITLE\": JSON.stringify(appTitle),\n        },\n    };\n});\n"
  },
  {
    "path": "web/studio/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\ndev-dist\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "web/studio/.prettierrc",
    "content": "{\n    \"trailingComma\": \"all\",\n    \"tabWidth\": 4,\n    \"semi\": true,\n    \"singleQuote\": false,\n    \"printWidth\": 120\n}"
  },
  {
    "path": "web/studio/README.md",
    "content": "# Airstation Studio\n"
  },
  {
    "path": "web/studio/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\" />\n        <link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n        <meta name=\"msapplication-TileColor\" content=\"#29323c\" />\n        <meta name=\"theme-color\" content=\"#29323c\" />\n        <title>Airstation Studio</title>\n        <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n        <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n        <link\n            href=\"https://fonts.googleapis.com/css2?family=Exo+2:ital,wght@0,100..900;1,100..900&display=swap\"\n            rel=\"stylesheet\"\n        />\n    </head>\n    <body>\n        <div id=\"root\"></div>\n        <script type=\"module\" src=\"/src/main.tsx\"></script>\n    </body>\n</html>\n"
  },
  {
    "path": "web/studio/package.json",
    "content": "{\n    \"name\": \"studio\",\n    \"private\": true,\n    \"version\": \"0.0.0\",\n    \"type\": \"module\",\n    \"scripts\": {\n        \"dev\": \"vite\",\n        \"build\": \"tsc -b && vite build\",\n        \"preview\": \"vite preview\"\n    },\n    \"dependencies\": {\n        \"@dnd-kit/core\": \"^6.3.1\",\n        \"@dnd-kit/modifiers\": \"^9.0.0\",\n        \"@dnd-kit/sortable\": \"^10.0.0\",\n        \"@dnd-kit/utilities\": \"^3.2.2\",\n        \"@mantine/core\": \"^9.0.0\",\n        \"@mantine/form\": \"^9.0.0\",\n        \"@mantine/hooks\": \"^9.0.0\",\n        \"@mantine/modals\": \"^9.0.0\",\n        \"@mantine/notifications\": \"^9.0.0\",\n        \"hls.js\": \"^1.6.15\",\n        \"react\": \"^19.2.4\",\n        \"react-dom\": \"^19.2.4\",\n        \"zustand\": \"^5.0.12\"\n    },\n    \"devDependencies\": {\n        \"@types/react\": \"^19.2.14\",\n        \"@types/react-dom\": \"^19.2.3\",\n        \"@vitejs/plugin-react\": \"^6.0.1\",\n        \"globals\": \"^17.4.0\",\n        \"typescript\": \"~6.0.2\",\n        \"vite\": \"^8.0.3\",\n        \"vite-plugin-pwa\": \"^1.2.0\"\n    },\n    \"overrides\": {\n        \"vite-plugin-pwa\": {\n            \"vite\": \"$vite\"\n        }\n    }\n}\n"
  },
  {
    "path": "web/studio/src/App.tsx",
    "content": "import { MantineProvider } from \"@mantine/core\";\nimport { ModalsProvider } from \"@mantine/modals\";\nimport { Notifications } from \"@mantine/notifications\";\nimport { AuthGuard } from \"./components/AuthGuard\";\nimport { theme } from \"./theme\";\nimport { Page } from \"./page\";\n\nconst App = () => {\n    return (\n        <MantineProvider defaultColorScheme=\"dark\" theme={theme}>\n            <ModalsProvider modalProps={{ transitionProps: { duration: 100 } }}>\n                <Notifications position=\"bottom-right\" autoClose={7000} />\n                <AuthGuard>\n                    <Page />\n                </AuthGuard>\n            </ModalsProvider>\n        </MantineProvider>\n    );\n};\n\nexport default App;\n"
  },
  {
    "path": "web/studio/src/api/index.ts",
    "content": "import { PlaybackState, Playlist, ResponseErr, ResponseOK, StationInfo, Track, TracksPage } from \"./types\";\nimport { jsonRequestParams, queryParams } from \"./utils\";\n\nexport const API_HOST = \"\";\nexport const API_PREFIX = \"/api/v1\";\n\nclass AirstationAPI {\n    private host: string;\n    private prefix: string;\n    private url: () => string;\n\n    constructor(host: string, prefix: string) {\n        this.host = host;\n        this.prefix = prefix;\n        this.url = () => `${this.host + this.prefix}`;\n    }\n\n    async login(secret: string) {\n        const url = `${this.url()}/login`;\n        return await this.makeRequest<ResponseOK>(url, jsonRequestParams(\"POST\", { secret }));\n    }\n\n    async getPlayback() {\n        const url = `${this.url()}/playback`;\n        return await this.makeRequest<PlaybackState>(url);\n    }\n\n    async pausePlayback() {\n        const url = `${this.url()}/playback/pause`;\n        return await this.makeRequest<PlaybackState>(url, jsonRequestParams(\"POST\", {}));\n    }\n\n    async playPlayback() {\n        const url = `${this.url()}/playback/play`;\n        return await this.makeRequest<PlaybackState>(url, jsonRequestParams(\"POST\", {}));\n    }\n\n    async getTracks(page: number, limit: number, search: string, sortBy: keyof Track, sortOrder: \"asc\" | \"desc\") {\n        const url = `${this.url()}/tracks?${queryParams({\n            page,\n            limit,\n            search,\n            sort_by: sortBy,\n            sort_order: sortOrder,\n        })}`;\n        return await this.makeRequest<TracksPage>(url);\n    }\n\n    async uploadTracks(files: File[]) {\n        const url = `${this.url()}/tracks`;\n        const formData = new FormData();\n\n        for (let i = 0; i < files.length; i++) {\n            formData.append(\"tracks\", files[i]);\n        }\n\n        return await this.makeRequest<ResponseOK>(url, {\n            method: \"POST\",\n            body: formData,\n        });\n    }\n\n    async deleteTracks(ids: string[]) {\n        const url = `${this.url()}/tracks`;\n        return await this.makeRequest<ResponseOK>(url, jsonRequestParams(\"DELETE\", { ids }));\n    }\n\n    async getQueue() {\n        const url = `${this.url()}/queue`;\n        return await this.makeRequest<Track[]>(url);\n    }\n\n    async addToQueue(trackIDs: string[]) {\n        const url = `${this.url()}/queue`;\n        return await this.makeRequest<ResponseOK>(url, jsonRequestParams(\"POST\", { ids: trackIDs }));\n    }\n\n    async updateQueue(trackIDs: string[]) {\n        const url = `${this.url()}/queue`;\n        return await this.makeRequest<ResponseOK>(url, jsonRequestParams(\"PUT\", { ids: trackIDs }));\n    }\n\n    async removeFromQueue(trackIDs: string[]) {\n        const url = `${this.url()}/queue`;\n        return await this.makeRequest<ResponseOK>(url, jsonRequestParams(\"DELETE\", { ids: trackIDs }));\n    }\n\n    async addPlaylist(name: string, trackIDs: string[], description?: string) {\n        const url = `${this.url()}/playlist`;\n        return await this.makeRequest<Playlist>(url, jsonRequestParams(\"POST\", { name, description, trackIDs }));\n    }\n\n    async getPlaylists() {\n        const url = `${this.url()}/playlists`;\n        return await this.makeRequest<Playlist[]>(url);\n    }\n\n    async getPlaylist(id: string) {\n        const url = `${this.url()}/playlist/` + id;\n        return await this.makeRequest<Playlist>(url);\n    }\n\n    async editPlaylist(id: string, name: string, trackIDs: string[], description?: string) {\n        const url = `${this.url()}/playlist/` + id;\n        return await this.makeRequest<ResponseOK>(url, jsonRequestParams(\"PUT\", { name, description, trackIDs }));\n    }\n\n    async deletePlaylist(id: string) {\n        const url = `${this.url()}/playlist/` + id;\n        return await this.makeRequest<ResponseOK>(url, jsonRequestParams(\"DELETE\", {}));\n    }\n\n    async getStationInfo() {\n        const url = `${this.url()}/station/info`;\n        return await this.makeRequest<StationInfo>(url);\n    }\n\n    async editStationInfo(info: StationInfo) {\n        const url = `${this.url()}/station/info`;\n        return await this.makeRequest<StationInfo>(url, jsonRequestParams(\"PUT\", info));\n    }\n\n    private async makeRequest<T>(url: string, params: RequestInit = {}): Promise<T> {\n        const resp = await fetch(url, params);\n        if (!resp.ok) {\n            const body: ResponseErr = await resp.json();\n            throw new Error(body.message);\n        }\n\n        return resp.json();\n    }\n}\n\nexport const airstationAPI = new AirstationAPI(API_HOST, API_PREFIX);\n"
  },
  {
    "path": "web/studio/src/api/types.ts",
    "content": "export interface Track {\n    id: string;\n    name: string;\n    path: string;\n    duration: number;\n    bitRate: number;\n}\n\nexport interface TracksPage {\n    tracks: Track[];\n    page: number;\n    limit: number;\n    total: number;\n}\n\nexport interface PlaybackState {\n    currentTrack: Track | null;\n    currentTrackElapsed: number;\n    isPlaying: boolean;\n    updatedAt: number;\n}\n\nexport interface ResponseErr {\n    message: string;\n}\n\nexport interface ResponseOK {\n    message: string;\n}\n\nexport interface Playlist {\n    id: string;\n    name: string;\n    description?: string;\n    tracks: Track[];\n    trackCount: number;\n}\n\nexport interface StationInfo {\n    name: string;\n    description: string;\n    faviconURL: string;\n    logoURL: string;\n    location: string;\n    timezone: string;\n    links: string;\n    theme: string;\n}\n"
  },
  {
    "path": "web/studio/src/api/utils.ts",
    "content": "export const jsonRequestParams = (method: string, body: Record<string, any>) => {\n    return {\n        method,\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify(body),\n    };\n};\n\nexport const queryParams = (params: Record<string, any>) => {\n    removeEmptyFields(params);\n    return new URLSearchParams(params).toString();\n};\n\nconst removeEmptyFields = (obj: Record<string, any>) => {\n    for (const key in obj) {\n        if (obj.hasOwnProperty(key) && [undefined, null, \"\"].includes(obj[key])) {\n            delete obj[key];\n        }\n    }\n};\n"
  },
  {
    "path": "web/studio/src/components/AudioPlayer.tsx",
    "content": "import React, { useEffect, useRef } from \"react\";\nimport { useThrottledState } from \"@mantine/hooks\";\nimport { ActionIcon, Box, Checkbox, Flex, Group, Progress, Text, Tooltip, useMantineColorScheme } from \"@mantine/core\";\nimport { API_HOST } from \"../api\";\nimport { Track } from \"../api/types\";\nimport { formatTime } from \"../utils/time\";\nimport { IconPlayerPlayFilled } from \"../icons\";\nimport { IconPlayerStopFilled } from \"../icons\";\n\ninterface AudioPlayerProps {\n    track: Track;\n    isPlaying: boolean;\n    selected: Set<string>;\n    setSelected: React.Dispatch<React.SetStateAction<Set<string>>>;\n    togglePlaying: () => void;\n}\n\nexport const AudioPlayer: React.FC<AudioPlayerProps> = ({ track, isPlaying, selected, setSelected, togglePlaying }) => {\n    const audioRef = useRef<HTMLAudioElement>(null);\n    const [progress, setProgress] = useThrottledState(0, 500);\n    const [cursorPos, setCursorPos] = useThrottledState(0, 100);\n    const { colorScheme } = useMantineColorScheme();\n\n    const btnColor = colorScheme === \"dark\" ? \"gray\" : \"black\";\n\n    const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {\n        if (audioRef.current) {\n            const rect = e.currentTarget.getBoundingClientRect();\n            const clickPosition = (e.clientX - rect.left) / rect.width;\n            const newTime = clickPosition * audioRef.current.duration;\n            audioRef.current.currentTime = newTime;\n            setProgress(clickPosition * 100);\n            if (!isPlaying) togglePlaying();\n        }\n    };\n\n    const handleTimeUpdate = () => {\n        if (audioRef.current) {\n            const currentTime = audioRef.current.currentTime;\n            const duration = audioRef.current.duration;\n            setProgress((currentTime / duration) * 100);\n        }\n    };\n\n    const handleAudioEnd = () => {\n        if (audioRef.current) {\n            audioRef.current.pause();\n            audioRef.current.currentTime = 0;\n            togglePlaying();\n            setProgress(0);\n        }\n    };\n\n    useEffect(() => {\n        if (audioRef.current) {\n            if (isPlaying) {\n                audioRef.current.play();\n            } else {\n                audioRef.current.pause();\n            }\n        }\n    }, [isPlaying]);\n\n    return (\n        <>\n            <audio\n                crossOrigin=\"use-credentials\"\n                ref={audioRef}\n                src={`${API_HOST}/${track.path}`}\n                preload=\"none\"\n                onTimeUpdate={handleTimeUpdate}\n                onEnded={handleAudioEnd}\n            />\n\n            <Text style={{ whiteSpace: \"wrap\" }}>{track.name}</Text>\n            <Flex gap=\"sm\" align=\"center\">\n                <ActionIcon onClick={togglePlaying} variant=\"subtle\" color=\"white\" size=\"sm\" aria-label=\"Settings\">\n                    {isPlaying ? <IconPlayerStopFilled fill={btnColor} /> : <IconPlayerPlayFilled fill={btnColor} />}\n                </ActionIcon>\n\n                <Box w=\"100%\" mt=\"xs\" style={{ cursor: \"pointer\" }}>\n                    <Tooltip.Floating label={formatTime(track.duration * cursorPos)} disabled={!isPlaying}>\n                        <Progress\n                            onMouseMove={(e) => {\n                                const rect = e.currentTarget.getBoundingClientRect();\n                                setCursorPos(Math.abs((e.clientX - rect.left) / rect.width));\n                            }}\n                            onClick={handleProgressClick}\n                            value={progress}\n                        />\n                    </Tooltip.Floating>\n\n                    <Group align=\"end\">\n                        <Text ta=\"end\" mt={3} c=\"dimmed\" size=\"sm\">\n                            {formatTime((progress / 100) * track.duration || 0)}/{formatTime(track.duration || 0)}\n                        </Text>\n                    </Group>\n                </Box>\n\n                <Flex>\n                    <Checkbox\n                        checked={selected.has(track.id)}\n                        onChange={() => {\n                            setSelected((prevSelected) => {\n                                const newSelected = new Set(prevSelected);\n                                if (newSelected.has(track.id)) {\n                                    newSelected.delete(track.id);\n                                } else {\n                                    newSelected.add(track.id);\n                                }\n                                return newSelected;\n                            });\n                        }}\n                    />\n                </Flex>\n            </Flex>\n        </>\n    );\n};\n"
  },
  {
    "path": "web/studio/src/components/AuthGuard.tsx",
    "content": "import { FC, JSX, useEffect, useState } from \"react\";\nimport { useDisclosure } from \"@mantine/hooks\";\nimport { Box, Button, Flex, Group, LoadingOverlay, Paper, TextInput } from \"@mantine/core\";\nimport { airstationAPI } from \"../api\";\nimport { handleErr } from \"../utils/error\";\nimport { errNotify } from \"../notifications\";\n\nexport const AuthGuard: FC<{ children: JSX.Element }> = (props) => {\n    const [isAuth, setIsAuth] = useState(false);\n    const [loader, handLoader] = useDisclosure(false);\n\n    const handleLogin = async (secret: string) => {\n        try {\n            handLoader.open();\n            await airstationAPI.login(secret);\n            await airstationAPI.getQueue(); // Need to check is cookie setted correctly\n            setIsAuth(true);\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            handLoader.close();\n        }\n    };\n\n    useEffect(() => {\n        (async () => {\n            try {\n                handLoader.open();\n                await airstationAPI.getQueue();\n                setIsAuth(true);\n            } catch (error) {\n                const msg = handleErr(error);\n                if (!msg.includes(\"Unauthorized\")) errNotify(msg);\n            } finally {\n                handLoader.close();\n            }\n        })();\n    }, []);\n\n    return (\n        <>\n            {isAuth ? (\n                props.children\n            ) : (\n                <Box w=\"100%\" h=\"100vh\">\n                    <LoadingOverlay visible={loader} />\n                    {loader ? null : <LoginForm handleLogin={handleLogin} />}\n                </Box>\n            )}\n        </>\n    );\n};\n\nconst MIN_SECRET_LENGTH = 10;\nconst LoginForm: FC<{ handleLogin: (s: string) => Promise<void> }> = (props) => {\n    const [secret, setSecret] = useState(\"\");\n\n    return (\n        <Flex h=\"100%\" justify=\"center\" align=\"center\">\n            <Paper mb=\"sm\" w={250} bg=\"transparent\">\n                <TextInput\n                    autoFocus\n                    type=\"password\"\n                    required\n                    onKeyDown={(event) => {\n                        if (event.key === \"Enter\" && secret.length >= MIN_SECRET_LENGTH) props.handleLogin(secret);\n                    }}\n                    value={secret}\n                    onChange={(event) => setSecret(event.currentTarget.value)}\n                    placeholder=\"Enter secret\"\n                />\n                <Group mt=\"sm\" justify=\"center\">\n                    <Button\n                        disabled={secret.length < MIN_SECRET_LENGTH}\n                        fullWidth\n                        variant=\"light\"\n                        onClick={() => props.handleLogin(secret)}\n                    >\n                        Submit\n                    </Button>\n                </Group>\n            </Paper>\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "web/studio/src/components/EmptyLabel.tsx",
    "content": "import { Flex, Text } from \"@mantine/core\";\nimport { FC } from \"react\";\n\nexport const EmptyLabel: FC<{ label: string }> = ({ label }) => {\n    return (\n        <Flex justify=\"center\" align=\"center\" w=\"100%\" h=\"100%\">\n            <Text fz=\"lg\" c=\"dimmed\">\n                {label}\n            </Text>\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "web/studio/src/hooks/useIsMobile.ts",
    "content": "import { useViewportSize } from \"@mantine/hooks\";\n\nexport const MAX_MOBILE_WIDTH = 800;\n\nexport const useIsMobile = () => {\n    const { width } = useViewportSize();\n    const isMobile = width <= MAX_MOBILE_WIDTH;\n    return { width, isMobile };\n};\n"
  },
  {
    "path": "web/studio/src/hooks/useThemeBlackColor.ts",
    "content": "import { useMantineColorScheme } from \"@mantine/core\";\n\nexport function useThemeBlackColor() {\n    const { colorScheme } = useMantineColorScheme();\n    return colorScheme === \"dark\" ? \"gray\" : \"black\";\n}\n"
  },
  {
    "path": "web/studio/src/icons/index.tsx",
    "content": "import { FC } from \"react\";\nimport { IconProps } from \"./types\";\n\nexport const IconPlayerPlayFilled: FC<IconProps> = ({ size, style, ...others }) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"1.5\"\n            viewBox=\"0 0 24 24\"\n            style={{ width: size, height: size, ...style }}\n            {...others}\n        >\n            <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n            <path d=\"M6 4v16a1 1 0 0 0 1.524 .852l13 -8a1 1 0 0 0 0 -1.704l-13 -8a1 1 0 0 0 -1.524 .852z\" />\n        </svg>\n    );\n};\n\nexport const IconPlayerStopFilled: FC<IconProps> = ({ size, style, ...others }) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"1.5\"\n            viewBox=\"0 0 24 24\"\n            style={{ width: size, height: size, ...style }}\n            {...others}\n        >\n            <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n            <path d=\"M17 4h-10a3 3 0 0 0 -3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3 -3v-10a3 3 0 0 0 -3 -3z\" />\n        </svg>\n    );\n};\n\nexport const IconPlaylistAd: FC<IconProps> = ({ size, style, ...others }) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"1.5\"\n            viewBox=\"0 0 24 24\"\n            style={{ width: size, height: size, ...style }}\n            {...others}\n        >\n            <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n            <path d=\"M19 8h-14\" />\n            <path d=\"M5 12h9\" />\n            <path d=\"M11 16h-6\" />\n            <path d=\"M15 16h6\" />\n            <path d=\"M18 13v6\" />\n        </svg>\n    );\n};\n\nexport const IconWashDry: FC<IconProps> = ({ size, style, ...others }) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"1.5\"\n            viewBox=\"0 0 24 24\"\n            style={{ width: size, height: size, ...style }}\n            {...others}\n        >\n            <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n            <path d=\"M3 3m0 3a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3z\" />\n        </svg>\n    );\n};\n\nexport const IconHeadphones: FC<IconProps> = ({ size, style, ...others }) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            style={{ width: size, height: size, ...style }}\n            {...others}\n        >\n            <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n            <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\" />\n            <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\" />\n            <path d=\"M4 15v-3a8 8 0 0 1 16 0v3\" />\n        </svg>\n    );\n};\n\nexport const IconVolumeOn: FC<IconProps> = ({ size, style, ...others }) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            style={{ width: size, height: size, ...style }}\n            {...others}\n        >\n            <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n            <path d=\"M15 8a5 5 0 0 1 0 8\" />\n            <path d=\"M17.7 5a9 9 0 0 1 0 14\" />\n            <path d=\"M6 15h-2a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h2l3.5 -4.5a.8 .8 0 0 1 1.5 .5v14a.8 .8 0 0 1 -1.5 .5l-3.5 -4.5\" />\n        </svg>\n    );\n};\n\nexport const IconVolumeOff: FC<IconProps> = ({ size, style, ...others }) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            style={{ width: size, height: size, ...style }}\n            {...others}\n        >\n            <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n            <path d=\"M15 8a5 5 0 0 1 1.912 4.934m-1.377 2.602a5 5 0 0 1 -.535 .464\" />\n            <path d=\"M17.7 5a9 9 0 0 1 2.362 11.086m-1.676 2.299a9 9 0 0 1 -.686 .615\" />\n            <path d=\"M9.069 5.054l.431 -.554a.8 .8 0 0 1 1.5 .5v2m0 4v8a.8 .8 0 0 1 -1.5 .5l-3.5 -4.5h-2a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h2l1.294 -1.664\" />\n            <path d=\"M3 3l18 18\" />\n        </svg>\n    );\n};\n\nexport const IconSortAscending: FC<IconProps> = ({ size, style, ...others }) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            style={{ width: size, height: size, ...style }}\n            {...others}\n        >\n            <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n            <path d=\"M14 9l3 -3l3 3\" />\n            <path d=\"M5 5m0 .5a.5 .5 0 0 1 .5 -.5h4a.5 .5 0 0 1 .5 .5v4a.5 .5 0 0 1 -.5 .5h-4a.5 .5 0 0 1 -.5 -.5z\" />\n            <path d=\"M5 14m0 .5a.5 .5 0 0 1 .5 -.5h4a.5 .5 0 0 1 .5 .5v4a.5 .5 0 0 1 -.5 .5h-4a.5 .5 0 0 1 -.5 -.5z\" />\n            <path d=\"M17 6v12\" />\n        </svg>\n    );\n};\n\nexport const IconSortDescending: FC<IconProps> = ({ size, style, ...others }) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            style={{ width: size, height: size, ...style }}\n            {...others}\n        >\n            <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n            <path d=\"M5 5m0 .5a.5 .5 0 0 1 .5 -.5h4a.5 .5 0 0 1 .5 .5v4a.5 .5 0 0 1 -.5 .5h-4a.5 .5 0 0 1 -.5 -.5z\" />\n            <path d=\"M5 14m0 .5a.5 .5 0 0 1 .5 -.5h4a.5 .5 0 0 1 .5 .5v4a.5 .5 0 0 1 -.5 .5h-4a.5 .5 0 0 1 -.5 -.5z\" />\n            <path d=\"M14 15l3 3l3 -3\" />\n            <path d=\"M17 18v-12\" />\n        </svg>\n    );\n};\n\nexport const IconReload: FC<IconProps> = ({ size, style, ...others }) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            style={{ width: size, height: size, ...style }}\n            {...others}\n        >\n            <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n            <path d=\"M19.933 13.041a8 8 0 1 1 -9.925 -8.788c3.899 -1 7.935 1.007 9.425 4.747\" />\n            <path d=\"M20 4v5h-5\" />\n        </svg>\n    );\n};\n\nexport const IconPlaylist: FC<IconProps> = ({ size, style, ...others }) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            style={{ width: size, height: size, ...style }}\n            {...others}\n        >\n            <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n            <path d=\"M14 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0\" />\n            <path d=\"M17 17v-13h4\" />\n            <path d=\"M13 5h-10\" />\n            <path d=\"M3 9l10 0\" />\n            <path d=\"M9 13h-6\" />\n        </svg>\n    );\n};\n\nexport const IconQueue: FC<IconProps> = ({ size, style, ...others }) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            style={{ width: size, height: size, ...style }}\n            {...others}\n        >\n            <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n            <path d=\"M7 6h10\" />\n            <path d=\"M4 12h16\" />\n            <path d=\"M7 12h13\" />\n            <path d=\"M7 18h10\" />\n        </svg>\n    );\n};\n\nexport const IconTrash: FC<IconProps> = ({ size, style, ...others }) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            style={{ width: size, height: size, ...style }}\n            {...others}\n        >\n            <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n            <path d=\"M4 7l16 0\" />\n            <path d=\"M10 11l0 6\" />\n            <path d=\"M14 11l0 6\" />\n            <path d=\"M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12\" />\n            <path d=\"M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3\" />\n        </svg>\n    );\n};\n\nexport const IconSettings: FC<IconProps> = ({ size, style, ...others }) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"currentColor\"\n            style={{ width: size, height: size, ...style }}\n            {...others}\n        >\n            <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n            <path d=\"M14.647 4.081a.724 .724 0 0 0 1.08 .448c2.439 -1.485 5.23 1.305 3.745 3.744a.724 .724 0 0 0 .447 1.08c2.775 .673 2.775 4.62 0 5.294a.724 .724 0 0 0 -.448 1.08c1.485 2.439 -1.305 5.23 -3.744 3.745a.724 .724 0 0 0 -1.08 .447c-.673 2.775 -4.62 2.775 -5.294 0a.724 .724 0 0 0 -1.08 -.448c-2.439 1.485 -5.23 -1.305 -3.745 -3.744a.724 .724 0 0 0 -.447 -1.08c-2.775 -.673 -2.775 -4.62 0 -5.294a.724 .724 0 0 0 .448 -1.08c-1.485 -2.439 1.305 -5.23 3.744 -3.745a.722 .722 0 0 0 1.08 -.447c.673 -2.775 4.62 -2.775 5.294 0zm-2.647 4.919a3 3 0 1 0 0 6a3 3 0 0 0 0 -6z\" />\n        </svg>\n    );\n};\n"
  },
  {
    "path": "web/studio/src/icons/types.ts",
    "content": "export interface IconProps extends React.ComponentPropsWithoutRef<\"svg\"> {\n    size?: number | string;\n}\n"
  },
  {
    "path": "web/studio/src/index.css",
    "content": "* {\n    ::-webkit-scrollbar {\n        width: 7px;\n    }\n\n    ::-webkit-scrollbar-track {\n        background: #ffffff15;\n        border-radius: 1px;\n    }\n\n    ::-webkit-scrollbar-thumb {\n        background: #ffffff20;\n        border-radius: 1px;\n    }\n\n    ::-webkit-scrollbar-thumb:hover {\n        background: #dadada;\n    }\n}\n\nbody {\n    background: #29323c;\n    background: -webkit-linear-gradient(to top, #29323c, #485563);\n    background: linear-gradient(to top, #29323c, #485563);\n}\n\n.mantine-Modal-inner {\n    padding-inline: 0.2rem;\n}\n"
  },
  {
    "path": "web/studio/src/main.tsx",
    "content": "import { createRoot } from \"react-dom/client\";\nimport App from \"./App.tsx\";\n\nimport \"@mantine/core/styles.css\";\nimport \"@mantine/notifications/styles.css\";\nimport \"./index.css\";\n\ncreateRoot(document.getElementById(\"root\")!).render(<App />);\n"
  },
  {
    "path": "web/studio/src/notifications/index.ts",
    "content": "import { notifications } from \"@mantine/notifications\";\nimport { handleErr } from \"../utils/error\";\n\nexport const errNotify = (err: string | unknown) => {\n    const message = typeof err === \"string\" ? err : handleErr(err);\n\n    notifications.show({\n        message,\n        withBorder: true,\n        withCloseButton: true,\n        autoClose: 20_000,\n        color: \"red\",\n    });\n};\n\nexport const okNotify = (message: string) => {\n    notifications.show({\n        message,\n        withBorder: true,\n        withCloseButton: true,\n        color: \"green\",\n    });\n};\n\nexport const infoNotify = (message: string) => {\n    notifications.show({\n        message,\n        withBorder: true,\n        withCloseButton: true,\n        color: \"blue\",\n    });\n};\n\nexport const warnNotify = (message: string) => {\n    notifications.show({\n        message,\n        withBorder: true,\n        withCloseButton: true,\n        color: \"yellow\",\n    });\n};\n"
  },
  {
    "path": "web/studio/src/page/DesktopPage.tsx",
    "content": "import { Container, Flex, SimpleGrid } from \"@mantine/core\";\nimport { FC } from \"react\";\nimport { Playback } from \"./Playback\";\nimport { TrackLibrary } from \"./TracksLibrary\";\nimport { TrackQueue } from \"./TracksQueue\";\nimport { useSettingsStore } from \"../store/settings\";\n\nconst DesktopPage: FC<{ windowWidth: number }> = ({ windowWidth }) => {\n    const interfaceWidth = useSettingsStore((s) => s.interfaceWidth);\n    const defineWidth = () => {\n        if (interfaceWidth) return interfaceWidth;\n        return windowWidth >= 2400 ? \"xl\" : \"lg\";\n    };\n\n    return (\n        <Container size={defineWidth()}>\n            <Flex p=\"sm\" direction=\"column\" justify=\"center\" align=\"center\" h=\"100vh\">\n                <Playback />\n\n                <SimpleGrid cols={{ base: 1, sm: 2 }} spacing=\"sm\" mt=\"sm\" w=\"100%\">\n                    <TrackQueue />\n                    <TrackLibrary />\n                </SimpleGrid>\n            </Flex>\n        </Container>\n    );\n};\n\nexport default DesktopPage;\n"
  },
  {
    "path": "web/studio/src/page/MobileBar.tsx",
    "content": "import { Button, Flex } from \"@mantine/core\";\nimport { FC } from \"react\";\n\ninterface MobileBarProps {\n    activeBar: string;\n    setActiveBar: React.Dispatch<React.SetStateAction<string>>;\n}\n\nexport const MOBILE_BARS = [\"Playback\", \"Queue\", \"Tracks\"];\n\nexport const MobileBar: FC<MobileBarProps> = ({ activeBar, setActiveBar }) => {\n    return (\n        <Flex w=\"100%\" justify=\"space-around\" align=\"center\" px=\"sm\">\n            {MOBILE_BARS.map((bar) => (\n                <Button\n                    key={bar}\n                    my=\"sm\"\n                    variant=\"transparent\"\n                    c={bar === activeBar ? \"air\" : \"white\"}\n                    onClick={() => setActiveBar(bar)}\n                >\n                    {bar}\n                </Button>\n            ))}\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "web/studio/src/page/MobilePage.tsx",
    "content": "import { Flex } from \"@mantine/core\";\nimport { useState } from \"react\";\nimport { MobileBar } from \"./MobileBar\";\nimport { Playback } from \"./Playback\";\nimport { TrackLibrary } from \"./TracksLibrary\";\nimport { TrackQueue } from \"./TracksQueue\";\n\nconst MobilePage = () => {\n    const [activeBar, setActiveBar] = useState(\"Playback\");\n    const isVisible = (bar: string) => (bar === activeBar ? \"block\" : \"none\");\n\n    return (\n        <Flex direction=\"column\" h=\"100vh\">\n            <div style={{ flex: 1, display: isVisible(\"Playback\") }}>\n                <Playback isMobile />\n            </div>\n            <div style={{ flex: 1, display: isVisible(\"Queue\") }}>\n                <TrackQueue isMobile />\n            </div>\n            <div style={{ flex: 1, display: isVisible(\"Tracks\") }}>\n                <TrackLibrary isMobile />\n            </div>\n\n            <MobileBar activeBar={activeBar} setActiveBar={setActiveBar} />\n        </Flex>\n    );\n};\n\nexport default MobilePage;\n"
  },
  {
    "path": "web/studio/src/page/Playback.tsx",
    "content": "import {\n    ActionIcon,\n    Box,\n    Flex,\n    MantineSize,\n    Paper,\n    Progress,\n    Space,\n    Text,\n    Tooltip,\n    useMantineTheme,\n} from \"@mantine/core\";\nimport { FC, useEffect, useRef, useState } from \"react\";\nimport { airstationAPI, API_HOST } from \"../api\";\nimport { usePlaybackStore } from \"../store/playback\";\nimport { formatTime } from \"../utils/time\";\nimport { useTrackQueueStore } from \"../store/track-queue\";\nimport { IconHeadphones, IconPlayerPlayFilled, IconPlayerStopFilled, IconVolumeOff, IconVolumeOn } from \"../icons\";\nimport { useDisclosure } from \"@mantine/hooks\";\nimport { errNotify } from \"../notifications\";\nimport { EVENTS, useEventSourceStore } from \"../store/events\";\nimport { PlaybackState } from \"../api/types\";\nimport { SettingsModal } from \"./Settings\";\nimport Hls from \"hls.js\";\nimport { modals } from \"@mantine/modals\";\nimport styles from \"./styles.module.css\";\n\nexport const Playback: FC<{ isMobile?: boolean }> = (props) => {\n    const updateIntervalID = useRef(0);\n    const [loader, handLoader] = useDisclosure(false);\n    const playback = usePlaybackStore((s) => s.playback);\n    const setPlayback = usePlaybackStore((s) => s.setPlayback);\n    const fetchPlayback = usePlaybackStore((s) => s.fetchPlayback);\n    const syncElapsedTime = usePlaybackStore((s) => s.syncElapsedTime);\n    const rotateQueue = useTrackQueueStore((s) => s.rotateQueue);\n    const addEventHandler = useEventSourceStore((s) => s.addEventHandler);\n    const theme = useMantineTheme();\n\n    useEffect(() => {\n        (async () => {\n            await fetchPlayback();\n        })();\n\n        addEventHandler(EVENTS.newTrack, async () => {\n            rotateQueue();\n            await fetchPlayback();\n        });\n\n        if (!updateIntervalID.current) {\n            updateIntervalID.current = setInterval(() => {\n                syncElapsedTime();\n            }, 1000);\n        }\n\n        return () => {\n            if (updateIntervalID.current) {\n                clearInterval(updateIntervalID.current);\n                updateIntervalID.current = 0;\n            }\n        };\n    }, []);\n\n    const togglePlayback = async () => {\n        handLoader.open();\n        try {\n            const pb = playback.isPlaying ? await airstationAPI.pausePlayback() : await airstationAPI.playPlayback();\n            setPlayback(pb);\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            handLoader.close();\n        }\n    };\n\n    const handlePlaybackAction = () => {\n        if (!playback.isPlaying) {\n            togglePlayback();\n            return;\n        }\n\n        modals.openConfirmModal({\n            title: \"Confirm stop playback\",\n            cancelProps: { variant: \"light\", color: \"gray\" },\n            centered: true,\n            children: (\n                <Text size=\"sm\">\n                    Do you really want to stop playing tracks on the station? This action will affect all listeners.\n                </Text>\n            ),\n            labels: { confirm: \"Confirm\", cancel: \"Cancel\" },\n            onConfirm: () => togglePlayback(),\n        });\n    };\n\n    return (\n        <Paper w=\"100%\" radius=\"md\" className={styles.transparent_paper}>\n            <Flex\n                p=\"sm\"\n                gap=\"md\"\n                w=\"100%\"\n                h={props.isMobile ? \"calc(100vh - 60px)\" : undefined}\n                direction={props.isMobile ? \"column-reverse\" : \"row\"}\n                justify={props.isMobile ? \"space-between\" : \"cener\"}\n                align=\"center\"\n            >\n                {props.isMobile ? <Box h=\"1\" /> : null}\n                <Flex gap={props.isMobile ? \"lg\" : \"xs\"} direction=\"column\" justify=\"center\" align=\"center\">\n                    <Tooltip openDelay={500} label={`${playback.isPlaying ? \"Stop\" : \"Start\"} playback of the stream`}>\n                        <ActionIcon\n                            onClick={handlePlaybackAction}\n                            disabled={loader}\n                            color=\"dark\"\n                            variant=\"subtle\"\n                            size={props.isMobile ? 100 : \"md\"}\n                            aria-label=\"Settings\"\n                        >\n                            {playback?.isPlaying ? (\n                                <IconPlayerStopFilled stroke=\"0\" fill={theme.colors[theme.primaryColor][8]} />\n                            ) : (\n                                <IconPlayerPlayFilled stroke=\"0\" fill={theme.colors.dark[8]} />\n                            )}\n                        </ActionIcon>\n                    </Tooltip>\n                    <StreamToggler size={props.isMobile ? \"xl\" : \"md\"} playback={playback} />\n                </Flex>\n                <Flex w=\"100%\" direction=\"column\">\n                    <Flex justify=\"space-between\" direction={props.isMobile ? \"column-reverse\" : \"row\"} gap=\"sm\">\n                        {playback.isPlaying ? (\n                            <Text>{playback?.currentTrack?.name}</Text>\n                        ) : (\n                            <Text c=\"dimmed\">Stream is stopped</Text>\n                        )}\n                        <SettingsModal />\n                    </Flex>\n                    <Space h={10} />\n                    <Box>\n                        <Progress\n                            radius=\"xl\"\n                            value={(playback.currentTrackElapsed / (playback?.currentTrack?.duration || 1)) * 100}\n                        />\n                        <Flex justify=\"space-between\" align=\"center\" mt={4}>\n                            <Text ta=\"end\" mt={3} c=\"dimmed\">\n                                {`${formatTime(playback?.currentTrackElapsed || 0)} / ${formatTime(playback?.currentTrack?.duration || 0)}`}\n                            </Text>\n                            <ListenerCounter />\n                        </Flex>\n                    </Box>\n                </Flex>\n            </Flex>\n        </Paper>\n    );\n};\n\nconst ListenerCounter = () => {\n    const [count, setCount] = useState(0);\n    const addEventHandler = useEventSourceStore((s) => s.addEventHandler);\n\n    const handleCounter = (msg: MessageEvent<string>) => {\n        setCount(Number(msg.data));\n    };\n\n    useEffect(() => {\n        addEventHandler(EVENTS.countListeners, handleCounter);\n    }, []);\n\n    return (\n        <Tooltip openDelay={500} label={`Listener counter`}>\n            <Flex gap={5} justify=\"center\" align=\"center\" opacity={0.5}>\n                <IconHeadphones size={18} />\n                <Text>{!count ? \"\" : count}</Text>\n            </Flex>\n        </Tooltip>\n    );\n};\n\nconst StreamToggler: FC<{ playback: PlaybackState; size: MantineSize }> = (props) => {\n    const videoRef = useRef<HTMLAudioElement | null>(null);\n    const streamRef = useRef<Hls | null>(null);\n    const [isPlaying, setIsPlaying] = useState(false);\n\n    const initStream = () => {\n        if (isPlaying) return;\n\n        streamRef.current = new Hls();\n        streamRef.current.loadSource(API_HOST + \"/stream\");\n        streamRef.current.attachMedia(videoRef.current as unknown as HTMLMediaElement);\n    };\n\n    const destroyStream = () => {\n        streamRef.current?.destroy();\n        streamRef.current = null;\n        setIsPlaying(false);\n    };\n\n    const handlePause = () => {\n        videoRef.current?.pause();\n        destroyStream();\n    };\n\n    const handlePlay = async () => {\n        try {\n            initStream();\n            await videoRef.current?.play();\n            setIsPlaying(true);\n        } catch (error) {\n            console.log(\"Failed to play: \", error);\n        }\n    };\n\n    useEffect(() => {\n        if (!props.playback.isPlaying && streamRef.current) {\n            destroyStream();\n        }\n    }, [props.playback]);\n\n    return (\n        <>\n            <audio\n                style={{ display: \"none\" }}\n                onPause={handlePause}\n                onPlay={handlePlay}\n                id=\"stream\"\n                ref={videoRef}\n            ></audio>\n            <Tooltip openDelay={500} label={`${isPlaying ? \"Mute\" : \"Unmute\"} the stream just for me`}>\n                <ActionIcon\n                    variant=\"subtle\"\n                    onClick={isPlaying ? () => videoRef.current?.pause() : () => videoRef.current?.play()}\n                    color=\"gray\"\n                    size={props.size}\n                    aria-label=\"Settings\"\n                >\n                    {isPlaying ? <IconVolumeOff size={20} /> : <IconVolumeOn size={20} />}\n                </ActionIcon>\n            </Tooltip>\n        </>\n    );\n};\n"
  },
  {
    "path": "web/studio/src/page/Playlists.tsx",
    "content": "import { FC, useEffect, useState } from \"react\";\nimport { useDebouncedState, useDisclosure } from \"@mantine/hooks\";\nimport {\n    Modal,\n    Flex,\n    Box,\n    Button,\n    Tooltip,\n    ActionIcon,\n    TextInput,\n    Textarea,\n    Text,\n    LoadingOverlay,\n    CloseButton,\n    Menu,\n    Select,\n} from \"@mantine/core\";\nimport { modals } from \"@mantine/modals\";\nimport { CSS } from \"@dnd-kit/utilities\";\nimport { DndContext, DragEndEvent } from \"@dnd-kit/core\";\nimport { restrictToVerticalAxis } from \"@dnd-kit/modifiers\";\nimport { SortableContext, useSortable } from \"@dnd-kit/sortable\";\nimport { airstationAPI } from \"../api\";\nimport { formatTime } from \"../utils/time\";\nimport { Playlist, Track } from \"../api/types\";\nimport { moveArrayItem } from \"../utils/array\";\nimport { usePlaybackStore } from \"../store/playback\";\nimport { usePlaylistStore } from \"../store/playlists\";\nimport { EmptyLabel } from \"../components/EmptyLabel\";\nimport { useTrackQueueStore } from \"../store/track-queue\";\nimport { errNotify, okNotify, warnNotify } from \"../notifications\";\nimport { IconPlayerPlayFilled, IconPlaylist, IconTrash } from \"../icons\";\n\nconst PlaylistItem: FC<{ data: Playlist; confirmDelete: (id: string) => void }> = ({ data, confirmDelete }) => {\n    const [hovered, setHovered] = useState(false);\n    const updateQueue = useTrackQueueStore((s) => s.updateQueue);\n    const pausePlayback = usePlaybackStore((s) => s.pause);\n    const playPlayback = usePlaybackStore((s) => s.play);\n\n    const handleUse = async () => {\n        try {\n            const p = await airstationAPI.getPlaylist(data.id);\n            if (!p.tracks.length) {\n                warnNotify(\"Playlist is empty\");\n                return;\n            }\n\n            await pausePlayback();\n            await updateQueue(p.tracks);\n            await playPlayback();\n\n            okNotify(\"Playlist added to a queue\");\n        } catch (error) {\n            errNotify(error);\n        }\n    };\n\n    return (\n        <Flex\n            align=\"center\"\n            justify=\"space-between\"\n            gap=\"sm\"\n            onMouseEnter={() => setHovered(true)}\n            onMouseLeave={() => setHovered(false)}\n            w=\"100%\"\n        >\n            <Flex gap=\"xs\" w=\"100%\">\n                <PlaylistModal data={data} hovered={hovered} setHovered={setHovered} />\n                <Flex gap={5}>\n                    <Tooltip label=\"Delete playlist\">\n                        <ActionIcon variant=\"transparent\" color=\"red\" onClick={() => confirmDelete(data.id)}>\n                            <IconTrash size={18} />\n                        </ActionIcon>\n                    </Tooltip>\n                    <Tooltip label=\"Put it in a queue\">\n                        <ActionIcon onClick={handleUse} disabled={!data.trackCount} variant=\"transparent\" color=\"green\">\n                            <IconPlayerPlayFilled size={18} />\n                        </ActionIcon>\n                    </Tooltip>\n                </Flex>\n            </Flex>\n        </Flex>\n    );\n};\n\nconst TrackItem: FC<{\n    track: Track;\n    index: number;\n    setTracks: React.Dispatch<React.SetStateAction<Track[]>>;\n}> = ({ index, setTracks, track }) => {\n    const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: track.id });\n    const style = { transform: CSS.Transform.toString(transform), transition };\n\n    return (\n        <Flex ref={setNodeRef} p=\"0.3rem\" gap=\"sm\" align=\"start\" style={style}>\n            <Flex {...attributes} {...listeners} style={{ cursor: \"grab\" }} w=\"100%\">\n                <Text lh=\"xs\" w=\"100%\">\n                    {`${index + 1}. ${track.name}`}\n                </Text>\n                <Text c=\"dimmed\">{formatTime(Math.round(track.duration))}</Text>\n            </Flex>\n            <CloseButton onClick={() => setTracks((prev) => prev.filter((pt) => pt.id !== track.id))} />\n        </Flex>\n    );\n};\n\nconst TrackList: FC<{\n    tracks: Track[];\n    setTracks: React.Dispatch<React.SetStateAction<Track[]>>;\n}> = ({ tracks, setTracks }) => {\n    const handleDragEvent = (event: DragEndEvent) => {\n        const { active, over } = event;\n        if (over && active.id !== over.id) {\n            setTracks((tracks) => {\n                const fromIndex = tracks.findIndex((t) => t.id === active.id);\n                const toIndex = tracks.findIndex((t) => t.id === over.id);\n                return moveArrayItem(tracks, fromIndex, toIndex);\n            });\n        }\n    };\n\n    return (\n        <DndContext modifiers={[restrictToVerticalAxis]} onDragEnd={handleDragEvent}>\n            <SortableContext items={tracks}>\n                {tracks.map((track, index) => (\n                    <TrackItem key={track.id} track={track} index={index} setTracks={setTracks} />\n                ))}\n            </SortableContext>\n        </DndContext>\n    );\n};\n\nconst PlaylistModal: FC<{\n    data: Playlist;\n    hovered: boolean;\n    setHovered: React.Dispatch<React.SetStateAction<boolean>>;\n}> = ({ data, hovered, setHovered }) => {\n    const [opened, { open, close }] = useDisclosure(false);\n    const [isLoading, loading] = useDisclosure(false);\n    const [tracks, setTracks] = useState<Track[]>([]);\n    const [name, setName] = useState(data.name);\n    const [descr, setDescr] = useState(data.description);\n    const editPlaylist = usePlaylistStore((s) => s.editPlaylist);\n\n    const loadTracks = async () => {\n        loading.open();\n        try {\n            const p = await airstationAPI.getPlaylist(data.id);\n            setTracks(p.tracks || []);\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            loading.close();\n        }\n    };\n\n    const handleSave = async () => {\n        loading.open();\n        try {\n            const { message } = await editPlaylist(\n                data.id,\n                name,\n                tracks.map((t) => t.id),\n                descr,\n            );\n            okNotify(message);\n            close();\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            loading.close();\n        }\n    };\n\n    const handleOpen = () => {\n        open();\n        setHovered(false);\n    };\n\n    useEffect(() => {\n        if (opened) loadTracks();\n    }, [opened]);\n\n    return (\n        <>\n            <Modal size=\"lg\" centered opened={opened} onClose={close} withCloseButton={false}>\n                <LoadingOverlay visible={isLoading} overlayProps={{ radius: \"md\" }} />\n                <Box>\n                    <TextInput\n                        required\n                        minLength={3}\n                        placeholder=\"Name*\"\n                        value={name}\n                        onChange={(event) => setName(event.currentTarget.value)}\n                    />\n                    <Textarea\n                        mt=\"sm\"\n                        placeholder=\"Description\"\n                        maxLength={4096}\n                        minRows={1}\n                        autosize\n                        maxRows={4}\n                        value={descr}\n                        onChange={(event) => setDescr(event.currentTarget.value)}\n                    />\n                    <Box\n                        mt=\"md\"\n                        flex={1}\n                        mih={200}\n                        mah={500}\n                        style={{\n                            overflowX: \"hidden\",\n                            overflowY: \"auto\",\n                            scrollbarGutter: \"stable\",\n                        }}\n                    >\n                        <TrackList tracks={tracks} setTracks={setTracks} />\n                        {!tracks.length ? <EmptyLabel label=\"No tracks\" /> : null}\n                    </Box>\n                </Box>\n\n                <Flex mt=\"md\" justify=\"space-between\" align=\"center\" gap=\"sm\">\n                    <Flex justify=\"end\">\n                        <Text>Total time:&nbsp;</Text>\n                        <Text c=\"dimmed\">\n                            {formatTime(Math.round(tracks.reduce((prev, curr) => (prev += curr.duration), 0)))}\n                        </Text>\n                    </Flex>\n                    <Flex gap=\"sm\">\n                        <Button autoFocus onClick={close} color=\"dimmed\" variant=\"light\">\n                            Close\n                        </Button>\n                        <Button onClick={handleSave} color=\"green\" variant=\"light\">\n                            Save\n                        </Button>\n                    </Flex>\n                </Flex>\n            </Modal>\n            <Flex w=\"100%\" gap=\"sm\" onClick={handleOpen} style={{ cursor: \"pointer\" }}>\n                <Text style={{ textWrap: \"nowrap\" }} c={hovered ? \"blue\" : undefined}>\n                    {data.name}\n                </Text>\n                <Text w=\"100%\" c=\"dimmed\">\n                    {data.trackCount} {data.trackCount === 1 ? \"track\" : \"tracks\"}\n                </Text>\n            </Flex>\n        </>\n    );\n};\n\nconst CreatePlaylistModal: FC<{}> = () => {\n    const [opened, { open, close }] = useDisclosure(false);\n    const [isLoading, loading] = useDisclosure(false);\n    const [name, setName] = useState(\"\");\n    const [descr, setDescr] = useState(\"\");\n    const addPlaylist = usePlaylistStore((s) => s.addPlaylist);\n\n    const handleCreate = async () => {\n        loading.open();\n        try {\n            await addPlaylist(name, [], descr);\n            okNotify(\"A new playlist has been successfully created.\");\n            close();\n            setName(\"\");\n            setDescr(\"\");\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            loading.close();\n        }\n    };\n\n    return (\n        <>\n            <Modal centered opened={opened} onClose={close} title=\"New playlist\">\n                <Flex direction=\"column\" gap=\"md\">\n                    <TextInput\n                        required\n                        placeholder=\"Name*\"\n                        value={name}\n                        onChange={(event) => setName(event.currentTarget.value)}\n                    />\n                    <Textarea\n                        placeholder=\"Description\"\n                        maxLength={4096}\n                        rows={4}\n                        value={descr}\n                        onChange={(event) => setDescr(event.currentTarget.value)}\n                    />\n                    <Button loading={isLoading} variant=\"light\" disabled={name.length < 3} onClick={handleCreate}>\n                        Create\n                    </Button>\n                </Flex>\n            </Modal>\n\n            <Button onClick={open} variant=\"light\" color=\"green\">\n                New playlist\n            </Button>\n        </>\n    );\n};\n\nexport const PlaylistsModal: FC<{}> = () => {\n    const [opened, { open, close }] = useDisclosure(false);\n    const playlists = usePlaylistStore((s) => s.playlists);\n    const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists);\n    const deletePlaylist = usePlaylistStore((s) => s.deletePlaylist);\n    const [search, setSearch] = useDebouncedState(\"\", 200);\n    const [isLoading, loading] = useDisclosure(true);\n\n    const loadPlaylists = async () => {\n        loading.open();\n        try {\n            await fetchPlaylists();\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            loading.close();\n        }\n    };\n\n    const handleDelete = async (id: string) => {\n        loading.open();\n        try {\n            const { message } = await deletePlaylist(id);\n            okNotify(message);\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            loading.close();\n        }\n    };\n\n    const confirmDelete = (id: string) => {\n        modals.openConfirmModal({\n            title: \"Confirm delete playlist\",\n            cancelProps: { variant: \"light\", color: \"gray\" },\n            centered: true,\n            children: (\n                <Text size=\"sm\">\n                    Do you really want to delete this playlist? All tracks from the playlist will still be available in\n                    the library.\n                </Text>\n            ),\n            labels: { confirm: \"Confirm\", cancel: \"Cancel\" },\n            onConfirm: () => handleDelete(id),\n        });\n    };\n\n    useEffect(() => {\n        loadPlaylists();\n    }, []);\n\n    return (\n        <>\n            <Modal centered size=\"lg\" opened={opened} onClose={close} withCloseButton={false} radius=\"md\">\n                <Flex direction=\"column\" gap=\"md\">\n                    <Text>Playlists</Text>\n                    {playlists.length > 7 ? (\n                        <TextInput\n                            defaultValue={search}\n                            onChange={(event) => setSearch(event.currentTarget.value)}\n                            placeholder=\"Search...\"\n                        />\n                    ) : null}\n                    <Box flex={1} mih={200} mah=\"90vh\" style={{ overflowY: \"auto\" }}>\n                        <LoadingOverlay visible={isLoading} overlayProps={{ radius: \"md\", opacity: 0.7 }} />\n                        {playlists\n                            .filter((p) => p.name.toLowerCase().includes(search))\n                            .map((p) => (\n                                <PlaylistItem key={p.id} data={p} confirmDelete={confirmDelete} />\n                            ))}\n                        {!playlists.filter((p) => p.name.toLowerCase().includes(search)).length ? (\n                            <EmptyLabel label=\"No playlists\" />\n                        ) : null}\n                    </Box>\n                    <Flex justify=\"end\" gap=\"sm\">\n                        <Button onClick={close} color=\"dimmed\" variant=\"light\">\n                            Close\n                        </Button>\n                        <CreatePlaylistModal />\n                    </Flex>\n                </Flex>\n            </Modal>\n\n            <Tooltip openDelay={500} label=\"Playlists\">\n                <ActionIcon onClick={open} variant=\"transparent\" size=\"md\">\n                    <IconPlaylist size={18} color=\"gray\" />\n                </ActionIcon>\n            </Tooltip>\n        </>\n    );\n};\n\nexport const AddToPalylistModal: FC<{ trackIDs: string[] }> = ({ trackIDs }) => {\n    const [opened, { open, close }] = useDisclosure(false);\n    const playlists = usePlaylistStore((s) => s.playlists);\n    const [selected, setSelected] = useState<string | null>(null);\n    const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists);\n    const editPlaylist = usePlaylistStore((s) => s.editPlaylist);\n    const [isLoading, loading] = useDisclosure(true);\n\n    const loadPlaylists = async () => {\n        loading.open();\n        try {\n            await fetchPlaylists();\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            loading.close();\n        }\n    };\n\n    const handleAppendToPlaylist = async () => {\n        loading.open();\n        try {\n            if (!selected) return;\n            const p = await airstationAPI.getPlaylist(selected);\n            const { message } = await editPlaylist(\n                p.id,\n                p.name,\n                [...new Set([...(p.tracks || [])?.map((t) => t.id), ...trackIDs])],\n                p.description,\n            );\n            okNotify(message);\n            close();\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            loading.close();\n        }\n    };\n\n    useEffect(() => {\n        if (opened) loadPlaylists();\n    }, [opened]);\n\n    return (\n        <>\n            <Modal size=\"sm\" centered onClose={close} opened={opened} withCloseButton={false}>\n                <LoadingOverlay visible={isLoading} overlayProps={{ radius: \"md\" }} />\n                <Select\n                    searchable\n                    placeholder=\"Select a playlist\"\n                    value={selected}\n                    onChange={setSelected}\n                    data={[{ group: \"\", items: playlists.map((p) => ({ label: p.name, value: p.id })) }]}\n                />\n                <Flex mt=\"md\" gap=\"sm\" justify=\"end\">\n                    <Button onClick={close} color=\"dimmed\" variant=\"light\">\n                        Cancel\n                    </Button>\n                    <Button disabled={!selected} variant=\"light\" onClick={handleAppendToPlaylist}>\n                        Confirm\n                    </Button>\n                </Flex>\n            </Modal>\n            <Menu.Item onClick={open} leftSection={<IconPlaylist size={14} />}>\n                Add to playlist\n            </Menu.Item>\n        </>\n    );\n};\n"
  },
  {
    "path": "web/studio/src/page/Settings.tsx",
    "content": "import { FC, useEffect, useState } from \"react\";\nimport { useDisclosure } from \"@mantine/hooks\";\nimport {\n    Accordion,\n    ActionIcon,\n    Button,\n    Box,\n    Flex,\n    Group,\n    Modal,\n    Textarea,\n    TextInput,\n    Tooltip,\n    Text,\n    Slider,\n    ColorInput,\n    Select,\n} from \"@mantine/core\";\nimport { IconSettings } from \"../icons\";\nimport { useSettingsStore } from \"../store/settings\";\nimport { MAX_MOBILE_WIDTH, useIsMobile } from \"../hooks/useIsMobile\";\nimport { useForm } from \"@mantine/form\";\nimport { StationInfo } from \"../api/types\";\nimport { airstationAPI } from \"../api\";\nimport { errNotify, okNotify } from \"../notifications\";\n\nexport const SettingsModal: FC<{}> = () => {\n    const [opened, { open, close }] = useDisclosure(false);\n    const [loading, setLoading] = useState(false);\n    const { isMobile } = useIsMobile();\n\n    const interfaceWidth = useSettingsStore((s) => s.interfaceWidth);\n    const setInterfaceWidth = useSettingsStore((s) => s.setInterfaceWidth);\n    const stationInfo = useForm<StationInfo>({\n        initialValues: {\n            name: \"\",\n            description: \"\",\n            faviconURL: \"\",\n            logoURL: \"\",\n            location: \"\",\n            timezone: \"\",\n            links: \"\",\n            theme: \"\",\n        },\n    });\n\n    const loadStationInfo = async () => {\n        setLoading(true);\n        try {\n            const info = await airstationAPI.getStationInfo();\n            stationInfo.setValues(info);\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            setLoading(false);\n        }\n    };\n\n    const saveStationInfo = async (overrides?: Partial<StationInfo>) => {\n        setLoading(true);\n        try {\n            const info = await airstationAPI.editStationInfo({ ...stationInfo.values, ...overrides });\n            stationInfo.setValues(info);\n            okNotify(\"Saved successfully\");\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            setLoading(false);\n        }\n    };\n\n    useEffect(() => {\n        loadStationInfo();\n    }, []);\n\n    return (\n        <>\n            <Modal\n                title=\"Settings\"\n                p={0}\n                centered\n                size=\"lg\"\n                opened={opened}\n                onClose={close}\n                withCloseButton\n                radius=\"md\"\n            >\n                <Accordion defaultValue=\"station_info\" variant=\"filled\">\n                    <Accordion.Item value=\"station_info\">\n                        <Accordion.Control>Station info</Accordion.Control>\n                        <Accordion.Panel>\n                            <TextInput\n                                label=\"Name\"\n                                placeholder=\"Enter name\"\n                                key={stationInfo.key(\"name\")}\n                                {...stationInfo.getInputProps(\"name\")}\n                            />\n                            <Textarea\n                                rows={4}\n                                maxRows={6}\n                                label=\"Description\"\n                                placeholder=\"Enter description\"\n                                mt=\"sm\"\n                                key={stationInfo.key(\"description\")}\n                                {...stationInfo.getInputProps(\"description\")}\n                            />\n                            <Flex mt=\"sm\" gap=\"xs\">\n                                <TextInput\n                                    w=\"100%\"\n                                    label=\"Location\"\n                                    placeholder=\"Enter location\"\n                                    key={stationInfo.key(\"location\")}\n                                    {...stationInfo.getInputProps(\"location\")}\n                                />\n                                <TextInput\n                                    w=\"100%\"\n                                    label=\"Timezone\"\n                                    placeholder=\"Enter timezone\"\n                                    key={stationInfo.key(\"timezone\")}\n                                    {...stationInfo.getInputProps(\"timezone\")}\n                                />\n                            </Flex>\n                            <Flex mt=\"sm\" gap=\"xs\">\n                                <TextInput\n                                    w=\"100%\"\n                                    label=\"Favicon\"\n                                    placeholder=\"Enter URL\"\n                                    key={stationInfo.key(\"faviconURL\")}\n                                    {...stationInfo.getInputProps(\"faviconURL\")}\n                                />\n                                <TextInput\n                                    w=\"100%\"\n                                    label=\"Logo\"\n                                    placeholder=\"Enter URL\"\n                                    key={stationInfo.key(\"logoURL\")}\n                                    {...stationInfo.getInputProps(\"logoURL\")}\n                                />\n                            </Flex>\n\n                            <Textarea\n                                spellCheck={false}\n                                rows={4}\n                                maxRows={6}\n                                label=\"Links\"\n                                placeholder={`Add some links to your socials in format [TITLE](URL)`}\n                                mt=\"sm\"\n                                key={stationInfo.key(\"links\")}\n                                {...stationInfo.getInputProps(\"links\")}\n                            />\n\n                            <Group mt=\"md\" justify=\"flex-end\">\n                                <Button loading={loading} onClick={() => saveStationInfo()}>\n                                    Save\n                                </Button>\n                            </Group>\n                        </Accordion.Panel>\n                    </Accordion.Item>\n\n                    <Accordion.Item value=\"player_theme\">\n                        <Accordion.Control>Player theme</Accordion.Control>\n                        <Accordion.Panel>\n                            <PlayerThemeSetup\n                                defaultTheme={stationInfo.values.theme}\n                                loading={loading}\n                                saveStationInfo={saveStationInfo}\n                            />\n                        </Accordion.Panel>\n                    </Accordion.Item>\n\n                    {!isMobile && (\n                        <Accordion.Item value=\"studio_interface\">\n                            <Accordion.Control>Studio interface</Accordion.Control>\n                            <Accordion.Panel>\n                                <Box>\n                                    <Text size=\"sm\">Interface width in pixels</Text>\n                                    <Text size=\"xs\" c=\"dimmed\">\n                                        Determines how wide the interface will be.\n                                    </Text>\n                                    <Slider\n                                        value={interfaceWidth}\n                                        onChange={setInterfaceWidth}\n                                        min={MAX_MOBILE_WIDTH}\n                                        max={window.screen.width}\n                                        step={10}\n                                    />\n                                </Box>\n                            </Accordion.Panel>\n                        </Accordion.Item>\n                    )}\n                </Accordion>\n            </Modal>\n\n            <Tooltip openDelay={500} label=\"Settings\">\n                <ActionIcon onClick={open} variant=\"transparent\" size=\"md\">\n                    <IconSettings size={18} color=\"gray\" />\n                </ActionIcon>\n            </Tooltip>\n        </>\n    );\n};\n\nconst defaultSwatches = [\n    \"#2e2e2e\",\n    \"#868e96\",\n    \"#fa5252\",\n    \"#e64980\",\n    \"#be4bdb\",\n    \"#7950f2\",\n    \"#4c6ef5\",\n    \"#228be6\",\n    \"#15aabf\",\n    \"#12b886\",\n    \"#40c057\",\n    \"#82c91e\",\n    \"#fab005\",\n    \"#fd7e14\",\n];\n\nconst customThemeName = \"Custom\";\nconst predefinedThemes = {\n    Airstation: \"#29323c;#485563;#a8a8a8;#ffffff;;\",\n    \"Deep ocean\": \"#0f2027;#2c5364;#4a90a5;#e6f7ff;#00d4ff;\",\n    Graphite: \"#2d3436;#636e72;#818588;#dfe6e9;;\",\n    \"Sandy beach\": \"#f7c59f;#efd9b4;#c8a77a;#4a3c2a;#e76f51;\",\n    \"Fresh mint\": \"#134e5e;#71b280;#5d9170;#e9f5e9;#5af55a;\",\n    \"Misty forest\":\n        \"#2f3f4d;#bcc1cd;#2f3f4d;#ffffff;;https://images.unsplash.com/photo-1487621167305-5d248087c724?q=80\\u0026w=1932\\u0026auto=format\\u0026fit=crop\\u0026ixlib=rb-4.1.0\\u0026ixid=\",\n    Hackerman: \"#000000;#000000;#04e600;#04e600;#04e600;\",\n    \"Just dark\": \"#000000;#000000;#a8a8a8;#ffffff;;\",\n    \"Just light\": \"#ffffff;#ffffff;#a8a8a8;#000000;;\",\n};\n\nconst PlayerThemeSetup: FC<{\n    defaultTheme: string;\n    loading: boolean;\n    saveStationInfo: (overrides?: Partial<StationInfo> | undefined) => Promise<void>;\n}> = ({ defaultTheme, loading, saveStationInfo }) => {\n    const parsedDefaultTheme = defaultTheme.split(\";\");\n\n    const [theme, setTheme] = useState<string>(customThemeName);\n    const [bgStart, setBgStart] = useState(parsedDefaultTheme[0] || \"\");\n    const [bgEnd, setBgEnd] = useState(parsedDefaultTheme[1] || \"\");\n    const [iconsColor, setIconsColor] = useState(parsedDefaultTheme[2] || \"\");\n    const [textColor, setTextColor] = useState(parsedDefaultTheme[3] || \"\");\n    const [accentColor, setAccentColor] = useState(parsedDefaultTheme[4] || \"\");\n    const [bgImage, setBgImage] = useState(parsedDefaultTheme[5] || \"\");\n\n    const getThemeString = () => {\n        return `${bgStart};${bgEnd};${iconsColor};${textColor};${accentColor};${bgImage}`;\n    };\n\n    const handleSave = async () => {\n        await saveStationInfo({ theme: getThemeString() });\n    };\n\n    const handlePredefinedTheme = (value: string | null) => {\n        if (value == null) {\n            setTheme(customThemeName);\n            return;\n        }\n\n        const pt = predefinedThemes[value as keyof typeof predefinedThemes];\n        if (pt) {\n            const parsed = pt.split(\";\");\n            setBgStart(parsed[0]);\n            setBgEnd(parsed[1]);\n            setIconsColor(parsed[2]);\n            setTextColor(parsed[3]);\n            setAccentColor(parsed[4]);\n            setBgImage(parsed[5]);\n        }\n\n        setTheme(value);\n    };\n\n    const defineThemeName = () => {\n        const themeString = `${bgStart};${bgEnd};${iconsColor};${textColor};${accentColor};${bgImage}`;\n\n        if (Object.values(predefinedThemes).includes(themeString)) {\n            setTheme(\n                Object.keys(predefinedThemes).find(\n                    (key) => predefinedThemes[key as keyof typeof predefinedThemes] === themeString,\n                ) || customThemeName,\n            );\n        } else {\n            setTheme(customThemeName);\n        }\n    };\n\n    useEffect(() => {\n        defineThemeName();\n    }, [bgStart, bgEnd, iconsColor, textColor, accentColor, bgImage]);\n\n    return (\n        <Flex direction=\"column\" gap=\"sm\">\n            <Text size=\"sm\" c=\"dimmed\">\n                Here you can customize the appearance of the station's player page.\n            </Text>\n\n            <Select\n                label=\"Select pre-defined theme\"\n                placeholder=\"Pre-defined theme\"\n                value={theme}\n                onChange={handlePredefinedTheme}\n                data={[...Object.keys(predefinedThemes), customThemeName]}\n            />\n\n            <Text size=\"xs\" c=\"dimmed\">\n                Below you can define the color for the background. Two colors are needed to create a gradient effect. If\n                you need a solid color, just repeat it 2 times.\n            </Text>\n            <Flex gap=\"sm\">\n                <ColorInput\n                    w=\"100%\"\n                    label=\"Background start\"\n                    placeholder=\"Bottom color\"\n                    format=\"hex\"\n                    value={bgStart}\n                    onChange={setBgStart}\n                    swatches={defaultSwatches}\n                />\n                <ColorInput\n                    w=\"100%\"\n                    label=\"Background end\"\n                    placeholder=\"Top color\"\n                    format=\"hex\"\n                    value={bgEnd}\n                    onChange={setBgEnd}\n                    swatches={defaultSwatches}\n                />\n            </Flex>\n            <Flex gap=\"sm\">\n                <ColorInput\n                    w=\"100%\"\n                    label=\"Icons color\"\n                    placeholder=\"Color for icons\"\n                    format=\"hex\"\n                    value={iconsColor}\n                    onChange={setIconsColor}\n                    swatches={defaultSwatches}\n                />\n                <ColorInput\n                    w=\"100%\"\n                    label=\"Text color\"\n                    placeholder=\"Color for text\"\n                    format=\"hex\"\n                    value={textColor}\n                    onChange={setTextColor}\n                    swatches={defaultSwatches}\n                />\n            </Flex>\n\n            <ColorInput\n                w=\"100%\"\n                label=\"Accent color\"\n                placeholder=\"Color for music visualizer\"\n                description=\"The color for the music visualizer when the play button is pressed. If the value is empty, the visualizer will sparkle with multicolored shades.\"\n                format=\"hex\"\n                value={accentColor}\n                onChange={setAccentColor}\n                swatches={defaultSwatches}\n            />\n            <TextInput\n                w=\"100%\"\n                label=\"Background image URL\"\n                description=\"A link to the image that will be used instead of the colored background.\"\n                placeholder=\"Enter URL\"\n                value={bgImage}\n                onChange={(e) => setBgImage(e.target.value)}\n            />\n            <Group mt=\"sm\" justify=\"flex-end\">\n                <Button loading={loading} onClick={handleSave}>\n                    Save\n                </Button>\n            </Group>\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "web/studio/src/page/TracksLibrary.tsx",
    "content": "import { FC, useCallback, useEffect, useRef, useState } from \"react\";\nimport {\n    ActionIcon,\n    Box,\n    Button,\n    Checkbox,\n    FileButton,\n    Flex,\n    Group,\n    LoadingOverlay,\n    Menu,\n    Paper,\n    Select,\n    Space,\n    Text,\n    TextInput,\n    Tooltip,\n} from \"@mantine/core\";\nimport { modals } from \"@mantine/modals\";\nimport { useDebouncedValue, useDisclosure } from \"@mantine/hooks\";\nimport { AddToPalylistModal } from \"./Playlists\";\nimport { Track } from \"../api/types\";\nimport { DisclosureHandler } from \"../types\";\nimport { useTracksStore } from \"../store/tracks\";\nimport { EmptyLabel } from \"../components/EmptyLabel\";\nimport { AudioPlayer } from \"../components/AudioPlayer\";\nimport { useTrackQueueStore } from \"../store/track-queue\";\nimport { EVENTS, useEventSourceStore } from \"../store/events\";\nimport { errNotify, infoNotify, okNotify, warnNotify } from \"../notifications\";\nimport { IconQueue, IconReload, IconSortAscending, IconSortDescending, IconTrash } from \"../icons\";\nimport styles from \"./styles.module.css\";\n\nconst PAGE_LIMIT = 20;\n\nexport const TrackLibrary: FC<{ isMobile?: boolean }> = (props) => {\n    const tracksContainerRef = useRef<HTMLDivElement>(null);\n    const [page, setPage] = useState(1);\n    const [search, setSearch] = useState(\"\");\n    const [debouncedSearch] = useDebouncedValue(search, 500);\n    const [sortBy, setSortBy] = useState<keyof Track>(\"id\");\n    const [sortOrder, setSortOrder] = useState<\"asc\" | \"desc\">(\"desc\");\n    const [playingTrackID, setPlayingTrackID] = useState(\"\");\n    const [selectedTrackIDs, setSelectedTrackIDs] = useState<Set<string>>(new Set());\n    const [loader, handLoader] = useDisclosure(false);\n    const addEventHandler = useEventSourceStore((s) => s.addEventHandler);\n    const [hovered, setHovered] = useState(false);\n\n    const tracks = useTracksStore((s) => s.tracks);\n    const totalTracks = useTracksStore((s) => s.totalTracks);\n    const queue = useTrackQueueStore((s) => s.queue);\n    const fetchTracks = useTracksStore((s) => s.fetchTracks);\n\n    const loadTracks = useCallback(async () => {\n        handLoader.open();\n        try {\n            await fetchTracks(page, PAGE_LIMIT, search, sortBy, sortOrder);\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            handLoader.close();\n        }\n    }, [page, search, sortBy, sortOrder]);\n\n    const toggleTrackPlaying = (id: string) => {\n        // If the same track is clicked again, pause it\n        // If a different track is clicked, pause the current one and play the new one\n        const newVelue = playingTrackID === id ? \"\" : id;\n        setPlayingTrackID(newVelue);\n    };\n\n    const isTrackInQueue = (trackID: string) => {\n        return queue.map((t) => t.id).includes(trackID);\n    };\n\n    const handleSort = (sb: keyof Track, so: \"asc\" | \"desc\") => {\n        setSortBy(sb);\n        setSortOrder(so);\n    };\n\n    const handleLoadNextPage = () => {\n        setPage((prev) => prev + 1);\n    };\n\n    useEffect(() => {\n        addEventHandler(EVENTS.loadedTracks, (msg: MessageEvent<string>) => {\n            setSortBy(\"id\");\n            setSortOrder(\"desc\");\n            setSearch(\"\");\n            setPage(1);\n            loadTracks();\n\n            infoNotify(`${msg.data} new track(s) are now available in your library.`);\n        });\n    }, []);\n\n    useEffect(() => {\n        setPage(1);\n    }, [debouncedSearch, sortBy, sortOrder]);\n\n    useEffect(() => {\n        loadTracks();\n    }, [page, debouncedSearch, sortBy, sortOrder]);\n\n    return (\n        <Paper radius=\"md\" className={styles.transparent_paper}>\n            <Flex p=\"xs\" direction=\"column\" h={props.isMobile ? \"calc(100vh - 60px)\" : \"80vh\"} mah={1200}>\n                <LoadingOverlay visible={loader} overlayProps={{ radius: \"md\" }} />\n\n                <Flex justify=\"space-between\" align=\"center\">\n                    <Flex align=\"center\" gap=\"xs\">\n                        <Text fw={700} size=\"lg\">\n                            Library\n                        </Text>\n                        <Text c=\"dimmed\">{`${tracks.length}/${totalTracks} ${\n                            totalTracks > 1 ? \"tracks\" : \"track\"\n                        }`}</Text>\n                    </Flex>\n\n                    <Flex align=\"center\" gap={5}>\n                        <Tooltip openDelay={500} label=\"Reload list\">\n                            <ActionIcon onClick={loadTracks} variant=\"transparent\" size=\"md\">\n                                <IconReload size={18} color=\"gray\" />\n                            </ActionIcon>\n                        </Tooltip>\n                        <Tooltip openDelay={500} label={`Sort by ${sortOrder === \"asc\" ? \"descending\" : \"ascending\"}`}>\n                            <ActionIcon\n                                onClick={() => handleSort(sortBy, sortOrder === \"asc\" ? \"desc\" : \"asc\")}\n                                variant=\"transparent\"\n                                size=\"md\"\n                            >\n                                {sortOrder === \"asc\" ? (\n                                    <IconSortAscending size={18} color=\"gray\" />\n                                ) : (\n                                    <IconSortDescending size={18} color=\"gray\" />\n                                )}\n                            </ActionIcon>\n                        </Tooltip>\n                        <Tooltip openDelay={500} label=\"Parameter by which tracks are sorted\">\n                            <Select\n                                w={90}\n                                withCheckIcon={false}\n                                variant=\"transparent\"\n                                size=\"xs\"\n                                allowDeselect={false}\n                                value={sortBy}\n                                data={[\"id\", \"name\", \"duration\"]}\n                                onChange={(value) => handleSort(value as keyof Track, sortOrder)}\n                                comboboxProps={{ offset: 0, variant: \"transparent\" }}\n                            />\n                        </Tooltip>\n                    </Flex>\n                </Flex>\n\n                <Space h={12} />\n\n                <Flex gap=\"xs\">\n                    <TextInput\n                        style={{ flexGrow: 1 }}\n                        placeholder=\"Search\"\n                        value={search}\n                        onChange={(event) => setSearch(event.currentTarget.value)}\n                    />\n                    <TrackUploader handLoader={handLoader} />\n                </Flex>\n\n                <Space h={16} />\n\n                <Box\n                    flex={1}\n                    onMouseEnter={() => setHovered(true)}\n                    onMouseLeave={() => setHovered(false)}\n                    style={{\n                        overflowX: \"hidden\",\n                        overflowY: hovered ? \"auto\" : \"hidden\",\n                        scrollbarGutter: \"stable\",\n                    }}\n                    ref={tracksContainerRef}\n                >\n                    <Flex direction=\"column\" gap=\"sm\" justify=\"center\">\n                        {tracks.length ? (\n                            <>\n                                {tracks.map((track) => (\n                                    <Paper\n                                        p=\"xs\"\n                                        key={track.id}\n                                        c={isTrackInQueue(track.id) ? \"dimmed\" : undefined}\n                                        bg=\"transparent\"\n                                    >\n                                        <AudioPlayer\n                                            track={track}\n                                            isPlaying={playingTrackID === track.id}\n                                            selected={selectedTrackIDs}\n                                            setSelected={setSelectedTrackIDs}\n                                            togglePlaying={() => toggleTrackPlaying(track.id)}\n                                        />\n                                    </Paper>\n                                ))}\n                                {Math.ceil(totalTracks / PAGE_LIMIT) > page ? (\n                                    <Button onClick={handleLoadNextPage} variant=\"transparent\">\n                                        Load more\n                                    </Button>\n                                ) : null}\n                            </>\n                        ) : (\n                            <EmptyLabel label={\"No tracks found\"} />\n                        )}\n                    </Flex>\n                </Box>\n                <Space h={12} />\n\n                <Group justify=\"space-between\">\n                    {selectedTrackIDs.size ? <Text>{`Selected: ${selectedTrackIDs.size}`}</Text> : <div />}\n                    <TrackActions\n                        handLoader={handLoader}\n                        selected={selectedTrackIDs}\n                        setSelected={setSelectedTrackIDs}\n                        freeTrackIDs={new Set(tracks.filter((t) => !isTrackInQueue(t.id)).map((t) => t.id))}\n                        tracks={tracks}\n                    />\n                </Group>\n            </Flex>\n        </Paper>\n    );\n};\n\nconst TrackUploader: FC<{ handLoader: DisclosureHandler }> = (props) => {\n    const uploadTracks = useTracksStore((s) => s.uploadTracks);\n\n    const handleUpload = async (files: File[]) => {\n        if (!files.length) {\n            warnNotify(\"No files for upload...\");\n            return;\n        }\n\n        props.handLoader.open();\n        try {\n            const { message } = await uploadTracks(files);\n            okNotify(message);\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            props.handLoader.close();\n        }\n    };\n\n    return (\n        <>\n            <FileButton multiple onChange={handleUpload} accept=\"audio/mpeg,audio/aac,audio/wav,audio/flac\">\n                {(props) => (\n                    <Button {...props} variant=\"light\" color=\"green\">\n                        Add\n                    </Button>\n                )}\n            </FileButton>\n        </>\n    );\n};\n\nconst TrackActions: FC<{\n    handLoader: DisclosureHandler;\n    selected: Set<string>;\n    setSelected: React.Dispatch<React.SetStateAction<Set<string>>>;\n    freeTrackIDs: Set<string>;\n    tracks: Track[];\n}> = (props) => {\n    const addToQueue = useTrackQueueStore((s) => s.addToQueue);\n    const deleteTracks = useTracksStore((s) => s.deleteTracks);\n\n    const toggleSelection = () => {\n        if (props.selected.size) {\n            props.setSelected(new Set());\n            return;\n        }\n\n        props.setSelected(new Set(props.tracks.map((t) => t.id)));\n    };\n\n    const handleDelete = async () => {\n        props.handLoader.open();\n        try {\n            const { message } = await deleteTracks([...props.selected]);\n            props.setSelected(new Set());\n            okNotify(message);\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            props.handLoader.close();\n        }\n    };\n\n    const handleAddToQueue = async () => {\n        props.handLoader.open();\n        try {\n            await addToQueue([...props.selected]);\n            props.setSelected(new Set());\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            props.handLoader.close();\n        }\n    };\n\n    const confirmDelete = () => {\n        modals.openConfirmModal({\n            title: \"Confirm clear queue\",\n            cancelProps: { variant: \"light\", color: \"gray\" },\n            centered: true,\n            children: <Text size=\"sm\">Do you really want to delete selected tracks from the server?</Text>,\n            labels: { confirm: \"Confirm\", cancel: \"Cancel\" },\n            onConfirm: () => handleDelete(),\n        });\n    };\n\n    return (\n        <Flex align=\"center\" gap=\"md\">\n            <Menu position=\"left\" offset={0} keepMounted>\n                <Menu.Target>\n                    <Button variant=\"light\" px=\"md\" disabled={!props.selected.size}>\n                        Action\n                    </Button>\n                </Menu.Target>\n\n                <Menu.Dropdown>\n                    <Menu.Item leftSection={<IconQueue size={14} />} color=\"blue\" onClick={handleAddToQueue}>\n                        Move to queue\n                    </Menu.Item>\n                    <Menu.Divider />\n                    <AddToPalylistModal trackIDs={[...props.selected]} />\n                    <Menu.Divider />\n                    <Menu.Item\n                        leftSection={<IconTrash size={14} />}\n                        color=\"red\"\n                        disabled={![...props.selected].every((id) => props.freeTrackIDs.has(id))}\n                        onClick={confirmDelete}\n                    >\n                        Delete\n                    </Menu.Item>\n                </Menu.Dropdown>\n            </Menu>\n            <Checkbox size=\"md\" readOnly checked={props.selected.size > 0} onClick={toggleSelection} />\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "web/studio/src/page/TracksQueue.tsx",
    "content": "import { FC, useEffect, useState } from \"react\";\nimport {\n    ActionIcon,\n    Box,\n    Button,\n    CloseButton,\n    Flex,\n    Group,\n    LoadingOverlay,\n    Paper,\n    Space,\n    Text,\n    Tooltip,\n} from \"@mantine/core\";\nimport { modals } from \"@mantine/modals\";\nimport { useDisclosure } from \"@mantine/hooks\";\nimport { CSS } from \"@dnd-kit/utilities\";\nimport { DndContext, DragEndEvent } from \"@dnd-kit/core\";\nimport { restrictToVerticalAxis } from \"@dnd-kit/modifiers\";\nimport { SortableContext, useSortable } from \"@dnd-kit/sortable\";\nimport { PlaylistsModal } from \"./Playlists\";\nimport { Track } from \"../api/types\";\nimport { IconReload } from \"../icons\";\nimport { usePlaybackStore } from \"../store/playback\";\nimport { EmptyLabel } from \"../components/EmptyLabel\";\nimport { errNotify, okNotify } from \"../notifications\";\nimport { useTrackQueueStore } from \"../store/track-queue\";\nimport { moveArrayItem, shuffleArray } from \"../utils/array\";\nimport styles from \"./styles.module.css\";\n\nexport const TrackQueue: FC<{ isMobile?: boolean }> = (props) => {\n    const [loader, handLoader] = useDisclosure(false);\n    const playback = usePlaybackStore((s) => s.playback);\n    const queue = useTrackQueueStore((s) => s.queue);\n    const fetchQueue = useTrackQueueStore((s) => s.fetchQueue);\n    const updateQueue = useTrackQueueStore((s) => s.updateQueue);\n    const removeFromQueue = useTrackQueueStore((s) => s.removeFromQueue);\n    const [hovered, setHovered] = useState(false);\n\n    const loadQueue = async () => {\n        handLoader.open();\n        try {\n            await fetchQueue();\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            handLoader.close();\n        }\n    };\n\n    const handleRemove = async (trackIDs: string[]) => {\n        handLoader.open();\n        try {\n            await removeFromQueue(trackIDs);\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            handLoader.close();\n            setHovered(true);\n        }\n    };\n\n    const handleClear = async () => {\n        handLoader.open();\n        try {\n            const trackIDs = queue.filter(({ id }) => id !== playback.currentTrack?.id).map(({ id }) => id);\n            const { message } = await removeFromQueue(trackIDs);\n            okNotify(message);\n        } catch (error) {\n            errNotify(error);\n        } finally {\n            handLoader.close();\n        }\n    };\n\n    const confirmClear = () => {\n        modals.openConfirmModal({\n            title: \"Confirm clear the queue\",\n            cancelProps: { variant: \"light\", color: \"gray\" },\n            centered: true,\n            children: <Text size=\"sm\">Do you really want to completely clear the track queue?</Text>,\n            labels: { confirm: \"Confirm\", cancel: \"Cancel\" },\n            onConfirm: () => handleClear(),\n        });\n    };\n\n    const handleShuffle = async () => {\n        try {\n            const shuffled = shuffleArray(queue.filter(({ id }) => id !== playback.currentTrack?.id));\n            await updateQueue(playback.currentTrack ? [playback.currentTrack, ...shuffled] : shuffled);\n        } catch (error) {\n            errNotify(error);\n        }\n    };\n\n    const confirmShuffle = () => {\n        modals.openConfirmModal({\n            title: \"Confirm shuffle the queue\",\n            cancelProps: { variant: \"light\", color: \"gray\" },\n            centered: true,\n            children: <Text size=\"sm\">Do you really want to shuffle the track queue?</Text>,\n            labels: { confirm: \"Confirm\", cancel: \"Cancel\" },\n            onConfirm: () => handleShuffle(),\n        });\n    };\n\n    const handleDragEvent = async (event: DragEndEvent) => {\n        const { active, over } = event;\n        if (over && active.id !== over.id) {\n            const fromIndex = queue.findIndex((t) => t.id === active.id);\n            const toIndex = queue.findIndex((t) => t.id === over.id);\n            setHovered(false);\n            try {\n                await updateQueue(moveArrayItem(queue, fromIndex, toIndex));\n            } catch (error) {\n                errNotify(error);\n            }\n        }\n    };\n\n    const tracklist = queue.map((track) => {\n        if (track.id === playback?.currentTrack?.id && playback.isPlaying) return null;\n        return <QueueItem key={track.id} track={track} handleRemove={handleRemove} />;\n    });\n\n    useEffect(() => {\n        loadQueue();\n    }, []);\n\n    return (\n        <Paper radius=\"md\" className={styles.transparent_paper}>\n            <Flex p=\"sm\" direction=\"column\" h={props.isMobile ? \"calc(100vh - 60px)\" : \"80vh\"} mah={1200}>\n                <LoadingOverlay visible={loader} overlayProps={{ radius: \"md\" }} />\n                <Flex justify=\"space-between\" align=\"center\">\n                    <Flex align=\"center\" justify=\"center\" gap=\"xs\">\n                        <Box\n                            w={10}\n                            h={10}\n                            bg={playback?.isPlaying ? \"red\" : \"#ffffff33\"}\n                            style={{ borderRadius: \"50%\" }}\n                        />\n                        <Text fw={700} size=\"lg\">\n                            Live queue\n                        </Text>\n                        <Text c=\"dimmed\">{queue.length > 1 ? queue.length - (playback.isPlaying ? 1 : 0) : \"\"}</Text>\n                    </Flex>\n                    <Flex>\n                        <PlaylistsModal />\n                        <Tooltip openDelay={500} label=\"Reload list\">\n                            <ActionIcon onClick={loadQueue} variant=\"transparent\" size=\"md\">\n                                <IconReload size={18} color=\"gray\" />\n                            </ActionIcon>\n                        </Tooltip>\n                    </Flex>\n                </Flex>\n\n                <Space h={12} />\n\n                <Box\n                    flex={1}\n                    onMouseEnter={() => setHovered(true)}\n                    onMouseLeave={() => setHovered(false)}\n                    style={{\n                        overflowX: \"hidden\",\n                        overflowY: hovered ? \"auto\" : \"hidden\",\n                        scrollbarGutter: \"stable\",\n                    }}\n                >\n                    <DndContext modifiers={[restrictToVerticalAxis]} onDragEnd={handleDragEvent}>\n                        <SortableContext items={queue}>{tracklist}</SortableContext>\n                    </DndContext>\n                    {!queue.length || (queue.length === 1 && playback.isPlaying) ? (\n                        <EmptyLabel label={\"Queue is empty\"} />\n                    ) : null}\n                </Box>\n\n                <Space h={12} />\n\n                <Group gap=\"xs\">\n                    <Button onClick={confirmClear} variant=\"light\" color=\"gray\" disabled={loader || queue.length <= 1}>\n                        Clear\n                    </Button>\n                    <Button onClick={confirmShuffle} variant=\"light\" color=\"pink\" disabled={queue.length < 3}>\n                        🎲 Shuffle\n                    </Button>\n                </Group>\n            </Flex>\n        </Paper>\n    );\n};\n\nconst QueueItem: FC<{ track: Track; handleRemove: (ids: string[]) => Promise<void> }> = ({ track, handleRemove }) => {\n    const [hovered, setHovered] = useState(false);\n    const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: track.id });\n    const style = { transform: CSS.Transform.toString(transform), transition };\n\n    return (\n        <Paper\n            ref={setNodeRef}\n            p=\"0.3rem\"\n            key={track.id}\n            bg=\"transparent\"\n            style={style}\n            onMouseEnter={() => setHovered(true)}\n            onMouseLeave={() => setHovered(false)}\n        >\n            <Flex align=\"center\" gap={5}>\n                <Text\n                    {...attributes}\n                    {...listeners}\n                    w=\"100%\"\n                    c={hovered ? \"air\" : undefined}\n                    style={{ whiteSpace: \"nowrap\", textOverflow: \"ellipsis\", overflow: \"hidden\", cursor: \"grab\" }}\n                >\n                    {track.name}\n                </Text>\n                <CloseButton\n                    variant=\"transparent\"\n                    display={hovered ? undefined : \"none\"}\n                    size=\"sm\"\n                    onClick={() => {\n                        handleRemove([track.id]);\n                        setHovered(false);\n                    }}\n                />\n            </Flex>\n        </Paper>\n    );\n};\n"
  },
  {
    "path": "web/studio/src/page/index.tsx",
    "content": "import { lazy, Suspense } from \"react\";\nimport { useIsMobile } from \"../hooks/useIsMobile\";\n\nconst DesktopPage = lazy(() => import(\"./DesktopPage\"));\nconst MobilePage = lazy(() => import(\"./MobilePage\"));\n\nexport const Page = () => {\n    const { isMobile, width } = useIsMobile();\n    const PageComponent = isMobile ? MobilePage : DesktopPage;\n\n    return (\n        <Suspense fallback={null}>\n            <PageComponent windowWidth={width} />\n        </Suspense>\n    );\n};\n"
  },
  {
    "path": "web/studio/src/page/styles.module.css",
    "content": ".transparent_paper {\n    position: relative;\n    background-color: rgb(0 0 0 / 30%);\n}\n"
  },
  {
    "path": "web/studio/src/store/events.ts",
    "content": "import { create } from \"zustand\";\nimport { API_HOST, API_PREFIX } from \"../api\";\n\nconst EVENT_SOURCE_URL = API_HOST + API_PREFIX + \"/events\";\nexport const EVENTS = {\n    newTrack: \"new_track\",\n    loadedTracks: \"loaded_tracks\",\n    countListeners: \"count_listeners\",\n};\n\ntype EventHandler = (event: MessageEvent) => void;\n\ninterface EventSourceStore {\n    eventSource?: EventSource;\n    addEventHandler: (eventName: string, handler: EventHandler) => void;\n    closeEventSource: () => void;\n}\n\nexport const useEventSourceStore = create<EventSourceStore>((set, get) => ({\n    eventSource: undefined,\n\n    addEventHandler: (eventName: string, handler: EventHandler) => {\n        let { eventSource } = get();\n\n        if (!eventSource) {\n            eventSource = new EventSource(EVENT_SOURCE_URL);\n\n            eventSource.onerror = () => {\n                console.error(\"EventSource connection error\");\n            };\n\n            set({ eventSource });\n        }\n\n        eventSource.addEventListener(eventName, handler);\n    },\n\n    closeEventSource: () => {\n        const { eventSource } = get();\n        if (eventSource) {\n            eventSource.close();\n            set({ eventSource: undefined });\n        }\n    },\n}));\n"
  },
  {
    "path": "web/studio/src/store/playback.ts",
    "content": "import { create } from \"zustand\";\nimport { PlaybackState } from \"../api/types\";\nimport { airstationAPI } from \"../api\";\nimport { errNotify } from \"../notifications\";\nimport { getUnixTime } from \"../utils/time\";\n\ninterface PlaybackStore {\n    playback: PlaybackState;\n    setPlayback: (pb: PlaybackState) => void;\n    play: () => Promise<PlaybackState>;\n    pause: () => Promise<PlaybackState>;\n    fetchPlayback: () => Promise<void>;\n    syncElapsedTime: () => void;\n}\n\nexport const usePlaybackStore = create<PlaybackStore>()((set) => ({\n    playback: { currentTrack: null, currentTrackElapsed: 0, isPlaying: false, updatedAt: getUnixTime() },\n\n    setPlayback(pb) {\n        if (pb.currentTrack) pb.currentTrack.duration = Math.ceil(pb.currentTrack.duration);\n        set({ playback: pb });\n    },\n\n    async fetchPlayback() {\n        try {\n            const pb = await airstationAPI.getPlayback();\n            if (pb.currentTrack) pb.currentTrack.duration = Math.ceil(pb.currentTrack.duration);\n\n            set({ playback: pb });\n        } catch (error) {\n            errNotify(error);\n        }\n    },\n\n    async play() {\n        const playback = await airstationAPI.playPlayback();\n        set({ playback });\n        return playback;\n    },\n\n    async pause() {\n        const playback = await airstationAPI.pausePlayback();\n        set({ playback });\n        return playback;\n    },\n\n    syncElapsedTime() {\n        set((state) => {\n            if (!state.playback.currentTrack || !state.playback.isPlaying) return state;\n\n            const currentTime = getUnixTime();\n            const diff = currentTime - state.playback.updatedAt;\n            const elapsed = state.playback.currentTrackElapsed + diff;\n            if (elapsed > state.playback.currentTrack.duration) return state;\n\n            return {\n                ...state,\n                playback: {\n                    ...state.playback,\n                    currentTrackElapsed: elapsed,\n                    updatedAt: currentTime,\n                },\n            };\n        });\n    },\n}));\n"
  },
  {
    "path": "web/studio/src/store/playlists.ts",
    "content": "import { create } from \"zustand\";\nimport { Playlist, ResponseOK } from \"../api/types\";\nimport { airstationAPI } from \"../api\";\n\ninterface PlaylistStore {\n    playlists: Playlist[];\n\n    setPlaylists(playlists: Playlist[]): void;\n    addPlaylist(name: string, trackIDs: string[], description?: string): Promise<Playlist>;\n    fetchPlaylists(): Promise<void>;\n    editPlaylist(id: string, name: string, trackIDs: string[], description?: string): Promise<ResponseOK>;\n    deletePlaylist(id: string): Promise<ResponseOK>;\n}\n\nexport const usePlaylistStore = create<PlaylistStore>()((set, get) => ({\n    playlists: [],\n\n    setPlaylists(p) {\n        set({ playlists: p });\n    },\n\n    async fetchPlaylists() {\n        const p = await airstationAPI.getPlaylists();\n        set({ playlists: p });\n    },\n\n    async addPlaylist(name, trackIDs, description) {\n        const p = await airstationAPI.addPlaylist(name, trackIDs, description);\n        set({ playlists: [p, ...get().playlists] });\n        return p;\n    },\n\n    async editPlaylist(id: string, name: string, trackIDs: string[], description?: string) {\n        const resp = await airstationAPI.editPlaylist(id, name, trackIDs, description);\n        set({\n            playlists: get().playlists.map((p) =>\n                p.id === id\n                    ? {\n                          id,\n                          name,\n                          tracks: [],\n                          trackCount: trackIDs.length,\n                          description,\n                      }\n                    : p,\n            ),\n        });\n\n        return resp;\n    },\n\n    async deletePlaylist(id) {\n        const resp = await airstationAPI.deletePlaylist(id);\n        set({ playlists: get().playlists.filter((p) => p.id !== id) });\n        return resp;\n    },\n}));\n"
  },
  {
    "path": "web/studio/src/store/settings.ts",
    "content": "import { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\n\ninterface SettingsStore {\n    interfaceWidth?: number;\n\n    setInterfaceWidth: (width: number) => void;\n}\n\nexport const useSettingsStore = create<SettingsStore>()(\n    persist(\n        (set) => ({\n            setInterfaceWidth: (width: number) => set({ interfaceWidth: width }),\n        }),\n        { name: \"settings\" },\n    ),\n);\n"
  },
  {
    "path": "web/studio/src/store/track-queue.ts",
    "content": "import { create } from \"zustand\";\nimport { ResponseOK, Track } from \"../api/types\";\nimport { airstationAPI } from \"../api\";\n\ninterface TrackQueueStore {\n    queue: Track[];\n    fetchQueue: () => Promise<void>;\n    updateQueue: (tracks: Track[]) => Promise<void>;\n    addToQueue(trackIDs: string[]): Promise<ResponseOK>;\n    removeFromQueue(trackIDs: string[]): Promise<ResponseOK>;\n    rotateQueue: () => void;\n}\n\nexport const useTrackQueueStore = create<TrackQueueStore>()((set, get) => ({\n    queue: [],\n\n    async fetchQueue() {\n        const q = await airstationAPI.getQueue();\n        set({ queue: q });\n    },\n\n    async updateQueue(tracks) {\n        set({ queue: tracks });\n        await airstationAPI.updateQueue(tracks.map(({ id }) => id));\n    },\n\n    async addToQueue(trackIDs: string[]) {\n        const resp = await airstationAPI.addToQueue(trackIDs);\n        const q = await airstationAPI.getQueue();\n        set({ queue: q });\n        return resp;\n    },\n\n    async removeFromQueue(trackIDs: string[]) {\n        const resp = await airstationAPI.removeFromQueue(trackIDs);\n        const filtered = get().queue.filter(({ id }) => !trackIDs.includes(id));\n        set({ queue: filtered });\n        return resp;\n    },\n\n    rotateQueue() {\n        set((state) => {\n            if (state.queue.length === 0) return state;\n            return {\n                ...state,\n                queue: [...state.queue.slice(1), state.queue[0]],\n            };\n        });\n    },\n}));\n"
  },
  {
    "path": "web/studio/src/store/tracks.ts",
    "content": "import { create } from \"zustand\";\nimport { ResponseOK, Track } from \"../api/types\";\nimport { airstationAPI } from \"../api\";\n\ninterface TracksStore {\n    tracks: Track[];\n    totalTracks: number;\n\n    setTracks(tracks: Track[]): void;\n    fetchTracks(p: number, l: number, s: string, sb: keyof Track, so: \"asc\" | \"desc\"): Promise<void>;\n    uploadTracks(files: File[]): Promise<ResponseOK>;\n    deleteTracks(trackIDs: string[]): Promise<ResponseOK>;\n}\n\nexport const useTracksStore = create<TracksStore>()((set, get) => ({\n    tracks: [],\n    totalTracks: 0,\n\n    setTracks(q) {\n        set({ tracks: q });\n    },\n\n    async fetchTracks(p: number, l: number, s: string, sb: keyof Track, so: \"asc\" | \"desc\") {\n        const result = await airstationAPI.getTracks(p, l, s, sb, so);\n        if (p === 1) {\n            set({ tracks: result.tracks, totalTracks: result.total });\n            return;\n        }\n\n        // If it not a first page, just append new tracks\n        const trackIDs = new Set(get().tracks.map((t) => t.id));\n        const tracks = [...get().tracks];\n\n        for (const track of result.tracks) {\n            if (trackIDs.has(track.id)) continue;\n            tracks.push(track);\n        }\n\n        set({ tracks, totalTracks: result.total });\n    },\n\n    async uploadTracks(files: File[]) {\n        const resp = await airstationAPI.uploadTracks(files);\n        return resp;\n    },\n\n    async deleteTracks(trackIDs: string[]) {\n        const resp = await airstationAPI.deleteTracks(trackIDs);\n        const filtered = get().tracks.filter(({ id }) => !trackIDs.includes(id));\n        set({ tracks: filtered, totalTracks: get().totalTracks - trackIDs.length });\n        return resp;\n    },\n}));\n"
  },
  {
    "path": "web/studio/src/theme.ts",
    "content": "import { createTheme, LoadingOverlay, Overlay } from \"@mantine/core\";\n\nexport const theme = createTheme({\n    fontFamily: '\"Exo 2\", serif',\n\n    colors: {\n        dark: [\n            \"#f3f5f7\",\n            \"#e7e7e7\",\n            \"#cacccf\",\n            \"#aab0b7\",\n            \"#4d555e\",\n            \"#7e8997\",\n            \"#262f3a\",\n            \"#2e3944\",\n            \"#566373\",\n            \"#29323c\",\n        ],\n        air: [\n            \"#dffbff\",\n            \"#caf2ff\",\n            \"#99e2ff\",\n            \"#64d2ff\",\n            \"#3cc4fe\",\n            \"#23bcfe\",\n            \"#09b8ff\",\n            \"#00a1e4\",\n            \"#008fcd\",\n            \"#007cb6\",\n        ],\n    },\n    primaryColor: \"air\",\n    defaultGradient: { from: \"#29323c\", to: \"#485563\", deg: -180 },\n    components: {\n        LoadingOverlay: LoadingOverlay.extend({\n            defaultProps: {\n                overlayProps: {},\n            },\n        }),\n        Overlay: Overlay.extend({\n            defaultProps: {\n                bg: \"rgba(0, 0, 0, 0.4)\",\n            },\n        }),\n    },\n});\n"
  },
  {
    "path": "web/studio/src/types/index.ts",
    "content": "export interface DisclosureHandler {\n    open: () => void;\n    close: () => void;\n    toggle: () => void;\n}\n"
  },
  {
    "path": "web/studio/src/utils/array.ts",
    "content": "export const moveArrayItem = <T>(array: T[], fromIndex: number, toIndex: number): T[] => {\n    if (fromIndex < 0 || fromIndex >= array.length || toIndex < 0 || toIndex >= array.length) {\n        return array;\n    }\n\n    const newArray = [...array];\n    const [movedItem] = newArray.splice(fromIndex, 1);\n\n    newArray.splice(toIndex, 0, movedItem);\n    return newArray;\n};\n\nexport const shuffleArray = <T>(array: T[]): T[] => {\n    const shuffled = [...array];\n\n    for (let i = shuffled.length - 1; i > 0; i--) {\n        const j = Math.floor(Math.random() * (i + 1));\n        [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];\n    }\n\n    return shuffled;\n};\n"
  },
  {
    "path": "web/studio/src/utils/error.ts",
    "content": "export const handleErr = (err: unknown) => {\n    return String(err).replace(\"Error: \", \"\");\n};\n"
  },
  {
    "path": "web/studio/src/utils/json.ts",
    "content": "export const safeJsonParser = <T = any>(jsonString: string): T | null => {\n    try {\n        return JSON.parse(jsonString) as T;\n    } catch {\n        return null;\n    }\n};\n"
  },
  {
    "path": "web/studio/src/utils/time.ts",
    "content": "export const formatTime = (seconds: number): string => {\n    const hours = Math.floor(seconds / 3600);\n    const minutes = Math.floor((seconds % 3600) / 60);\n    const remainingSeconds = Math.floor(seconds % 60);\n\n    const formattedHours = String(hours).padStart(2, \"0\");\n    const formattedMinutes = String(minutes).padStart(2, \"0\");\n    const formattedSeconds = String(remainingSeconds).padStart(2, \"0\");\n\n    return hours > 0\n        ? `${formattedHours}:${formattedMinutes}:${formattedSeconds}`\n        : `${formattedMinutes}:${formattedSeconds}`;\n};\n\nexport const getUnixTime = (): number => Math.floor(Date.now() / 1000);\n"
  },
  {
    "path": "web/studio/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "web/studio/tsconfig.app.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"isolatedModules\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "web/studio/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ]\n}\n"
  },
  {
    "path": "web/studio/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2022\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"isolatedModules\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "web/studio/vite.config.ts",
    "content": "import { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport { VitePWA } from \"vite-plugin-pwa\";\n\n// https://vite.dev/config/\nexport default defineConfig({\n    base: \"/studio/\",\n    plugins: [\n        react(),\n        VitePWA({\n            scope: \"/studio/\",\n            registerType: \"autoUpdate\",\n            workbox: { cleanupOutdatedCaches: true },\n            devOptions: {\n                enabled: true,\n            },\n            manifest: {\n                scope: \"/studio/\",\n                start_url: \"/studio/\",\n                lang: \"en\",\n                name: \"Airstation Studio\",\n                short_name: \"Airstation\",\n                icons: [\n                    {\n                        src: \"icon48.png\",\n                        sizes: \"48x48\",\n                        type: \"image/png\",\n                        purpose: \"maskable any\",\n                    },\n                    {\n                        src: \"icon72.png\",\n                        sizes: \"72x72\",\n                        type: \"image/png\",\n                        purpose: \"maskable any\",\n                    },\n                    {\n                        src: \"icon96.png\",\n                        sizes: \"96x96\",\n                        type: \"image/png\",\n                        purpose: \"maskable any\",\n                    },\n                    {\n                        src: \"icon128.png\",\n                        sizes: \"128x128\",\n                        type: \"image/png\",\n                        purpose: \"maskable any\",\n                    },\n                    {\n                        src: \"icon144.png\",\n                        sizes: \"144x144\",\n                        type: \"image/png\",\n                        purpose: \"maskable any\",\n                    },\n                    {\n                        src: \"icon152.png\",\n                        sizes: \"152x152\",\n                        type: \"image/png\",\n                        purpose: \"maskable any\",\n                    },\n                    {\n                        src: \"icon192.png\",\n                        sizes: \"192x192\",\n                        type: \"image/png\",\n                        purpose: \"maskable any\",\n                    },\n                    {\n                        src: \"icon256.png\",\n                        sizes: \"256x256\",\n                        type: \"image/png\",\n                        purpose: \"maskable any\",\n                    },\n                    {\n                        src: \"icon512.png\",\n                        sizes: \"512x512\",\n                        type: \"image/png\",\n                        purpose: \"maskable any\",\n                    },\n                ],\n            },\n        }),\n    ],\n    server: {\n        proxy: {\n            \"/api\": { target: \"http://localhost:7331\", changeOrigin: true },\n            \"/static\": { target: \"http://localhost:7331\", changeOrigin: true },\n            \"/stream\": { target: \"http://localhost:7331\", changeOrigin: true },\n        },\n    },\n});\n"
  }
]