├── .gitignore ├── .fluentci ├── mod.ts ├── .vscode │ └── settings.json ├── src │ ├── runner.ts │ ├── mod.ts │ ├── list_jobs.ts │ ├── pipeline.ts │ ├── lib.ts │ └── jobs.ts ├── dagger.json ├── ci.ts ├── sdk │ ├── client.ts │ ├── builder.ts │ ├── connect.ts │ ├── context.ts │ └── utils.ts ├── deno.json ├── fixtures │ ├── buildspec.yml │ ├── workflow.yml │ ├── azure-pipelines.yml │ ├── .gitlab-ci.yml │ └── config.yml ├── LICENSE ├── .devcontainer │ └── devcontainer.json ├── README.md ├── import_map.json ├── deps.ts ├── CONTRIBUTING.md └── CODE_OF_CONDUCT.md ├── .vscode └── settings.json ├── dist ├── debian │ ├── .gitignore │ ├── amd64 │ │ └── DEBIAN │ │ │ └── control │ └── arm64 │ │ └── DEBIAN │ │ └── control └── rpm │ ├── amd64 │ └── tunein.spec │ └── arm64 │ └── tunein.spec ├── api.png ├── tools └── protoc ├── src ├── api │ ├── descriptor.bin │ └── objects.v1alpha1.rs ├── systemd │ └── tunein.service ├── format.rs ├── input.rs ├── tags.rs ├── provider │ ├── mod.rs │ ├── radiobrowser.rs │ └── tunein.rs ├── search.rs ├── tui.rs ├── server │ ├── mod.rs │ ├── playback.rs │ └── browse.rs ├── browse.rs ├── cfg.rs ├── extract.rs ├── decoder.rs ├── visualization │ ├── mod.rs │ ├── vectorscope.rs │ ├── oscilloscope.rs │ └── spectroscope.rs ├── music.rs ├── os_media_controls.rs ├── service.rs ├── favorites.rs ├── types.rs ├── lib.rs ├── player.rs ├── play.rs ├── main.rs └── audio.rs ├── .github ├── assets │ └── preview.png ├── workflows │ ├── fmt.yml │ ├── ci.yml │ ├── flakehub-publish-tagged.yml │ ├── flakestry-publish.yml │ ├── release.yml │ └── release-for-mac.yml └── FUNDING.yml ├── proto ├── buf.md ├── buf.lock ├── buf.yaml ├── objects │ └── v1alpha1 │ │ ├── category.proto │ │ └── station.proto └── tunein │ └── v1alpha1 │ ├── playback.proto │ └── browse.proto ├── tea.yaml ├── .tangled └── workflows │ └── fmt.yml ├── LICENSE ├── .devcontainer └── devcontainer.json ├── Cargo.toml ├── install.sh ├── CONTRIBUTING.md ├── flake.lock ├── flake.nix ├── README.md └── CODE_OF_CONDUCT.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /result 3 | *.md -------------------------------------------------------------------------------- /.fluentci/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./src/mod.ts"; 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true 3 | } 4 | -------------------------------------------------------------------------------- /dist/debian/.gitignore: -------------------------------------------------------------------------------- 1 | *.deb 2 | amd64/usr/* 3 | arm64/usr/* 4 | -------------------------------------------------------------------------------- /.fluentci/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true 3 | } 4 | -------------------------------------------------------------------------------- /api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsirysndr/tunein-cli/HEAD/api.png -------------------------------------------------------------------------------- /tools/protoc: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | _protoc --experimental_allow_proto3_optional "$@" 3 | -------------------------------------------------------------------------------- /src/api/descriptor.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsirysndr/tunein-cli/HEAD/src/api/descriptor.bin -------------------------------------------------------------------------------- /.fluentci/src/runner.ts: -------------------------------------------------------------------------------- 1 | import pipeline from "./pipeline.ts"; 2 | 3 | await pipeline(".", Deno.args); 4 | -------------------------------------------------------------------------------- /.github/assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsirysndr/tunein-cli/HEAD/.github/assets/preview.png -------------------------------------------------------------------------------- /.fluentci/dagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "", 3 | "name": "rust", 4 | "sdkRuntime": "tsiry/dagger-sdk-deno" 5 | } -------------------------------------------------------------------------------- /proto/buf.md: -------------------------------------------------------------------------------- 1 | ## TuneinServerAPIs 2 | 3 | This module contains all of APIs required to interact with the `TuneIn CLI` Server. 4 | -------------------------------------------------------------------------------- /tea.yaml: -------------------------------------------------------------------------------- 1 | # https://tea.xyz/what-is-this-file 2 | --- 3 | version: 1.0.0 4 | codeOwners: 5 | - '0xa6d02B527eCA842E3Bed7995cA39834265fcE845' 6 | quorum: 1 7 | -------------------------------------------------------------------------------- /.fluentci/ci.ts: -------------------------------------------------------------------------------- 1 | import { 2 | build, 3 | test, 4 | } from "https://pkg.fluentci.io/rust_pipeline@v0.6.1/mod.ts"; 5 | 6 | await test(); 7 | await build(); 8 | -------------------------------------------------------------------------------- /.fluentci/src/mod.ts: -------------------------------------------------------------------------------- 1 | import pipeline from "./pipeline.ts"; 2 | import { build, test, jobDescriptions } from "./jobs.ts"; 3 | 4 | export { pipeline, build, test, jobDescriptions }; 5 | -------------------------------------------------------------------------------- /proto/buf.lock: -------------------------------------------------------------------------------- 1 | # Generated by buf. DO NOT EDIT. 2 | version: v1 3 | deps: 4 | - remote: buf.build 5 | owner: googleapis 6 | repository: googleapis 7 | commit: 75b4300737fb4efca0831636be94e517 8 | -------------------------------------------------------------------------------- /proto/buf.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | name: buf.build/tsiry/tuneinserverapis 3 | deps: 4 | - buf.build/googleapis/googleapis 5 | lint: 6 | use: 7 | - DEFAULT 8 | breaking: 9 | use: 10 | - FILE 11 | -------------------------------------------------------------------------------- /src/systemd/tunein.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=TuneIn Server Daemon 3 | After=network.target 4 | 5 | [Service] 6 | ExecStart=/usr/bin/tunein server 7 | Restart=always 8 | 9 | [Install] 10 | WantedBy=default.target 11 | -------------------------------------------------------------------------------- /proto/objects/v1alpha1/category.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package objects.v1alpha1; 4 | 5 | import "objects/v1alpha1/station.proto"; 6 | 7 | message Category { 8 | string id = 1; 9 | string name = 2; 10 | repeated objects.v1alpha1.Station stations = 3; 11 | } 12 | -------------------------------------------------------------------------------- /src/format.rs: -------------------------------------------------------------------------------- 1 | pub trait SampleParser { 2 | fn parse(data: &[u8]) -> T; 3 | } 4 | 5 | pub struct Signed16PCM; 6 | impl SampleParser for Signed16PCM { 7 | fn parse(chunk: &[u8]) -> f64 { 8 | (chunk[0] as i16 | (chunk[1] as i16) << 8) as f64 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.tangled/workflows/fmt.yml: -------------------------------------------------------------------------------- 1 | when: 2 | - event: ["push", "pull_request"] 3 | branch: ["main"] 4 | 5 | engine: nixery 6 | 7 | dependencies: 8 | nixpkgs: 9 | - cargo 10 | - rustc 11 | - rustfmt 12 | 13 | steps: 14 | - name: "cargo fmt" 15 | command: | 16 | cargo fmt --all --check 17 | -------------------------------------------------------------------------------- /.fluentci/sdk/client.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from "../deps.ts"; 2 | 3 | export function createGQLClient(port: number, token: string): GraphQLClient { 4 | return new GraphQLClient(`http://127.0.0.1:${port}/query`, { 5 | headers: { 6 | Authorization: "Basic " + btoa(token + ":"), 7 | }, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /dist/debian/amd64/DEBIAN/control: -------------------------------------------------------------------------------- 1 | Package: tunein-cli 2 | Version: 0.4.1 3 | Section: user/multimedia 4 | Priority: optional 5 | Architecture: amd64 6 | Maintainer: Tsiry Sandratraina 7 | Depends: alsa-utils, libasound2-dev, libdbus-1-3 8 | Description: Browse and listen to thousands of radio stations across the globe right from your terminal 🌎 📻 🎵✨ 9 | -------------------------------------------------------------------------------- /dist/debian/arm64/DEBIAN/control: -------------------------------------------------------------------------------- 1 | Package: tunein-cli 2 | Version: 0.4.1 3 | Section: user/multimedia 4 | Priority: optional 5 | Architecture: arm64 6 | Maintainer: Tsiry Sandratraina 7 | Depends: alsa-utils, libasound2-dev, libdbus-1-3 8 | Description: Browse and listen to thousands of radio stations across the globe right from your terminal 🌎 📻 🎵✨ 9 | 10 | -------------------------------------------------------------------------------- /.fluentci/src/list_jobs.ts: -------------------------------------------------------------------------------- 1 | import { brightGreen, stringifyTree } from "../deps.ts"; 2 | import { Job, jobDescriptions, runnableJobs } from "./jobs.ts"; 3 | 4 | const tree = { 5 | name: brightGreen("rust_pipeline"), 6 | children: (Object.keys(runnableJobs) as Job[]).map((job) => ({ 7 | name: jobDescriptions[job] 8 | ? `${brightGreen(job)} - ${jobDescriptions[job]}` 9 | : brightGreen(job), 10 | children: [], 11 | })), 12 | }; 13 | 14 | console.log( 15 | stringifyTree( 16 | tree, 17 | (t) => t.name, 18 | (t) => t.children, 19 | ), 20 | ); 21 | -------------------------------------------------------------------------------- /proto/tunein/v1alpha1/playback.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package tunein.v1alpha1; 4 | 5 | message PlayOrPauseRequest {} 6 | 7 | message PlayOrPauseResponse {} 8 | 9 | message StopRequest {} 10 | 11 | message StopResponse {} 12 | 13 | message PlayRequest { 14 | string station_name_or_id = 1; 15 | optional string provider = 2; 16 | } 17 | 18 | message PlayResponse {} 19 | 20 | service PlaybackService { 21 | rpc Play(PlayRequest) returns (PlayResponse) {} 22 | rpc Stop(StopRequest) returns (StopResponse) {} 23 | rpc PlayOrPause(PlayOrPauseRequest) returns (PlayOrPauseResponse) {} 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/fmt.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | fmt: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Setup Fluent CI 15 | uses: fluentci-io/setup-fluentci@v5 16 | with: 17 | wasm: true 18 | plugin: rust 19 | args: setup 20 | env: 21 | GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | - name: Run fmt 23 | run: | 24 | type cargo 25 | cargo fmt --all --check 26 | -------------------------------------------------------------------------------- /.fluentci/src/pipeline.ts: -------------------------------------------------------------------------------- 1 | import * as jobs from "./jobs.ts"; 2 | 3 | const { build, test } = jobs; 4 | 5 | export default async function pipeline(src = ".", args: string[] = []) { 6 | if (args.length > 0) { 7 | await runSpecificJobs(args); 8 | return; 9 | } 10 | 11 | await test(src); 12 | await build(src); 13 | } 14 | 15 | async function runSpecificJobs(args: string[]) { 16 | for (const name of args) { 17 | // deno-lint-ignore no-explicit-any 18 | const job = (jobs as any)[name]; 19 | if (!job) { 20 | throw new Error(`Job ${name} not found`); 21 | } 22 | await job(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /proto/objects/v1alpha1/station.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package objects.v1alpha1; 4 | 5 | message Station { 6 | string id = 1; 7 | string name = 2; 8 | string playing = 3; 9 | } 10 | 11 | message StationLinkDetails { 12 | uint32 bitrate = 1; 13 | string element = 2; 14 | string is_ad_clipped_content_enabled = 3; 15 | bool is_direct = 4; 16 | string is_hls_advanced = 5; 17 | string live_seek_stream = 6; 18 | string media_type = 7; 19 | uint32 player_height = 8; 20 | uint32 player_width = 9; 21 | string playlist_type = 10; 22 | uint32 position = 11; 23 | uint32 reliability = 12; 24 | string url = 13; 25 | } -------------------------------------------------------------------------------- /dist/rpm/amd64/tunein.spec: -------------------------------------------------------------------------------- 1 | Name: tunein-cli 2 | Version: 0.4.1 3 | Release: 1%{?dist} 4 | Summary: CLI for listening to internet radio stations 5 | 6 | License: MIT 7 | 8 | BuildArch: x86_64 9 | 10 | Requires: alsa-utils, alsa-lib-devel, dbus-libs 11 | 12 | %description 13 | Browse and listen to thousands of radio stations across the globe right from your terminal 🌎 📻 🎵✨ 14 | 15 | %prep 16 | # Prepare the build environment 17 | 18 | %build 19 | # Build steps (if any) 20 | 21 | %install 22 | mkdir -p %{buildroot}/usr/bin 23 | cp -r %{_sourcedir}/amd64/usr %{buildroot}/ 24 | 25 | %files 26 | /usr/bin/tunein 27 | -------------------------------------------------------------------------------- /dist/rpm/arm64/tunein.spec: -------------------------------------------------------------------------------- 1 | 2 | Name: tunein-cli 3 | Version: 0.4.1 4 | Release: 1%{?dist} 5 | Summary: CLI for listening to internet radio stations 6 | 7 | License: MIT 8 | 9 | BuildArch: aarch64 10 | 11 | Requires: alsa-utils, alsa-lib-devel, dbus-libs 12 | 13 | %description 14 | Browse and listen to thousands of radio stations across the globe right from your terminal 🌎 📻 🎵✨ 15 | 16 | %prep 17 | # Prepare the build environment 18 | 19 | %build 20 | # Build steps (if any) 21 | 22 | %install 23 | mkdir -p %{buildroot}/usr/bin 24 | cp -r %{_sourcedir}/arm64/usr %{buildroot}/ 25 | 26 | %files 27 | /usr/bin/tunein 28 | -------------------------------------------------------------------------------- /src/input.rs: -------------------------------------------------------------------------------- 1 | pub type Matrix = Vec>; 2 | 3 | /// separate a stream of alternating channels into a matrix of channel streams: 4 | /// L R L R L R L R L R 5 | /// becomes 6 | /// L L L L L 7 | /// R R R R R 8 | pub fn stream_to_matrix( 9 | stream: impl Iterator, 10 | channels: usize, 11 | norm: O, 12 | ) -> Matrix 13 | where 14 | I: Copy + Into, 15 | O: Copy + std::ops::Div, 16 | { 17 | let mut out = vec![vec![]; channels]; 18 | let mut channel = 0; 19 | for sample in stream { 20 | out[channel].push(sample.into() / norm); 21 | channel = (channel + 1) % channels; 22 | } 23 | out 24 | } 25 | -------------------------------------------------------------------------------- /.fluentci/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "importMap": "import_map.json", 3 | "tasks": { 4 | "esm:add": "deno run -A https://esm.sh/v128 add", 5 | "esm:update": "deno run -A https://esm.sh/v128 update", 6 | "esm:remove": "deno run -A https://esm.sh/v128 remove", 7 | "schema": "deno run -A src/dagger/schema.ts", 8 | "clean": "rm -rf gen schema.graphql" 9 | }, 10 | "fmt": { 11 | "exclude": [ 12 | "example/", 13 | ".fluentci/", 14 | "gen/" 15 | ] 16 | }, 17 | "lint": { 18 | "exclude": [ 19 | "example/", 20 | ".fluentci/", 21 | "gen/" 22 | ] 23 | }, 24 | "test": { 25 | "exclude": [ 26 | "example/", 27 | ".fluentci/", 28 | "gen/" 29 | ] 30 | } 31 | } -------------------------------------------------------------------------------- /.fluentci/fixtures/buildspec.yml: -------------------------------------------------------------------------------- 1 | # Do not edit this file directly. It is generated by https://deno.land/x/fluent_aws_codepipeline 2 | 3 | version: 0.2 4 | phases: 5 | install: 6 | commands: 7 | - curl -fsSL https://deno.land/x/install/install.sh | sh 8 | - export DENO_INSTALL="$HOME/.deno" 9 | - export PATH="$DENO_INSTALL/bin:$PATH" 10 | - deno install -A -r https://cli.fluentci.io -n fluentci 11 | - curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh 12 | - mv bin/dagger /usr/local/bin 13 | - dagger version 14 | build: 15 | commands: 16 | - fluentci run rust_pipeline test build 17 | post_build: 18 | commands: 19 | - echo Build completed on `date` 20 | -------------------------------------------------------------------------------- /src/tags.rs: -------------------------------------------------------------------------------- 1 | pub const TAGS: &[&str] = &[ 2 | "pop", 3 | "music", 4 | "news", 5 | "rock", 6 | "classical", 7 | "talk", 8 | "hits", 9 | "radio", 10 | "entretenimiento", 11 | "dance", 12 | "oldies", 13 | "estación", 14 | "80s", 15 | "méxico", 16 | "fm", 17 | "christian", 18 | "public radio", 19 | "jazz", 20 | "música", 21 | "top 40", 22 | "classic hits", 23 | "90s", 24 | "community radio", 25 | "norteamérica", 26 | "electronic", 27 | "pop music", 28 | "adult contemporary", 29 | "moi merino", 30 | "classic rock", 31 | "latinoamérica", 32 | "español", 33 | "country", 34 | "local news", 35 | "alternative", 36 | ]; 37 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [tsirysndr] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: [] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.fluentci/fixtures/workflow.yml: -------------------------------------------------------------------------------- 1 | # Do not edit this file directly. It is generated by https://deno.land/x/fluent_github_actions 2 | 3 | name: Test 4 | on: 5 | push: 6 | branches: 7 | - main 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: denoland/setup-deno@v1 14 | with: 15 | deno-version: v1.37 16 | - name: Setup Fluent CI CLI 17 | run: deno install -A -r https://cli.fluentci.io -n fluentci 18 | - name: Setup Dagger 19 | run: | 20 | curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh 21 | sudo mv bin/dagger /usr/local/bin 22 | dagger version 23 | - name: Run Tests and Build 24 | run: fluentci run rust_pipeline test build 25 | -------------------------------------------------------------------------------- /.fluentci/fixtures/azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Do not edit this file directly. It is generated by https://deno.land/x/fluent_azure_pipelines 2 | 3 | trigger: 4 | - main 5 | pool: 6 | name: Default 7 | vmImage: ubuntu-latest 8 | steps: 9 | - script: | 10 | curl -fsSL https://deno.land/x/install/install.sh | sh 11 | export DENO_INSTALL="$HOME/.deno" 12 | export PATH="$DENO_INSTALL/bin:$PATH" 13 | displayName: Install Deno 14 | - script: deno install -A -r https://cli.fluentci.io -n fluentci 15 | displayName: Setup Fluent CI CLI 16 | - script: | 17 | curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh 18 | sudo mv bin/dagger /usr/local/bin 19 | dagger version 20 | displayName: Setup Dagger 21 | - script: fluentci run rust_pipeline test build 22 | displayName: Run Dagger Pipelines 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # Do not edit this file directly. It is generated by https://deno.land/x/fluent_github_actions 2 | 3 | name: ci 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | jobs: 12 | tests: 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Install build dependencies 17 | run: | 18 | sudo apt-get update 19 | sudo apt-get install -y build-essential libasound2-dev 20 | - name: Set up Homebrew 21 | id: set-up-homebrew 22 | uses: Homebrew/actions/setup-homebrew@master 23 | - run: brew install protobuf 24 | - name: Setup Fluent CI 25 | uses: fluentci-io/setup-fluentci@v5 26 | - name: Run tests 27 | run: | 28 | fluentci run --wasm rust test 29 | -------------------------------------------------------------------------------- /.fluentci/fixtures/.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | # Do not edit this file directly. It is generated by https://deno.land/x/fluent_gitlab_ci 2 | 3 | .docker: 4 | image: denoland/deno:alpine 5 | services: 6 | - docker:${DOCKER_VERSION}-dind 7 | variables: 8 | DOCKER_HOST: tcp://docker:2376 9 | DOCKER_TLS_VERIFY: "1" 10 | DOCKER_TLS_CERTDIR: /certs 11 | DOCKER_CERT_PATH: /certs/client 12 | DOCKER_DRIVER: overlay2 13 | DOCKER_VERSION: 20.10.16 14 | 15 | .dagger: 16 | extends: .docker 17 | before_script: 18 | - apk add docker-cli curl unzip 19 | - deno install -A -r https://cli.fluentci.io -n fluentci 20 | - curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh 21 | - mv bin/dagger /usr/local/bin 22 | - dagger version 23 | 24 | tests: 25 | extends: .dagger 26 | script: 27 | - fluentci run rust_pipeline test build 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/flakehub-publish-tagged.yml: -------------------------------------------------------------------------------- 1 | name: "Publish tags to FlakeHub" 2 | on: 3 | push: 4 | tags: 5 | - "v?[0-9]+.[0-9]+.[0-9]+*" 6 | workflow_dispatch: 7 | inputs: 8 | tag: 9 | description: "The existing tag to publish to FlakeHub" 10 | type: "string" 11 | required: true 12 | jobs: 13 | flakehub-publish: 14 | runs-on: "ubuntu-latest" 15 | permissions: 16 | id-token: "write" 17 | contents: "read" 18 | steps: 19 | - uses: "actions/checkout@v3" 20 | with: 21 | ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}" 22 | - uses: "DeterminateSystems/nix-installer-action@main" 23 | - uses: "DeterminateSystems/flakehub-push@main" 24 | with: 25 | visibility: "public" 26 | name: "tsirysndr/tunein-cli" 27 | tag: "${{ inputs.tag }}" 28 | -------------------------------------------------------------------------------- /.fluentci/sdk/builder.ts: -------------------------------------------------------------------------------- 1 | import { createGQLClient } from "./client.ts"; 2 | import { Context } from "./context.ts"; 3 | 4 | /** 5 | * @hidden 6 | * 7 | * Initialize a default client context from environment. 8 | */ 9 | export function initDefaultContext(): Context { 10 | let ctx = new Context(); 11 | 12 | // Prefer DAGGER_SESSION_PORT if set 13 | const daggerSessionPort = Deno.env.get("DAGGER_SESSION_PORT"); 14 | if (daggerSessionPort) { 15 | const sessionToken = Deno.env.get("DAGGER_SESSION_TOKEN"); 16 | if (!sessionToken) { 17 | throw new Error( 18 | "DAGGER_SESSION_TOKEN must be set when using DAGGER_SESSION_PORT" 19 | ); 20 | } 21 | 22 | ctx = new Context({ 23 | client: createGQLClient(Number(daggerSessionPort), sessionToken), 24 | }); 25 | } else { 26 | throw new Error("DAGGER_SESSION_PORT must be set"); 27 | } 28 | 29 | return ctx; 30 | } 31 | -------------------------------------------------------------------------------- /src/provider/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod radiobrowser; 2 | pub mod tunein; 3 | 4 | use crate::types::Station; 5 | use anyhow::Error; 6 | use async_trait::async_trait; 7 | use regex::Regex; 8 | 9 | #[async_trait] 10 | pub trait Provider { 11 | async fn search(&self, name: String) -> Result, Error>; 12 | async fn get_station(&self, id: String) -> Result, Error>; 13 | async fn browse( 14 | &self, 15 | category: String, 16 | offset: u32, 17 | limit: u32, 18 | ) -> Result, Error>; 19 | async fn categories(&self, offset: u32, limit: u32) -> Result, Error>; 20 | } 21 | 22 | pub fn is_valid_uuid(uuid: &str) -> bool { 23 | let uuid_pattern = Regex::new( 24 | r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" 25 | ).unwrap(); 26 | 27 | uuid_pattern.is_match(uuid) 28 | } 29 | -------------------------------------------------------------------------------- /.fluentci/fixtures/config.yml: -------------------------------------------------------------------------------- 1 | # Do not edit this file directly. It is generated by https://deno.land/x/fluent_circleci 2 | 3 | version: 2.1 4 | jobs: 5 | tests: 6 | steps: 7 | - checkout 8 | - run: sudo apt-get update && sudo apt-get install -y curl unzip 9 | - run: | 10 | curl -fsSL https://deno.land/x/install/install.sh | sh 11 | export DENO_INSTALL="$HOME/.deno" 12 | export PATH="$DENO_INSTALL/bin:$PATH" 13 | - run: deno install -A -r https://cli.fluentci.io -n fluentci 14 | - run: | 15 | curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh 16 | sudo mv bin/dagger /usr/local/bin 17 | dagger version 18 | - run: 19 | name: Run Dagger Pipelines 20 | command: fluentci run rust_pipeline test build 21 | machine: 22 | image: ubuntu-2004:2023.07.1 23 | workflows: 24 | dagger: 25 | jobs: 26 | - tests 27 | -------------------------------------------------------------------------------- /.github/workflows/flakestry-publish.yml: -------------------------------------------------------------------------------- 1 | name: "Publish a flake to flakestry" 2 | on: 3 | push: 4 | tags: 5 | - "v?[0-9]+.[0-9]+.[0-9]+" 6 | - "v?[0-9]+.[0-9]+" 7 | workflow_dispatch: 8 | inputs: 9 | tag: 10 | description: "The existing tag to publish" 11 | type: "string" 12 | required: true 13 | jobs: 14 | publish-flake: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | id-token: "write" 18 | contents: "read" 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Setup Fluent CI 22 | uses: fluentci-io/setup-fluentci@v5 23 | - name: Publish flake 24 | run: fluentci run --wasm flakestry publish 25 | env: 26 | VERSION: ${{ inputs.tag || github.ref_name }} 27 | GH_TOKEN: ${{ github.token }} 28 | ACTIONS_ID_TOKEN_REQUEST_TOKEN: ${{ env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }} 29 | ACTIONS_ID_TOKEN_REQUEST_URL: ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }} 30 | REF: ${{ github.sha }} 31 | -------------------------------------------------------------------------------- /.fluentci/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Tsiry Sandratraina 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Tsiry Sandratraina 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /.fluentci/.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/debian 3 | { 4 | "name": "Debian", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/base:bullseye", 7 | "features": { 8 | "ghcr.io/devcontainers/features/github-cli:1": {}, 9 | "ghcr.io/devcontainers/features/nix:1": {} 10 | }, 11 | 12 | // Features to add to the dev container. More info: https://containers.dev/features. 13 | // "features": {}, 14 | 15 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 16 | // "forwardPorts": [], 17 | 18 | // Use 'postCreateCommand' to run commands after the container is created. 19 | "postCreateCommand": "nix develop --experimental-features \"nix-command flakes\"" 20 | // Configure tool-specific properties. 21 | // "customizations": {}, 22 | 23 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 24 | // "remoteUser": "root" 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | release: 4 | types: [created] 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-22.04 9 | strategy: 10 | matrix: 11 | target: 12 | - aarch64-unknown-linux-gnu 13 | - armv7-unknown-linux-gnueabihf 14 | - x86_64-unknown-linux-gnu 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Homebrew 18 | id: set-up-homebrew 19 | uses: Homebrew/actions/setup-homebrew@master 20 | - run: brew install protobuf 21 | - uses: fluentci-io/setup-fluentci@v5 22 | - name: Set env 23 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 24 | - name: Build 25 | run: fluentci run . build 26 | env: 27 | TAG: ${{ env.RELEASE_VERSION }} 28 | TARGET: ${{ matrix.target }} 29 | - name: Upload release assets 30 | run: | 31 | for ext in tar.gz tar.gz.sha256; do 32 | export FILE=/assets/tunein_${{ env.RELEASE_VERSION }}_${{ matrix.target }}.$ext 33 | fluentci run github_pipeline release_upload 34 | done 35 | env: 36 | TAG: ${{ env.RELEASE_VERSION }} 37 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | -------------------------------------------------------------------------------- /src/search.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use owo_colors::OwoColorize; 3 | 4 | use crate::provider::{radiobrowser::Radiobrowser, tunein::Tunein, Provider}; 5 | 6 | pub async fn exec(query: &str, provider: &str) -> Result<(), Error> { 7 | let provider: Box = match provider { 8 | "tunein" => Box::new(Tunein::new()), 9 | "radiobrowser" => Box::new(Radiobrowser::new().await), 10 | _ => { 11 | return Err(anyhow::anyhow!(format!( 12 | "Unsupported provider '{}'", 13 | provider 14 | ))) 15 | } 16 | }; 17 | let results = provider.search(query.to_string()).await?; 18 | let query = format!("\"{}\"", query); 19 | println!("Results for {}:", query.bright_green()); 20 | 21 | if results.is_empty() { 22 | println!("No results found"); 23 | return Ok(()); 24 | } 25 | 26 | for result in results { 27 | match result.playing { 28 | Some(playing) => println!( 29 | "{} | {} | id: {}", 30 | result.name.magenta(), 31 | playing, 32 | result.id 33 | ), 34 | None => println!("{} | id: {}", result.name.magenta(), result.id), 35 | } 36 | } 37 | Ok(()) 38 | } 39 | -------------------------------------------------------------------------------- /src/tui.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, stderr, stdout, Stdout}; 2 | 3 | use crossterm::{ 4 | event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}, 5 | execute, 6 | terminal::*, 7 | }; 8 | use ratatui::prelude::*; 9 | 10 | /// A type alias for the terminal type used in this application 11 | pub type Tui = Terminal>; 12 | 13 | /// Initialize the terminal 14 | pub fn init() -> io::Result { 15 | execute!(stdout(), EnterAlternateScreen)?; 16 | execute!(stderr(), EnterAlternateScreen)?; 17 | if let Ok(true) = crossterm::terminal::supports_keyboard_enhancement() { 18 | execute!( 19 | stdout(), 20 | PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) 21 | )?; 22 | } 23 | 24 | enable_raw_mode()?; 25 | 26 | Terminal::new(CrosstermBackend::new(stdout())) 27 | } 28 | 29 | /// Restore the terminal to its original state 30 | pub fn restore() -> io::Result<()> { 31 | execute!(stdout(), LeaveAlternateScreen)?; 32 | execute!(stderr(), LeaveAlternateScreen)?; 33 | if let Ok(true) = crossterm::terminal::supports_keyboard_enhancement() { 34 | execute!(stdout(), PopKeyboardEnhancementFlags)?; 35 | } 36 | 37 | disable_raw_mode()?; 38 | 39 | Ok(()) 40 | } 41 | -------------------------------------------------------------------------------- /.fluentci/sdk/connect.ts: -------------------------------------------------------------------------------- 1 | import { Writable } from "node:stream"; 2 | import { Client } from "./client.gen.ts"; 3 | import { Context } from "./context.ts"; 4 | 5 | /** 6 | * ConnectOpts defines option used to connect to an engine. 7 | */ 8 | export interface ConnectOpts { 9 | /** 10 | * Use to overwrite Dagger workdir 11 | * @defaultValue process.cwd() 12 | */ 13 | Workdir?: string; 14 | /** 15 | * Enable logs output 16 | * @example 17 | * LogOutput 18 | * ```ts 19 | * connect(async (client: Client) => { 20 | const source = await client.host().workdir().id() 21 | ... 22 | }, {LogOutput: process.stdout}) 23 | ``` 24 | */ 25 | LogOutput?: Writable; 26 | } 27 | 28 | export type CallbackFct = (client: Client) => Promise; 29 | 30 | export interface ConnectParams { 31 | port: number; 32 | session_token: string; 33 | } 34 | 35 | /** 36 | * connect runs GraphQL server and initializes a 37 | * GraphQL client to execute query on it through its callback. 38 | * This implementation is based on the existing Go SDK. 39 | */ 40 | export async function connect( 41 | cb: CallbackFct, 42 | _config: ConnectOpts = {} 43 | ): Promise { 44 | const ctx = new Context(); 45 | const client = new Client({ ctx: ctx }); 46 | 47 | // Initialize connection 48 | await ctx.connection(); 49 | 50 | await cb(client).finally(() => { 51 | ctx.close(); 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/rust 3 | { 4 | "name": "Rust", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye", 7 | "features": { 8 | "ghcr.io/devcontainers/features/github-cli:1": {}, 9 | "ghcr.io/devcontainers/features/nix:1": {} 10 | }, 11 | 12 | // Use 'mounts' to make the cargo cache persistent in a Docker Volume. 13 | // "mounts": [ 14 | // { 15 | // "source": "devcontainer-cargo-cache-${devcontainerId}", 16 | // "target": "/usr/local/cargo", 17 | // "type": "volume" 18 | // } 19 | // ] 20 | 21 | // Features to add to the dev container. More info: https://containers.dev/features. 22 | // "features": {}, 23 | 24 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 25 | // "forwardPorts": [], 26 | 27 | // Use 'postCreateCommand' to run commands after the container is created. 28 | "postCreateCommand": "nix develop --experimental-features \"nix-command flakes\"" 29 | 30 | // Configure tool-specific properties. 31 | // "customizations": {}, 32 | 33 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 34 | // "remoteUser": "root" 35 | } 36 | -------------------------------------------------------------------------------- /proto/tunein/v1alpha1/browse.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package tunein.v1alpha1; 4 | 5 | import "objects/v1alpha1/category.proto"; 6 | import "objects/v1alpha1/station.proto"; 7 | 8 | message GetCategoriesRequest { 9 | optional string provider = 1; 10 | optional uint32 offset = 2; 11 | optional uint32 limit = 3; 12 | } 13 | 14 | message GetCategoriesResponse { 15 | repeated objects.v1alpha1.Category categories = 1; 16 | } 17 | 18 | message BrowseCategoryRequest { 19 | string category_id = 1; 20 | optional string provider = 2; 21 | optional uint32 offset = 3; 22 | optional uint32 limit = 4; 23 | } 24 | 25 | message BrowseCategoryResponse { 26 | repeated objects.v1alpha1.Category categories = 1; 27 | } 28 | 29 | message GetStationDetailsRequest { 30 | string id = 1; 31 | optional string provider = 2; 32 | } 33 | 34 | message GetStationDetailsResponse { 35 | objects.v1alpha1.StationLinkDetails station_link_details = 1; 36 | } 37 | 38 | message SearchRequest { 39 | string query = 1; 40 | optional string provider = 2; 41 | } 42 | 43 | message SearchResponse { 44 | repeated objects.v1alpha1.Station station = 1; 45 | } 46 | 47 | service BrowseService { 48 | rpc GetCategories(GetCategoriesRequest) returns (GetCategoriesResponse) {} 49 | rpc BrowseCategory(BrowseCategoryRequest) returns (BrowseCategoryResponse) {} 50 | rpc GetStationDetails(GetStationDetailsRequest) returns (GetStationDetailsResponse) {} 51 | rpc Search(SearchRequest) returns (SearchResponse) {} 52 | } 53 | -------------------------------------------------------------------------------- /.fluentci/README.md: -------------------------------------------------------------------------------- 1 | # Rust Pipeline 2 | 3 | [![fluentci pipeline](https://img.shields.io/badge/dynamic/json?label=pkg.fluentci.io&labelColor=%23000&color=%23460cf1&url=https%3A%2F%2Fapi.fluentci.io%2Fv1%2Fpipeline%2Frust_pipeline&query=%24.version)](https://pkg.fluentci.io/rust_pipeline) 4 | [![deno module](https://shield.deno.dev/x/rust_pipeline)](https://deno.land/x/rust_pipeline) 5 | ![deno compatibility](https://shield.deno.dev/deno/^1.37) 6 | [![](https://img.shields.io/codecov/c/gh/fluent-ci-templates/rust-pipeline)](https://codecov.io/gh/fluent-ci-templates/rust-pipeline) 7 | 8 | A ready-to-use CI/CD Pipeline for your Rust projects. 9 | ## 🚀 Usage 10 | 11 | Run the following command in your Rust Project: 12 | 13 | ```bash 14 | fluentci run rust_pipeline 15 | ``` 16 | 17 | Or if you want to run specific jobs: 18 | 19 | ```bash 20 | fluentci run rust_pipeline test build 21 | ``` 22 | 23 | 24 | if you want to use it as a template: 25 | 26 | ```bash 27 | fluentci init -t rust 28 | ``` 29 | 30 | This will create a `.fluentci` folder in your project. 31 | 32 | Now you can run the pipeline with: 33 | 34 | ```bash 35 | fluentci run . 36 | ``` 37 | 38 | ## Jobs 39 | 40 | | Job | Description | 41 | | ----- | ------------------ | 42 | | build | build your project | 43 | | test | Run your tests | 44 | 45 | ## Programmatic usage 46 | 47 | You can also use this pipeline programmatically: 48 | 49 | ```ts 50 | import { build, test } from "https://pkg.fluentci.io/rust_pipeline@v0.6.1/mod.ts"; 51 | 52 | await test(); 53 | await build(); 54 | ``` 55 | -------------------------------------------------------------------------------- /.fluentci/sdk/context.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from "../deps.ts"; 2 | 3 | import { initDefaultContext } from "./builder.ts"; 4 | 5 | interface ContextConfig { 6 | client?: GraphQLClient; 7 | } 8 | 9 | /** 10 | * Context abstracts the connection to the engine. 11 | * 12 | * It's required to implement the default global SDK. 13 | * Its purpose is to store and returns the connection to the graphQL API, if 14 | * no connection is set, it can create its own. 15 | * 16 | * This is also useful for lazy evaluation with the default global client, 17 | * this one should only run the engine if it actually executes something. 18 | */ 19 | export class Context { 20 | private _client?: GraphQLClient; 21 | 22 | constructor(config?: ContextConfig) { 23 | this._client = config?.client; 24 | } 25 | 26 | /** 27 | * Returns a GraphQL client connected to the engine. 28 | * 29 | * If no client is set, it will create one. 30 | */ 31 | public async connection(): Promise { 32 | if (!this._client) { 33 | const defaultCtx = await initDefaultContext(); 34 | this._client = defaultCtx._client as GraphQLClient; 35 | } 36 | 37 | return this._client; 38 | } 39 | 40 | /** 41 | * Close the connection and the engine if this one was started by the node 42 | * SDK. 43 | */ 44 | public close(): void { 45 | // Reset client, so it can restart a new connection if necessary 46 | this._client = undefined; 47 | } 48 | } 49 | 50 | /** 51 | * Expose a default context for the global client 52 | */ 53 | export const defaultContext = new Context(); 54 | -------------------------------------------------------------------------------- /src/server/mod.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | 3 | use anyhow::Error; 4 | use owo_colors::OwoColorize; 5 | use tonic::transport::Server; 6 | use tunein_cli::api::tunein::v1alpha1::{ 7 | browse_service_server::BrowseServiceServer, playback_service_server::PlaybackServiceServer, 8 | }; 9 | use tunein_cli::api::tunein::FILE_DESCRIPTOR_SET; 10 | 11 | use self::{browse::Browse, playback::Playback}; 12 | 13 | pub mod browse; 14 | pub mod playback; 15 | 16 | pub async fn exec(port: u16) -> Result<(), Error> { 17 | let addr: SocketAddr = format!("0.0.0.0:{}", port).parse().unwrap(); 18 | println!( 19 | "{}", 20 | r#" 21 | ______ ____ _______ ____ 22 | /_ __/_ _____ ___ / _/__ / ___/ / / _/ 23 | / / / // / _ \/ -_)/ // _ \ / /__/ /___/ / 24 | /_/ \_,_/_//_/\__/___/_//_/ \___/____/___/ 25 | 26 | "# 27 | .bright_green() 28 | ); 29 | println!("Listening on {}", addr.cyan()); 30 | Server::builder() 31 | .accept_http1(true) 32 | .add_service( 33 | tonic_reflection::server::Builder::configure() 34 | .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) 35 | .build_v1alpha()?, 36 | ) 37 | .add_service(tonic_web::enable(BrowseServiceServer::new( 38 | Browse::default(), 39 | ))) 40 | .add_service(tonic_web::enable(PlaybackServiceServer::new( 41 | Playback::default(), 42 | ))) 43 | .serve(addr) 44 | .await?; 45 | Ok(()) 46 | } 47 | -------------------------------------------------------------------------------- /.fluentci/import_map.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "@fluentci.io/dagger": "https://sdk.fluentci.io/v0.1.9/mod.ts", 4 | "@dagger.io/dagger": "https://esm.sh/v128/*@dagger.io/dagger@0.8.4", 5 | "graphql-tag": "https://esm.sh/v128/graphql-tag@2.12.6", 6 | "graphql-request": "https://esm.sh/v128/graphql-request@6.1.0", 7 | "fluent_gitlab_ci": "https://deno.land/x/fluent_gitlab_ci@v0.4.2/mod.ts", 8 | "fluent_github_actions": "https://deno.land/x/fluent_github_actions@v0.2.1/mod.ts", 9 | "fluent_circleci": "https://deno.land/x/fluent_circleci@v0.2.5/mod.ts", 10 | "fluent_azure_pipelines": "https://deno.land/x/fluent_azure_pipelines@v0.2.0/mod.ts", 11 | "fluent_aws_codepipeline": "https://deno.land/x/fluent_aws_codepipeline@v0.2.3/mod.ts", 12 | "crypto": "node:crypto", 13 | "fs": "node:fs", 14 | "os": "node:os", 15 | "path": "node:path", 16 | "process": "node:process", 17 | "readline": "node:readline", 18 | "url": "node:url" 19 | }, 20 | "scopes": { 21 | "https://esm.sh/v128/": { 22 | "@lifeomic/axios-fetch": "https://esm.sh/v128/@lifeomic/axios-fetch@3.0.1", 23 | "adm-zip": "https://esm.sh/v128/adm-zip@0.5.10", 24 | "env-paths": "https://esm.sh/v128/env-paths@3.0.0", 25 | "execa": "https://esm.sh/v128/execa@7.1.1", 26 | "graphql-request": "https://esm.sh/v128/graphql-request@6.1.0", 27 | "graphql-tag": "https://esm.sh/v128/graphql-tag@2.12.6", 28 | "graphql": "https://esm.sh/v128/graphql@16.7.1", 29 | "node-color-log": "https://esm.sh/v128/node-color-log@10.0.2", 30 | "node-fetch": "https://esm.sh/v128/node-fetch@3.3.1", 31 | "tar": "https://esm.sh/v128/tar@6.1.15" 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/browse.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use owo_colors::OwoColorize; 3 | 4 | use crate::provider::radiobrowser::Radiobrowser; 5 | use crate::provider::tunein::Tunein; 6 | use crate::provider::Provider; 7 | 8 | pub async fn exec( 9 | category: Option<&str>, 10 | offset: u32, 11 | limit: u32, 12 | provider: &str, 13 | ) -> Result<(), Error> { 14 | let provider: Box = match provider { 15 | "tunein" => Box::new(Tunein::new()), 16 | "radiobrowser" => Box::new(Radiobrowser::new().await), 17 | _ => { 18 | return Err(anyhow::anyhow!(format!( 19 | "Unsupported provider '{}'", 20 | provider 21 | ))) 22 | } 23 | }; 24 | 25 | match category { 26 | Some(category) => { 27 | let results = provider.browse(category.to_string(), offset, limit).await?; 28 | for result in results { 29 | match result.id.is_empty() { 30 | false => match result.playing { 31 | Some(playing) => println!( 32 | " {} | {} | id: {}", 33 | result.name.magenta(), 34 | playing, 35 | result.id 36 | ), 37 | None => println!(" {} | id: {}", result.name.magenta(), result.id), 38 | }, 39 | 40 | true => println!("{}", result.name), 41 | } 42 | } 43 | } 44 | None => { 45 | let results = provider.categories(offset, limit).await?; 46 | for result in results { 47 | println!("{}", result.magenta()); 48 | } 49 | } 50 | }; 51 | Ok(()) 52 | } 53 | -------------------------------------------------------------------------------- /src/cfg.rs: -------------------------------------------------------------------------------- 1 | use crate::music::Note; 2 | 3 | /// a simple oscilloscope/vectorscope for your terminal 4 | #[derive(Debug)] 5 | pub struct ScopeArgs { 6 | pub opts: SourceOptions, 7 | pub ui: UiOptions, 8 | } 9 | 10 | #[derive(Debug, Clone)] 11 | pub struct UiOptions { 12 | /// floating point vertical scale, from 0 to 1 13 | pub scale: f32, 14 | 15 | /// use vintage looking scatter mode instead of line mode 16 | pub scatter: bool, 17 | 18 | /// don't draw reference line 19 | pub no_reference: bool, 20 | 21 | /// hide UI and only draw waveforms 22 | pub no_ui: bool, 23 | 24 | /// don't use braille dots for drawing lines 25 | pub no_braille: bool, 26 | } 27 | 28 | #[derive(Debug, Clone)] 29 | pub struct SourceOptions { 30 | /// number of channels to open 31 | pub channels: usize, 32 | 33 | /// size of audio buffer, and width of scope 34 | pub buffer: u32, 35 | 36 | /// sample rate to use 37 | pub sample_rate: u32, 38 | 39 | /// tune buffer size to be in tune with given note (overrides buffer option) 40 | pub tune: Option, 41 | } 42 | 43 | // TODO its convenient to keep this here but it's not really the best place... 44 | impl SourceOptions { 45 | pub fn tune(&mut self) { 46 | if let Some(txt) = &self.tune { 47 | // TODO make it less jank 48 | if let Ok(note) = txt.parse::() { 49 | self.buffer = note.tune_buffer_size(self.sample_rate); 50 | while self.buffer % (self.channels as u32 * 2) != 0 { 51 | // TODO customizable bit depth 52 | self.buffer += 1; // TODO jank but otherwise it doesn't align 53 | } 54 | } else { 55 | eprintln!("[!] Unrecognized note '{}', ignoring option", txt); 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/api/objects.v1alpha1.rs: -------------------------------------------------------------------------------- 1 | // This file is @generated by prost-build. 2 | #[derive(Clone, PartialEq, ::prost::Message)] 3 | pub struct Station { 4 | #[prost(string, tag = "1")] 5 | pub id: ::prost::alloc::string::String, 6 | #[prost(string, tag = "2")] 7 | pub name: ::prost::alloc::string::String, 8 | #[prost(string, tag = "3")] 9 | pub playing: ::prost::alloc::string::String, 10 | } 11 | #[derive(Clone, PartialEq, ::prost::Message)] 12 | pub struct StationLinkDetails { 13 | #[prost(uint32, tag = "1")] 14 | pub bitrate: u32, 15 | #[prost(string, tag = "2")] 16 | pub element: ::prost::alloc::string::String, 17 | #[prost(string, tag = "3")] 18 | pub is_ad_clipped_content_enabled: ::prost::alloc::string::String, 19 | #[prost(bool, tag = "4")] 20 | pub is_direct: bool, 21 | #[prost(string, tag = "5")] 22 | pub is_hls_advanced: ::prost::alloc::string::String, 23 | #[prost(string, tag = "6")] 24 | pub live_seek_stream: ::prost::alloc::string::String, 25 | #[prost(string, tag = "7")] 26 | pub media_type: ::prost::alloc::string::String, 27 | #[prost(uint32, tag = "8")] 28 | pub player_height: u32, 29 | #[prost(uint32, tag = "9")] 30 | pub player_width: u32, 31 | #[prost(string, tag = "10")] 32 | pub playlist_type: ::prost::alloc::string::String, 33 | #[prost(uint32, tag = "11")] 34 | pub position: u32, 35 | #[prost(uint32, tag = "12")] 36 | pub reliability: u32, 37 | #[prost(string, tag = "13")] 38 | pub url: ::prost::alloc::string::String, 39 | } 40 | #[derive(Clone, PartialEq, ::prost::Message)] 41 | pub struct Category { 42 | #[prost(string, tag = "1")] 43 | pub id: ::prost::alloc::string::String, 44 | #[prost(string, tag = "2")] 45 | pub name: ::prost::alloc::string::String, 46 | #[prost(message, repeated, tag = "3")] 47 | pub stations: ::prost::alloc::vec::Vec, 48 | } 49 | -------------------------------------------------------------------------------- /.fluentci/deps.ts: -------------------------------------------------------------------------------- 1 | export { assertEquals } from "https://deno.land/std@0.191.0/testing/asserts.ts"; 2 | 3 | export type { DirectoryID, SecretID } from "./sdk/client.gen.ts"; 4 | export { Directory, Secret, File } from "./sdk/client.gen.ts"; 5 | export { connect, uploadContext } from "https://sdk.fluentci.io/v0.3.0/mod.ts"; 6 | export { brightGreen } from "https://deno.land/std@0.191.0/fmt/colors.ts"; 7 | export { withDevbox } from "https://nix.fluentci.io/v0.5.3/src/dagger/steps.ts"; 8 | export { stringifyTree } from "https://esm.sh/stringify-tree@1.1.1"; 9 | import gql from "https://esm.sh/graphql-tag@2.12.6"; 10 | export { gql }; 11 | export { 12 | dirname, 13 | join, 14 | resolve, 15 | } from "https://deno.land/std@0.203.0/path/mod.ts"; 16 | export { parse } from "https://deno.land/std@0.205.0/flags/mod.ts"; 17 | export { snakeCase, camelCase } from "https://cdn.skypack.dev/lodash"; 18 | 19 | export { 20 | ClientError, 21 | GraphQLClient, 22 | } from "https://esm.sh/v128/graphql-request@6.1.0"; 23 | export { 24 | DaggerSDKError, 25 | UnknownDaggerError, 26 | DockerImageRefValidationError, 27 | EngineSessionConnectParamsParseError, 28 | ExecError, 29 | GraphQLRequestError, 30 | InitEngineSessionBinaryError, 31 | TooManyNestedObjectsError, 32 | EngineSessionError, 33 | EngineSessionConnectionTimeoutError, 34 | NotAwaitedRequestError, 35 | ERROR_CODES, 36 | } from "https://esm.sh/@dagger.io/dagger@0.9.3"; 37 | 38 | export type { 39 | CallbackFct, 40 | ConnectOpts, 41 | } from "https://sdk.fluentci.io/v0.3.0/mod.ts"; 42 | 43 | export * as FluentGitlabCI from "https://deno.land/x/fluent_gitlab_ci@v0.4.2/mod.ts"; 44 | export * as FluentGithubActions from "https://deno.land/x/fluent_github_actions@v0.2.1/mod.ts"; 45 | export * as FluentCircleCI from "https://deno.land/x/fluent_circleci@v0.2.5/mod.ts"; 46 | export * as FluentAzurePipelines from "https://deno.land/x/fluent_azure_pipelines@v0.2.0/mod.ts"; 47 | export * as FluentAWSCodePipeline from "https://deno.land/x/fluent_aws_codepipeline@v0.2.3/mod.ts"; 48 | -------------------------------------------------------------------------------- /.fluentci/src/lib.ts: -------------------------------------------------------------------------------- 1 | import { dag } from "../sdk/client.gen.ts"; 2 | import { Directory, DirectoryID } from "../deps.ts"; 3 | 4 | export const getDirectory = async ( 5 | src: string | Directory | undefined = ".", 6 | ) => { 7 | if (src instanceof Directory) { 8 | return src; 9 | } 10 | if (typeof src === "string") { 11 | try { 12 | const directory = dag.loadDirectoryFromID(src as DirectoryID); 13 | await directory.id(); 14 | return directory; 15 | } catch (_) { 16 | return dag.host 17 | ? dag.host().directory(src) 18 | : dag.currentModule().source().directory(src); 19 | } 20 | } 21 | return dag.host 22 | ? dag.host().directory(src) 23 | : dag.currentModule().source().directory(src); 24 | }; 25 | 26 | export function buildRustFlags(): string { 27 | let rustflags = ""; 28 | switch (Deno.env.get("TARGET")) { 29 | case "aarch64-unknown-linux-gnu": 30 | rustflags = `-Clink-arg=-lsystemd \ 31 | -Clink-arg=-lcap \ 32 | -Clink-arg=-lgcrypt \ 33 | -Clink-arg=-lgpg-error \ 34 | -Clink-arg=-llz4 \ 35 | -Clink-arg=-llzma \ 36 | -Clink-arg=-lpsx \ 37 | -Clink-arg=-lxxhash \ 38 | -Clink-arg=-lzstd \ 39 | -C linker=aarch64-linux-gnu-gcc \ 40 | -L/usr/aarch64-linux-gnu/lib \ 41 | -L/build/sysroot/usr/lib/aarch64-linux-gnu \ 42 | -L/build/sysroot/lib/aarch64-linux-gnu`; 43 | break; 44 | case "armv7-unknown-linux-gnueabihf": 45 | rustflags = `-Clink-arg=-lsystemd \ 46 | -Clink-arg=-lcap \ 47 | -Clink-arg=-lgcrypt \ 48 | -Clink-arg=-lgpg-error \ 49 | -Clink-arg=-llz4 \ 50 | -Clink-arg=-llzma \ 51 | -Clink-arg=-lpsx \ 52 | -Clink-arg=-lxxhash \ 53 | -Clink-arg=-lzstd \ 54 | -C linker=arm-linux-gnueabihf-gcc \ 55 | -L/usr/arm-linux-gnueabihf/lib \ 56 | -L/build/sysroot/usr/lib/arm-linux-gnueabihf \ 57 | -L/build/sysroot/lib/arm-linux-gnueabihf`; 58 | break; 59 | default: 60 | break; 61 | } 62 | return rustflags; 63 | } 64 | -------------------------------------------------------------------------------- /.github/workflows/release-for-mac.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [created] 4 | 5 | jobs: 6 | release: 7 | name: release x86_64-apple-darwin 8 | runs-on: macos-13 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | target: 13 | - x86_64-apple-darwin 14 | - aarch64-apple-darwin 15 | 16 | steps: 17 | - name: Setup Fluent CI CLI 18 | uses: fluentci-io/setup-fluentci@v5 19 | - name: Installing needed dependencies 20 | run: brew install protobuf llvm@15 21 | - name: Installing Rust toolchain 22 | uses: actions-rs/toolchain@v1 23 | with: 24 | toolchain: stable 25 | target: ${{ matrix.target }} 26 | override: true 27 | - name: Checking out sources 28 | uses: actions/checkout@v1 29 | - name: Running cargo build 30 | uses: actions-rs/cargo@v1 31 | with: 32 | command: build 33 | toolchain: stable 34 | args: --release --target ${{ matrix.target }} 35 | - name: Install aarch64-apple-darwin toolchain 36 | if: matrix.target == 'aarch64-apple-darwin' 37 | run: rustup target add aarch64-apple-darwin 38 | - name: Set env 39 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 40 | - name: Packaging final binary 41 | shell: bash 42 | run: | 43 | cd target/${{ matrix.target }}/release 44 | tar czvf ../../../tunein_${{ env.RELEASE_VERSION }}_${{ matrix.target }}.tar.gz tunein 45 | shasum -a 256 ../../../tunein_${{ env.RELEASE_VERSION }}_${{ matrix.target }}.tar.gz > ../../../tunein_${{ env.RELEASE_VERSION }}_${{ matrix.target }}.tar.gz.sha256 46 | cd ../../../ && rm -rf target 47 | - name: Upload release assets 48 | run: | 49 | for ext in tar.gz tar.gz.sha256; do 50 | export FILE=tunein_${{ env.RELEASE_VERSION }}_${{ matrix.target }}.$ext 51 | fluentci run github_pipeline release_upload 52 | done 53 | env: 54 | TAG: ${{ env.RELEASE_VERSION }} 55 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Tsiry Sandratraina "] 3 | categories = ["command-line-utilities"] 4 | description = "Browse and listen to thousands of radio stations across the globe right from your terminal 🌎 📻 🎵✨" 5 | edition = "2021" 6 | keywords = ["radio", "api", "tokio", "web", "tunein"] 7 | license = "MIT" 8 | name = "tunein-cli" 9 | readme = "README.md" 10 | repository = "https://github.com/tsirysndr/tunein-cli" 11 | version = "0.4.1" 12 | 13 | [[bin]] 14 | name = "tunein" 15 | path = "src/main.rs" 16 | 17 | [workspace.metadata.cross.target.aarch64-unknown-linux-gnu] 18 | pre-build = [ 19 | "dpkg --add-architecture $CROSS_DEB_ARCH", 20 | "apt-get update && apt-get --assume-yes install libasound2-dev libasound2-dev:$CROSS_DEB_ARCH protobuf-compiler", 21 | ] 22 | 23 | [profile.release] 24 | codegen-units = 1 25 | lto = true 26 | 27 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 28 | 29 | [dependencies] 30 | anyhow = "1.0.69" 31 | async-trait = "0.1.85" 32 | clap = "3.2.20" 33 | cpal = "0.14.0" 34 | crossterm = "0.27.0" 35 | derive_more = "0.99.17" 36 | futures = "0.3.26" 37 | futures-util = "0.3.26" 38 | hyper = { version = "0.14.23", features = [ 39 | "client", 40 | "stream", 41 | "tcp", 42 | "http1", 43 | "http2", 44 | ] } 45 | m3u = "1.0.0" 46 | minimp3 = "0.6" 47 | owo-colors = "3.5.0" 48 | directories = "5.0.1" 49 | pls = "0.2.2" 50 | prost = "0.13.2" 51 | radiobrowser = { version = "0.6.1", features = [ 52 | "default-rustls", 53 | ], default-features = false } 54 | ratatui = "0.26.1" 55 | regex = "1.11.1" 56 | reqwest = { version = "0.11.14", features = [ 57 | "blocking", 58 | "rustls-tls", 59 | ], default-features = false } 60 | rodio = { version = "0.16" } 61 | rustfft = "6.2.0" 62 | serde = { version = "1.0.197", features = ["derive"] } 63 | serde_json = "1.0.117" 64 | surf = { version = "2.3.2", features = [ 65 | "h1-client-rustls", 66 | ], default-features = false } 67 | symphonia = { version = "0.5.1", features = [ 68 | "aac", 69 | "alac", 70 | "mp3", 71 | "isomp4", 72 | "flac", 73 | ] } 74 | termion = "2.0.1" 75 | thiserror = "1.0.58" 76 | tokio = { version = "1.36.0", features = [ 77 | "tokio-macros", 78 | "macros", 79 | "rt", 80 | "rt-multi-thread", 81 | ] } 82 | tonic = "0.12.3" 83 | tonic-reflection = "0.12.3" 84 | tonic-web = "0.12.3" 85 | tunein = "0.1.3" 86 | url = "2.3.1" 87 | souvlaki = "0.8.3" 88 | 89 | [build-dependencies] 90 | tonic-build = "0.12.3" 91 | -------------------------------------------------------------------------------- /src/extract.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use anyhow::Error; 4 | use serde::Deserialize; 5 | use surf::{Client, Config, Url}; 6 | 7 | #[derive(Deserialize)] 8 | pub struct Header { 9 | #[serde(rename = "Title")] 10 | pub title: String, 11 | #[serde(rename = "Subtitle")] 12 | pub subtitle: String, 13 | } 14 | 15 | #[derive(Deserialize)] 16 | pub struct NowPlaying { 17 | #[serde(rename = "Header")] 18 | pub header: Header, 19 | } 20 | 21 | pub async fn extract_stream_url(url: &str, playlist_type: Option) -> Result { 22 | match playlist_type { 23 | Some(playlist_type) => match playlist_type.as_str() { 24 | "pls" => { 25 | let client: Client = Config::new() 26 | .set_timeout(Some(Duration::from_secs(5))) 27 | .try_into() 28 | .unwrap(); 29 | let response = client 30 | .get(Url::parse(url)?) 31 | .recv_string() 32 | .await 33 | .map_err(|e| Error::msg(e.to_string()))?; 34 | 35 | let mut response = response.replace("[Playlist]", "[playlist]"); 36 | if !response.contains("NumberOfEntries") { 37 | response = format!("{}\nNumberOfEntries=1", response); 38 | } 39 | 40 | let url = pls::parse(&mut response.as_bytes()) 41 | .map_err(|e| Error::msg(e.to_string()))? 42 | .first() 43 | .map(|e| e.path.clone()) 44 | .unwrap(); 45 | Ok(url.to_string()) 46 | } 47 | _ => Err(Error::msg(format!( 48 | "Playlist type {} not supported", 49 | playlist_type 50 | ))), 51 | }, 52 | None => Ok(url.to_string()), 53 | } 54 | } 55 | 56 | pub async fn get_currently_playing(station: &str) -> Result { 57 | let client = Client::new(); 58 | let url = format!( 59 | "https://feed.tunein.com/profiles/{}/nowPlaying?partnerId=RadioTime", 60 | station 61 | ); 62 | let response: NowPlaying = client 63 | .get(Url::parse(&url)?) 64 | .recv_json() 65 | .await 66 | .map_err(|e| Error::msg(e.to_string()))?; 67 | 68 | let subtitle = response.header.subtitle.trim(); 69 | if subtitle.is_empty() { 70 | Ok(response.header.title.trim().to_string()) 71 | } else { 72 | Ok(subtitle.to_string()) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/decoder.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | use std::{io::Read, sync::mpsc::Sender}; 3 | 4 | use minimp3::{Decoder, Frame}; 5 | use rodio::Source; 6 | 7 | pub struct Mp3Decoder 8 | where 9 | R: Read, 10 | { 11 | decoder: Decoder, 12 | current_frame: Frame, 13 | current_frame_offset: usize, 14 | tx: Option>, 15 | } 16 | 17 | impl Mp3Decoder 18 | where 19 | R: Read, 20 | { 21 | pub fn new(mut data: R, tx: Option>) -> Result { 22 | if !is_mp3(data.by_ref()) { 23 | return Err(data); 24 | } 25 | let mut decoder = Decoder::new(data); 26 | let current_frame = decoder.next_frame().unwrap(); 27 | 28 | Ok(Mp3Decoder { 29 | decoder, 30 | current_frame, 31 | current_frame_offset: 0, 32 | tx, 33 | }) 34 | } 35 | } 36 | 37 | impl Source for Mp3Decoder 38 | where 39 | R: Read, 40 | { 41 | #[inline] 42 | fn current_frame_len(&self) -> Option { 43 | Some(self.current_frame.data.len()) 44 | } 45 | 46 | #[inline] 47 | fn channels(&self) -> u16 { 48 | self.current_frame.channels as _ 49 | } 50 | 51 | #[inline] 52 | fn sample_rate(&self) -> u32 { 53 | self.current_frame.sample_rate as _ 54 | } 55 | 56 | #[inline] 57 | fn total_duration(&self) -> Option { 58 | None 59 | } 60 | } 61 | 62 | impl Iterator for Mp3Decoder 63 | where 64 | R: Read, 65 | { 66 | type Item = i16; 67 | 68 | #[inline] 69 | fn next(&mut self) -> Option { 70 | if self.current_frame_offset == self.current_frame.data.len() { 71 | match self.decoder.next_frame() { 72 | Ok(frame) => { 73 | if let Some(tx) = &self.tx { 74 | if tx.send(frame.clone()).is_err() { 75 | return None; 76 | } 77 | } 78 | self.current_frame = frame 79 | } 80 | _ => return None, 81 | } 82 | self.current_frame_offset = 0; 83 | } 84 | 85 | let v = self.current_frame.data[self.current_frame_offset]; 86 | self.current_frame_offset += 1; 87 | 88 | Some(v) 89 | } 90 | } 91 | 92 | /// Returns true if the stream contains mp3 data, then resets it to where it was. 93 | fn is_mp3(mut data: R) -> bool 94 | where 95 | R: Read, 96 | { 97 | let mut decoder = Decoder::new(data.by_ref()); 98 | let ok = decoder.next_frame().is_ok(); 99 | ok 100 | } 101 | -------------------------------------------------------------------------------- /src/server/playback.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Arc, Mutex}; 2 | 3 | use crate::player::{Player, PlayerCommand}; 4 | use tokio::sync::mpsc; 5 | use tunein_cli::provider::{tunein::Tunein, Provider}; 6 | use tunein_cli::{ 7 | api::tunein::v1alpha1::{ 8 | playback_service_server::PlaybackService, PlayOrPauseRequest, PlayOrPauseResponse, 9 | PlayRequest, PlayResponse, StopRequest, StopResponse, 10 | }, 11 | provider::radiobrowser::Radiobrowser, 12 | }; 13 | 14 | pub struct Playback { 15 | player: Player, 16 | cmd_tx: mpsc::UnboundedSender, 17 | } 18 | 19 | impl Default for Playback { 20 | fn default() -> Self { 21 | let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::(); 22 | let cmd_rx = Arc::new(Mutex::new(cmd_rx)); 23 | let player = Player::new(cmd_rx); 24 | Self { player, cmd_tx } 25 | } 26 | } 27 | 28 | #[tonic::async_trait] 29 | impl PlaybackService for Playback { 30 | async fn play( 31 | &self, 32 | request: tonic::Request, 33 | ) -> Result, tonic::Status> { 34 | let req = request.into_inner(); 35 | 36 | let provider = req.provider.as_deref(); 37 | 38 | let client: Box = match provider { 39 | Some("tunein") => Box::new(Tunein::new()), 40 | Some("radiobrowser") => Box::new(Radiobrowser::new().await), 41 | None => Box::new(Tunein::new()), 42 | _ => { 43 | return Err(tonic::Status::internal("Unsupported provider")); 44 | } 45 | }; 46 | 47 | let station = client 48 | .get_station(req.station_name_or_id.clone()) 49 | .await 50 | .map_err(|e| tonic::Status::internal(e.to_string()))?; 51 | 52 | if station.is_none() { 53 | return Err(tonic::Status::internal("No station found")); 54 | } 55 | 56 | let station = station.unwrap(); 57 | let stream_url = station.stream_url.clone(); 58 | println!("{}", stream_url); 59 | 60 | self.cmd_tx.send(PlayerCommand::Play(stream_url)).unwrap(); 61 | Ok(tonic::Response::new(PlayResponse {})) 62 | } 63 | 64 | async fn stop( 65 | &self, 66 | _request: tonic::Request, 67 | ) -> Result, tonic::Status> { 68 | self.cmd_tx.send(PlayerCommand::Stop).unwrap(); 69 | Ok(tonic::Response::new(StopResponse {})) 70 | } 71 | 72 | async fn play_or_pause( 73 | &self, 74 | _request: tonic::Request, 75 | ) -> Result, tonic::Status> { 76 | self.cmd_tx.send(PlayerCommand::PlayOrPause).unwrap(); 77 | Ok(tonic::Response::new(PlayOrPauseResponse {})) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | readonly MAGENTA="$(tput setaf 5 2>/dev/null || echo '')" 4 | readonly GREEN="$(tput setaf 2 2>/dev/null || echo '')" 5 | readonly CYAN="$(tput setaf 6 2>/dev/null || echo '')" 6 | readonly NO_COLOR="$(tput sgr0 2>/dev/null || echo '')" 7 | 8 | # Define the release information 9 | RELEASE_URL="https://api.github.com/repos/tsirysndr/tunein-cli/releases/latest" 10 | 11 | # Determine the operating system 12 | OS=$(uname -s) 13 | if [ "$OS" = "Darwin" ]; then 14 | # Determine the CPU architecture 15 | ARCH=$(uname -m) 16 | if [ "$ARCH" = "arm64" ]; then 17 | ASSET_NAME="_aarch64-apple-darwin.tar.gz" 18 | else 19 | ASSET_NAME="_x86_64-apple-darwin.tar.gz" 20 | fi 21 | elif [ "$OS" = "Linux" ]; then 22 | # Determine the CPU architecture 23 | ARCH=$(uname -m) 24 | if [ "$ARCH" = "aarch64" ]; then 25 | ASSET_NAME="_aarch64-unknown-linux-gnu.tar.gz" 26 | elif [ "$ARCH" = "x86_64" ]; then 27 | ASSET_NAME="_x86_64-unknown-linux-gnu.tar.gz" 28 | else 29 | echo "Unsupported architecture: $ARCH" 30 | exit 1 31 | fi 32 | else 33 | echo "Unsupported operating system: $OS" 34 | exit 1 35 | fi 36 | 37 | # Retrieve the download URL for the desired asset 38 | DOWNLOAD_URL=$(curl -sSL $RELEASE_URL | grep -o "browser_download_url.*$ASSET_NAME\"" | cut -d ' ' -f 2) 39 | 40 | ASSET_NAME=$(basename $DOWNLOAD_URL) 41 | 42 | # Define the installation directory 43 | INSTALL_DIR="/usr/local/bin" 44 | 45 | DOWNLOAD_URL=`echo $DOWNLOAD_URL | tr -d '\"'` 46 | 47 | # Download the asset 48 | curl -SL $DOWNLOAD_URL -o /tmp/$ASSET_NAME 49 | 50 | # Extract the asset 51 | tar -xzf /tmp/$ASSET_NAME -C /tmp 52 | 53 | # Set the correct permissions for the binary 54 | chmod +x /tmp/tunein 55 | 56 | # Move the extracted binary to the installation directory 57 | # use sudo if OS is Linux 58 | if [ "$OS" = "Linux" ]; then 59 | if command -v sudo >/dev/null 2>&1; then 60 | sudo mv /tmp/tunein $INSTALL_DIR 61 | else 62 | mv /tmp/tunein $INSTALL_DIR 63 | fi 64 | else 65 | mv /tmp/tunein $INSTALL_DIR 66 | fi 67 | 68 | # Clean up temporary files 69 | rm /tmp/$ASSET_NAME 70 | 71 | cat << EOF 72 | ${CYAN} 73 | ______ ____ _______ ____ 74 | /_ __/_ _____ ___ / _/__ / ___/ / / _/ 75 | / / / // / _ \\/ -_)/ // _ \\ / /__/ /___/ / 76 | /_/ \\_,_/_//_/\\__/___/_//_/ \\___/____/___/ 77 | 78 | ${NO_COLOR} 79 | Browse and listen to thousands of radio stations across the globe right from your terminal 🌎 📻 🎵✨ 80 | 81 | ${GREEN}https://github.com/tsirysndr/tunein${NO_COLOR} 82 | 83 | Please file an issue if you encounter any problems! 84 | 85 | =============================================================================== 86 | 87 | Installation completed! 🎉 88 | 89 | EOF 90 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. 49 | 50 | 51 | ## Licensing 52 | 53 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 54 | -------------------------------------------------------------------------------- /.fluentci/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug 4 | report, new feature, correction, or additional documentation, we greatly value 5 | feedback and contributions from our community. 6 | 7 | Please read through this document before submitting any issues or pull requests 8 | to ensure we have all the necessary information to effectively respond to your 9 | bug report or contribution. 10 | 11 | ## Reporting Bugs/Feature Requests 12 | 13 | We welcome you to use the GitHub issue tracker to report bugs or suggest 14 | features. 15 | 16 | When filing an issue, please check existing open, or recently closed, issues to 17 | make sure somebody else hasn't already reported the issue. Please try to include 18 | as much information as you can. Details like these are incredibly useful: 19 | 20 | - A reproducible test case or series of steps 21 | - The version of our code being used 22 | - Any modifications you've made relevant to the bug 23 | - Anything unusual about your environment or deployment 24 | 25 | ## Contributing via Pull Requests 26 | 27 | Contributions via pull requests are much appreciated. Before sending us a pull 28 | request, please ensure that: 29 | 30 | 1. You are working against the latest source on the _master_ branch. 31 | 2. You check existing open, and recently merged, pull requests to make sure 32 | someone else hasn't addressed the problem already. 33 | 3. You open an issue to discuss any significant work - we would hate for your 34 | time to be wasted. 35 | 36 | To send us a pull request, please: 37 | 38 | 1. Fork the repository. 39 | 2. Modify the source; please focus on the specific change you are contributing. 40 | If you also reformat all the code, it will be hard for us to focus on your 41 | change. 42 | 3. Ensure local tests pass. 43 | 4. Commit to your fork using clear commit messages. 44 | 5. Send us a pull request, answering any default questions in the pull request 45 | interface. 46 | 6. Pay attention to any automated CI failures reported in the pull request, and 47 | stay involved in the conversation. 48 | 49 | GitHub provides additional document on 50 | [forking a repository](https://help.github.com/articles/fork-a-repo/) and 51 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 52 | 53 | ## Finding contributions to work on 54 | 55 | Looking at the existing issues is a great way to find something to contribute 56 | on. As our projects, by default, use the default GitHub issue labels 57 | (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 58 | 'help wanted' issues is a great place to start. 59 | 60 | ## Code of Conduct 61 | 62 | This project has adopted the 63 | [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1, 64 | available at 65 | https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. 66 | 67 | ## Licensing 68 | 69 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to 70 | confirm the licensing of your contribution. 71 | -------------------------------------------------------------------------------- /src/visualization/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod oscilloscope; 2 | pub mod spectroscope; 3 | pub mod vectorscope; 4 | 5 | use crossterm::event::Event; 6 | use ratatui::{ 7 | style::{Color, Style}, 8 | symbols::Marker, 9 | widgets::{Axis, Dataset, GraphType}, 10 | }; 11 | 12 | use crate::input::Matrix; 13 | 14 | pub enum Dimension { 15 | X, 16 | Y, 17 | } 18 | 19 | #[derive(Debug, Clone)] 20 | pub struct GraphConfig { 21 | pub pause: bool, 22 | pub samples: u32, 23 | #[allow(dead_code)] 24 | pub sampling_rate: u32, 25 | pub scale: f64, 26 | pub width: u32, 27 | pub scatter: bool, 28 | pub references: bool, 29 | pub show_ui: bool, 30 | pub marker_type: Marker, 31 | pub palette: Vec, 32 | pub labels_color: Color, 33 | pub axis_color: Color, 34 | } 35 | 36 | impl GraphConfig { 37 | pub fn palette(&self, index: usize) -> Color { 38 | *self 39 | .palette 40 | .get(index % self.palette.len()) 41 | .unwrap_or(&Color::White) 42 | } 43 | } 44 | 45 | #[allow(clippy::ptr_arg)] 46 | pub trait DisplayMode { 47 | // MUST define 48 | fn from_args(args: &crate::cfg::SourceOptions) -> Self 49 | where 50 | Self: Sized; 51 | fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_>; // TODO simplify this 52 | fn process(&mut self, cfg: &GraphConfig, data: &Matrix) -> Vec; 53 | fn mode_str(&self) -> &'static str; 54 | 55 | // SHOULD override 56 | fn channel_name(&self, index: usize) -> String { 57 | format!("{}", index) 58 | } 59 | fn header(&self, _cfg: &GraphConfig) -> String { 60 | "".into() 61 | } 62 | fn references(&self, _cfg: &GraphConfig) -> Vec { 63 | vec![] 64 | } 65 | fn handle(&mut self, _event: Event) {} 66 | } 67 | 68 | pub struct DataSet { 69 | name: Option, 70 | data: Vec<(f64, f64)>, 71 | marker_type: Marker, 72 | graph_type: GraphType, 73 | color: Color, 74 | } 75 | 76 | impl<'a> From<&'a DataSet> for Dataset<'a> { 77 | fn from(ds: &'a DataSet) -> Dataset<'a> { 78 | let mut out = Dataset::default(); // TODO creating a binding is kinda ugly, is it avoidable? 79 | if let Some(name) = &ds.name { 80 | out = out.name(name.clone()); 81 | } 82 | out.marker(ds.marker_type) 83 | .graph_type(ds.graph_type) 84 | .style(Style::default().fg(ds.color)) 85 | .data(&ds.data) 86 | } 87 | } 88 | 89 | // TODO this is pretty ugly but I need datasets which own the data 90 | impl DataSet { 91 | pub fn new( 92 | name: Option, 93 | data: Vec<(f64, f64)>, 94 | marker_type: Marker, 95 | graph_type: GraphType, 96 | color: Color, 97 | ) -> Self { 98 | DataSet { 99 | name, 100 | data, 101 | marker_type, 102 | graph_type, 103 | color, 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/music.rs: -------------------------------------------------------------------------------- 1 | use std::{num::ParseIntError, str::FromStr}; 2 | 3 | #[derive(Debug, PartialEq, Clone)] 4 | pub enum Tone { 5 | C, 6 | Db, 7 | D, 8 | Eb, 9 | E, 10 | F, 11 | Gb, 12 | G, 13 | Ab, 14 | A, 15 | Bb, 16 | B, 17 | } 18 | 19 | #[derive(Debug, thiserror::Error, derive_more::Display)] 20 | pub struct ToneError(); 21 | 22 | #[derive(Debug, PartialEq, Clone)] 23 | pub struct Note { 24 | tone: Tone, 25 | octave: u32, 26 | } 27 | 28 | #[derive(Debug, thiserror::Error, derive_more::From, derive_more::Display)] 29 | pub enum NoteError { 30 | InvalidOctave(ParseIntError), 31 | InalidNote(ToneError), 32 | } 33 | 34 | impl FromStr for Note { 35 | type Err = NoteError; 36 | 37 | fn from_str(txt: &str) -> Result { 38 | let trimmed = txt.trim(); 39 | let mut split = 0; 40 | for c in trimmed.chars() { 41 | if !c.is_ascii_digit() { 42 | split += 1; 43 | } else { 44 | break; 45 | } 46 | } 47 | Ok(Note { 48 | tone: trimmed[..split].parse::()?, 49 | octave: trimmed[split..].parse::().unwrap_or(0), 50 | }) 51 | } 52 | } 53 | 54 | impl FromStr for Tone { 55 | type Err = ToneError; 56 | 57 | fn from_str(txt: &str) -> Result { 58 | match txt { 59 | "C" => Ok(Tone::C), 60 | "C#" | "Db" => Ok(Tone::Db), 61 | "D" => Ok(Tone::D), 62 | "D#" | "Eb" => Ok(Tone::Eb), 63 | "E" => Ok(Tone::E), 64 | "F" => Ok(Tone::F), 65 | "F#" | "Gb" => Ok(Tone::Gb), 66 | "G" => Ok(Tone::G), 67 | "G#" | "Ab" => Ok(Tone::Ab), 68 | "A" => Ok(Tone::A), 69 | "A#" | "Bb" => Ok(Tone::Bb), 70 | "B" => Ok(Tone::B), 71 | _ => Err(ToneError()), 72 | } 73 | } 74 | } 75 | 76 | impl Note { 77 | pub fn tune_buffer_size(&self, sample_rate: u32) -> u32 { 78 | let t = 1.0 / self.tone.freq(self.octave); // periodo ? 79 | let buf = (sample_rate as f32) * t; 80 | buf.round() as u32 81 | } 82 | } 83 | 84 | impl Tone { 85 | pub fn freq(&self, octave: u32) -> f32 { 86 | match octave { 87 | 0 => match self { 88 | Tone::C => 16.35, 89 | Tone::Db => 17.32, 90 | Tone::D => 18.35, 91 | Tone::Eb => 19.45, 92 | Tone::E => 20.60, 93 | Tone::F => 21.83, 94 | Tone::Gb => 23.12, 95 | Tone::G => 24.50, 96 | Tone::Ab => 25.96, 97 | Tone::A => 27.50, 98 | Tone::Bb => 29.14, 99 | Tone::B => 30.87, 100 | }, 101 | _ => { 102 | let mut freq = self.freq(0); 103 | for _ in 0..octave { 104 | freq *= 2.0; 105 | } 106 | freq 107 | } 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/os_media_controls.rs: -------------------------------------------------------------------------------- 1 | //! Operating system level media controls. 2 | 3 | use tokio::sync::mpsc::UnboundedReceiver; 4 | 5 | /// Operating system level media controls. 6 | #[derive(Debug)] 7 | pub struct OsMediaControls { 8 | /// Controls that interface with the OS. 9 | controls: souvlaki::MediaControls, 10 | /// Receiver for events produced by OS level interaction. 11 | event_receiver: UnboundedReceiver, 12 | } 13 | 14 | impl OsMediaControls { 15 | /// Create new [`OsMediaControls`]. 16 | pub fn new() -> Result { 17 | let mut controls = souvlaki::MediaControls::new(souvlaki::PlatformConfig { 18 | display_name: "tunein-cli", 19 | dbus_name: "tsirysndr.tunein-cli", 20 | // TODO: support windows platform 21 | hwnd: None, 22 | })?; 23 | 24 | let (event_sender, event_receiver) = 25 | tokio::sync::mpsc::unbounded_channel::(); 26 | 27 | controls.attach(move |event| { 28 | event_sender.send(event).expect("receiver always alive"); 29 | })?; 30 | 31 | Ok(Self { 32 | controls, 33 | event_receiver, 34 | }) 35 | } 36 | 37 | /// Try to receive event produced by the operating system. 38 | /// 39 | /// Is [`None`] if no event is produced. 40 | pub fn try_recv_os_event(&mut self) -> Option { 41 | self.event_receiver.try_recv().ok() 42 | } 43 | 44 | /// Send the given [`Command`] to the operating system. 45 | pub fn send_to_os(&mut self, command: Command) -> Result<(), souvlaki::Error> { 46 | match command { 47 | Command::Play => self 48 | .controls 49 | .set_playback(souvlaki::MediaPlayback::Playing { progress: None }), 50 | Command::Pause => self 51 | .controls 52 | .set_playback(souvlaki::MediaPlayback::Paused { progress: None }), 53 | Command::SetVolume(volume) => { 54 | // NOTE: is supported only for MPRIS backend, 55 | // `souvlaki` doesn't provide a way to know this, so 56 | // need to use `cfg` attribute like the way it exposes 57 | // the platform 58 | #[cfg(all( 59 | unix, 60 | not(any(target_os = "macos", target_os = "ios", target_os = "android")) 61 | ))] 62 | { 63 | self.controls.set_volume(volume) 64 | } 65 | #[cfg(not(all( 66 | unix, 67 | not(any(target_os = "macos", target_os = "ios", target_os = "android")) 68 | )))] 69 | { 70 | Ok(()) 71 | } 72 | } 73 | Command::SetMetadata(metadata) => self.controls.set_metadata(metadata), 74 | } 75 | } 76 | } 77 | 78 | /// Commands understood by OS media controls. 79 | #[derive(Debug, Clone)] 80 | pub enum Command<'a> { 81 | Play, 82 | Pause, 83 | /// Volume must be between `0.0..=1.0`. 84 | SetVolume(f64), 85 | /// Set the [`souvlaki::MediaMetadata`]. 86 | SetMetadata(souvlaki::MediaMetadata<'a>), 87 | } 88 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "advisory-db": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1756294590, 7 | "narHash": "sha256-CyhicqYGMUCtBAbsyMKIuQVYl5D7m+yh/E0wonouF+A=", 8 | "owner": "rustsec", 9 | "repo": "advisory-db", 10 | "rev": "11793a852bab94279ef3efc26d69face55a9e2ba", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "rustsec", 15 | "repo": "advisory-db", 16 | "type": "github" 17 | } 18 | }, 19 | "crane": { 20 | "locked": { 21 | "lastModified": 1755993354, 22 | "narHash": "sha256-FCRRAzSaL/+umLIm3RU3O/+fJ2ssaPHseI2SSFL8yZU=", 23 | "owner": "ipetkov", 24 | "repo": "crane", 25 | "rev": "25bd41b24426c7734278c2ff02e53258851db914", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "ipetkov", 30 | "repo": "crane", 31 | "type": "github" 32 | } 33 | }, 34 | "fenix": { 35 | "inputs": { 36 | "nixpkgs": [ 37 | "nixpkgs" 38 | ], 39 | "rust-analyzer-src": [] 40 | }, 41 | "locked": { 42 | "lastModified": 1755585599, 43 | "narHash": "sha256-tl/0cnsqB/Yt7DbaGMel2RLa7QG5elA8lkaOXli6VdY=", 44 | "owner": "nix-community", 45 | "repo": "fenix", 46 | "rev": "6ed03ef4c8ec36d193c18e06b9ecddde78fb7e42", 47 | "type": "github" 48 | }, 49 | "original": { 50 | "owner": "nix-community", 51 | "repo": "fenix", 52 | "type": "github" 53 | } 54 | }, 55 | "flake-utils": { 56 | "inputs": { 57 | "systems": "systems" 58 | }, 59 | "locked": { 60 | "lastModified": 1731533236, 61 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 62 | "owner": "numtide", 63 | "repo": "flake-utils", 64 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 65 | "type": "github" 66 | }, 67 | "original": { 68 | "owner": "numtide", 69 | "repo": "flake-utils", 70 | "type": "github" 71 | } 72 | }, 73 | "nixpkgs": { 74 | "locked": { 75 | "lastModified": 1756266583, 76 | "narHash": "sha256-cr748nSmpfvnhqSXPiCfUPxRz2FJnvf/RjJGvFfaCsM=", 77 | "owner": "NixOS", 78 | "repo": "nixpkgs", 79 | "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", 80 | "type": "github" 81 | }, 82 | "original": { 83 | "owner": "NixOS", 84 | "ref": "nixos-unstable", 85 | "repo": "nixpkgs", 86 | "type": "github" 87 | } 88 | }, 89 | "root": { 90 | "inputs": { 91 | "advisory-db": "advisory-db", 92 | "crane": "crane", 93 | "fenix": "fenix", 94 | "flake-utils": "flake-utils", 95 | "nixpkgs": "nixpkgs" 96 | } 97 | }, 98 | "systems": { 99 | "locked": { 100 | "lastModified": 1681028828, 101 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 102 | "owner": "nix-systems", 103 | "repo": "default", 104 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 105 | "type": "github" 106 | }, 107 | "original": { 108 | "owner": "nix-systems", 109 | "repo": "default", 110 | "type": "github" 111 | } 112 | } 113 | }, 114 | "root": "root", 115 | "version": 7 116 | } 117 | -------------------------------------------------------------------------------- /src/service.rs: -------------------------------------------------------------------------------- 1 | use std::{path::Path, process::Command}; 2 | 3 | use anyhow::Error; 4 | 5 | const SERVICE_TEMPLATE: &str = include_str!("./systemd/tunein.service"); 6 | 7 | pub fn install() -> Result<(), Error> { 8 | if cfg!(not(target_os = "linux")) { 9 | println!("This command is only supported on Linux"); 10 | std::process::exit(1); 11 | } 12 | 13 | let home = std::env::var("HOME")?; 14 | let service_path: &str = &format!("{}/.config/systemd/user/tunein.service", home); 15 | std::fs::create_dir_all(format!("{}/.config/systemd/user", home)) 16 | .expect("Failed to create systemd user directory"); 17 | 18 | if Path::new(service_path).exists() { 19 | println!("Service file already exists. Nothing to install."); 20 | return Ok(()); 21 | } 22 | 23 | let tunein_path = std::env::current_exe()?; 24 | 25 | let service_template: &str = &SERVICE_TEMPLATE.replace( 26 | "ExecStart=/usr/bin/tunein", 27 | &format!("ExecStart={}", tunein_path.display()), 28 | ); 29 | 30 | std::fs::write(service_path, service_template).expect("Failed to write service file"); 31 | 32 | Command::new("systemctl") 33 | .arg("--user") 34 | .arg("daemon-reload") 35 | .status()?; 36 | 37 | Command::new("systemctl") 38 | .arg("--user") 39 | .arg("enable") 40 | .arg("tunein") 41 | .status()?; 42 | 43 | Command::new("systemctl") 44 | .arg("--user") 45 | .arg("start") 46 | .arg("tunein") 47 | .status()?; 48 | 49 | println!("✅ Tunein service installed successfully!"); 50 | 51 | Ok(()) 52 | } 53 | 54 | pub fn uninstall() -> Result<(), Error> { 55 | if cfg!(not(target_os = "linux")) { 56 | println!("This command is only supported on Linux"); 57 | std::process::exit(1); 58 | } 59 | 60 | let home = std::env::var("HOME")?; 61 | let service_path: &str = &format!("{}/.config/systemd/user/tunein.service", home); 62 | 63 | if Path::new(service_path).exists() { 64 | Command::new("systemctl") 65 | .arg("--user") 66 | .arg("stop") 67 | .arg("tunein") 68 | .status()?; 69 | 70 | Command::new("systemctl") 71 | .arg("--user") 72 | .arg("disable") 73 | .arg("tunein") 74 | .status()?; 75 | 76 | std::fs::remove_file(service_path).expect("Failed to remove service file"); 77 | 78 | Command::new("systemctl") 79 | .arg("--user") 80 | .arg("daemon-reload") 81 | .status()?; 82 | 83 | println!("✅ Tunein service uninstalled successfully!"); 84 | } else { 85 | println!("Service file does not exist. Nothing to uninstall."); 86 | } 87 | 88 | Ok(()) 89 | } 90 | 91 | pub fn status() -> Result<(), Error> { 92 | if cfg!(not(target_os = "linux")) { 93 | println!("This command is only supported on Linux"); 94 | std::process::exit(1); 95 | } 96 | 97 | let home = std::env::var("HOME")?; 98 | let service_path: &str = &format!("{}/.config/systemd/user/tunein.service", home); 99 | 100 | if Path::new(service_path).exists() { 101 | Command::new("systemctl") 102 | .arg("--user") 103 | .arg("status") 104 | .arg("tunein") 105 | .status()?; 106 | } else { 107 | println!("Service file does not exist. Tunein service is not installed."); 108 | } 109 | 110 | Ok(()) 111 | } 112 | -------------------------------------------------------------------------------- /src/visualization/vectorscope.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | style::Style, 3 | text::Span, 4 | widgets::{Axis, GraphType}, 5 | }; 6 | 7 | use crate::input::Matrix; 8 | 9 | use super::{DataSet, Dimension, DisplayMode, GraphConfig}; 10 | 11 | #[derive(Default)] 12 | pub struct Vectorscope {} 13 | 14 | impl DisplayMode for Vectorscope { 15 | fn from_args(_opts: &crate::cfg::SourceOptions) -> Self { 16 | Vectorscope::default() 17 | } 18 | 19 | fn mode_str(&self) -> &'static str { 20 | "vector" 21 | } 22 | 23 | fn channel_name(&self, index: usize) -> String { 24 | format!("{}", index) 25 | } 26 | 27 | fn header(&self, _: &GraphConfig) -> String { 28 | "live".into() 29 | } 30 | 31 | fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_> { 32 | let (name, bounds) = match dimension { 33 | Dimension::X => ("left -", [-cfg.scale, cfg.scale]), 34 | Dimension::Y => ("| right", [-cfg.scale, cfg.scale]), 35 | }; 36 | let mut a = Axis::default(); 37 | if cfg.show_ui { 38 | // TODO don't make it necessary to check show_ui inside here 39 | a = a.title(Span::styled(name, Style::default().fg(cfg.labels_color))); 40 | } 41 | a.style(Style::default().fg(cfg.axis_color)).bounds(bounds) 42 | } 43 | 44 | fn references(&self, cfg: &GraphConfig) -> Vec { 45 | vec![ 46 | DataSet::new( 47 | None, 48 | vec![(-cfg.scale, 0.0), (cfg.scale, 0.0)], 49 | cfg.marker_type, 50 | GraphType::Line, 51 | cfg.axis_color, 52 | ), 53 | DataSet::new( 54 | None, 55 | vec![(0.0, -cfg.scale), (0.0, cfg.scale)], 56 | cfg.marker_type, 57 | GraphType::Line, 58 | cfg.axis_color, 59 | ), 60 | ] 61 | } 62 | 63 | fn process(&mut self, cfg: &GraphConfig, data: &Matrix) -> Vec { 64 | let mut out = Vec::new(); 65 | 66 | for (n, chunk) in data.chunks(2).enumerate() { 67 | let mut tmp = vec![]; 68 | match chunk.len() { 69 | 2 => { 70 | for i in 0..std::cmp::min(chunk[0].len(), chunk[1].len()) { 71 | if i > cfg.samples as usize { 72 | break; 73 | } 74 | tmp.push((chunk[0][i], chunk[1][i])); 75 | } 76 | } 77 | 1 => { 78 | for i in 0..chunk[0].len() { 79 | if i > cfg.samples as usize { 80 | break; 81 | } 82 | tmp.push((chunk[0][i], i as f64)); 83 | } 84 | } 85 | _ => continue, 86 | } 87 | // split it in two for easier coloring 88 | // TODO configure splitting in multiple parts? 89 | let pivot = tmp.len() / 2; 90 | out.push(DataSet::new( 91 | Some(self.channel_name((n * 2) + 1)), 92 | tmp[pivot..].to_vec(), 93 | cfg.marker_type, 94 | if cfg.scatter { 95 | GraphType::Scatter 96 | } else { 97 | GraphType::Line 98 | }, 99 | cfg.palette((n * 2) + 1), 100 | )); 101 | out.push(DataSet::new( 102 | Some(self.channel_name(n * 2)), 103 | tmp[..pivot].to_vec(), 104 | cfg.marker_type, 105 | if cfg.scatter { 106 | GraphType::Scatter 107 | } else { 108 | GraphType::Line 109 | }, 110 | cfg.palette(n * 2), 111 | )); 112 | } 113 | 114 | out 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/favorites.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::{Path, PathBuf}; 3 | 4 | use anyhow::{Context, Error}; 5 | use directories::ProjectDirs; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | /// Metadata describing a favourited station. 9 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 10 | pub struct FavoriteStation { 11 | pub id: String, 12 | pub name: String, 13 | pub provider: String, 14 | } 15 | 16 | /// File-backed favourites store. 17 | pub struct FavoritesStore { 18 | path: PathBuf, 19 | favorites: Vec, 20 | } 21 | 22 | impl FavoritesStore { 23 | /// Load favourites from disk, falling back to an empty list when the file 24 | /// does not exist or is corrupted. 25 | pub fn load() -> Result { 26 | let path = favorites_path()?; 27 | ensure_parent(&path)?; 28 | 29 | let favorites = match fs::read_to_string(&path) { 30 | Ok(content) => match serde_json::from_str::>(&content) { 31 | Ok(entries) => entries, 32 | Err(err) => { 33 | eprintln!( 34 | "warning: favourites file corrupted ({}), starting fresh", 35 | err 36 | ); 37 | Vec::new() 38 | } 39 | }, 40 | Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(), 41 | Err(err) => return Err(Error::from(err).context("failed to read favourites file")), 42 | }; 43 | 44 | Ok(Self { path, favorites }) 45 | } 46 | 47 | /// Return a snapshot of all favourite stations. 48 | pub fn all(&self) -> &[FavoriteStation] { 49 | &self.favorites 50 | } 51 | 52 | /// Check whether the provided station is already a favourite. 53 | pub fn is_favorite(&self, id: &str, provider: &str) -> bool { 54 | self.favorites 55 | .iter() 56 | .any(|fav| fav.id == id && fav.provider == provider) 57 | } 58 | 59 | /// Add a station to favourites if it is not already present. 60 | pub fn add(&mut self, favorite: FavoriteStation) -> Result<(), Error> { 61 | if !self.is_favorite(&favorite.id, &favorite.provider) { 62 | self.favorites.push(favorite); 63 | self.save()?; 64 | } 65 | Ok(()) 66 | } 67 | 68 | /// Remove a station from favourites. 69 | pub fn remove(&mut self, id: &str, provider: &str) -> Result<(), Error> { 70 | let initial_len = self.favorites.len(); 71 | self.favorites 72 | .retain(|fav| !(fav.id == id && fav.provider == provider)); 73 | if self.favorites.len() != initial_len { 74 | self.save()?; 75 | } 76 | Ok(()) 77 | } 78 | 79 | /// Toggle a station in favourites, returning whether it was added (`true`) or removed (`false`). 80 | pub fn toggle(&mut self, favorite: FavoriteStation) -> Result { 81 | if self.is_favorite(&favorite.id, &favorite.provider) { 82 | self.remove(&favorite.id, &favorite.provider)?; 83 | Ok(false) 84 | } else { 85 | self.add(favorite)?; 86 | Ok(true) 87 | } 88 | } 89 | 90 | fn save(&self) -> Result<(), Error> { 91 | let serialized = serde_json::to_string_pretty(&self.favorites) 92 | .context("failed to serialize favourites list")?; 93 | fs::write(&self.path, serialized).context("failed to write favourites file") 94 | } 95 | } 96 | 97 | fn favorites_path() -> Result { 98 | let dirs = ProjectDirs::from("io", "tunein-cli", "tunein-cli") 99 | .ok_or_else(|| Error::msg("unable to determine configuration directory"))?; 100 | Ok(dirs.config_dir().join("favorites.json")) 101 | } 102 | 103 | fn ensure_parent(path: &Path) -> Result<(), Error> { 104 | if let Some(parent) = path.parent() { 105 | fs::create_dir_all(parent).context("failed to create favourites directory")?; 106 | } 107 | Ok(()) 108 | } 109 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | use std::thread; 2 | 3 | use radiobrowser::ApiStation; 4 | use tunein::types::{SearchResult, StationLinkDetails}; 5 | 6 | use crate::extract::extract_stream_url; 7 | 8 | #[derive(Debug, Clone)] 9 | pub struct Station { 10 | pub id: String, 11 | pub name: String, 12 | pub codec: String, 13 | pub bitrate: u32, 14 | pub stream_url: String, 15 | pub playing: Option, 16 | } 17 | 18 | impl From for Station { 19 | fn from(station: ApiStation) -> Station { 20 | Station { 21 | id: station.stationuuid, 22 | name: station.name, 23 | codec: station.codec, 24 | bitrate: station.bitrate, 25 | stream_url: station.url_resolved, 26 | playing: None, 27 | } 28 | } 29 | } 30 | 31 | impl From for Station { 32 | fn from(result: SearchResult) -> Station { 33 | Station { 34 | id: result.guide_id.unwrap_or_default(), 35 | name: result.text, 36 | bitrate: result 37 | .bitrate 38 | .unwrap_or("0".to_string()) 39 | .parse() 40 | .unwrap_or_default(), 41 | codec: Default::default(), 42 | stream_url: Default::default(), 43 | playing: result.subtext, 44 | } 45 | } 46 | } 47 | 48 | impl From> for Station { 49 | fn from(result: Box) -> Station { 50 | Station { 51 | id: result.guide_id.unwrap_or_default(), 52 | name: result.text, 53 | bitrate: result 54 | .bitrate 55 | .unwrap_or("0".to_string()) 56 | .parse() 57 | .unwrap_or_default(), 58 | codec: Default::default(), 59 | stream_url: Default::default(), 60 | playing: None, 61 | } 62 | } 63 | } 64 | 65 | impl From for Station { 66 | fn from(details: StationLinkDetails) -> Station { 67 | let handle = thread::spawn(move || { 68 | let rt = tokio::runtime::Runtime::new().unwrap(); 69 | rt.block_on(extract_stream_url(&details.url, details.playlist_type)) 70 | }); 71 | let stream_url = handle.join().unwrap().unwrap(); 72 | Station { 73 | id: Default::default(), 74 | name: Default::default(), 75 | bitrate: details.bitrate, 76 | stream_url, 77 | codec: details.media_type.to_uppercase(), 78 | playing: None, 79 | } 80 | } 81 | } 82 | 83 | impl From for Station { 84 | fn from(st: tunein::types::Station) -> Station { 85 | Station { 86 | id: st.guide_id.unwrap_or_default(), 87 | name: st.text, 88 | bitrate: st 89 | .bitrate 90 | .unwrap_or("0".to_string()) 91 | .parse() 92 | .unwrap_or_default(), 93 | stream_url: Default::default(), 94 | codec: st.formats.unwrap_or_default().to_uppercase(), 95 | playing: st.playing, 96 | } 97 | } 98 | } 99 | 100 | impl From> for Station { 101 | fn from(st: Box) -> Station { 102 | Station { 103 | id: st.guide_id.unwrap_or_default(), 104 | name: st.text, 105 | bitrate: st 106 | .bitrate 107 | .unwrap_or("0".to_string()) 108 | .parse() 109 | .unwrap_or_default(), 110 | stream_url: Default::default(), 111 | codec: st.formats.unwrap_or_default().to_uppercase(), 112 | playing: st.playing, 113 | } 114 | } 115 | } 116 | 117 | impl From for Station { 118 | fn from(ct: tunein::types::CategoryDetails) -> Station { 119 | Station { 120 | id: ct.guide_id.unwrap_or_default(), 121 | name: ct.text, 122 | bitrate: 0, 123 | stream_url: Default::default(), 124 | codec: Default::default(), 125 | playing: None, 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod extract; 2 | pub mod os_media_controls; 3 | pub mod provider; 4 | pub mod types; 5 | 6 | pub mod api { 7 | #[path = ""] 8 | pub mod tunein { 9 | use super::super::types; 10 | use tunein::types::CategoryDetails; 11 | 12 | use super::objects::v1alpha1::{Category, Station, StationLinkDetails}; 13 | 14 | #[path = "tunein.v1alpha1.rs"] 15 | pub mod v1alpha1; 16 | 17 | pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!("api/descriptor.bin"); 18 | 19 | impl From for Category { 20 | fn from(name: String) -> Self { 21 | Self { 22 | name, 23 | ..Default::default() 24 | } 25 | } 26 | } 27 | 28 | impl From for Category { 29 | fn from(st: crate::types::Station) -> Self { 30 | Self { 31 | id: st.id, 32 | name: st.name, 33 | ..Default::default() 34 | } 35 | } 36 | } 37 | 38 | impl From for Category { 39 | fn from(category: CategoryDetails) -> Self { 40 | Self { 41 | id: category.guide_id.unwrap_or_default(), 42 | name: category.text, 43 | stations: category 44 | .children 45 | .map(|c| { 46 | c.into_iter() 47 | .map(|x| Station { 48 | id: x.guide_id.unwrap_or_default(), 49 | name: x.text, 50 | playing: x.playing.unwrap_or_default(), 51 | }) 52 | .collect() 53 | }) 54 | .unwrap_or(vec![]), 55 | } 56 | } 57 | } 58 | 59 | impl From for Category { 60 | fn from(s: tunein::types::Station) -> Self { 61 | Self { 62 | id: s.guide_id.unwrap_or_default(), 63 | name: s.text, 64 | stations: s 65 | .children 66 | .map(|c| { 67 | c.into_iter() 68 | .map(|x| Station { 69 | id: x.guide_id.unwrap_or_default(), 70 | name: x.text, 71 | playing: x.playing.unwrap_or_default(), 72 | }) 73 | .collect() 74 | }) 75 | .unwrap_or(vec![]), 76 | } 77 | } 78 | } 79 | 80 | impl From for StationLinkDetails { 81 | fn from(s: types::Station) -> Self { 82 | Self { 83 | bitrate: s.bitrate, 84 | url: s.stream_url, 85 | media_type: s.codec, 86 | ..Default::default() 87 | } 88 | } 89 | } 90 | 91 | impl From for Station { 92 | fn from(s: types::Station) -> Self { 93 | Self { 94 | id: s.id, 95 | name: s.name, 96 | playing: s.playing.unwrap_or_default(), 97 | } 98 | } 99 | } 100 | 101 | impl From for StationLinkDetails { 102 | fn from(s: tunein::types::StationLinkDetails) -> Self { 103 | Self { 104 | bitrate: s.bitrate, 105 | element: s.element, 106 | is_ad_clipped_content_enabled: s.is_ad_clipped_content_enabled, 107 | is_direct: s.is_direct, 108 | is_hls_advanced: s.is_hls_advanced, 109 | live_seek_stream: s.live_seek_stream, 110 | media_type: s.media_type, 111 | player_height: s.player_height, 112 | player_width: s.player_width, 113 | playlist_type: s.playlist_type.unwrap_or_default(), 114 | position: s.position, 115 | reliability: s.reliability, 116 | url: s.url, 117 | } 118 | } 119 | } 120 | } 121 | 122 | #[path = ""] 123 | pub mod objects { 124 | #[path = "objects.v1alpha1.rs"] 125 | pub mod v1alpha1; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/player.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | pin::Pin, 3 | sync::{Arc, Mutex}, 4 | task::{Context, Poll}, 5 | thread, 6 | time::Duration, 7 | }; 8 | 9 | use anyhow::Error; 10 | use futures_util::Future; 11 | use reqwest::blocking::Response; 12 | use rodio::{OutputStream, OutputStreamHandle, Sink}; 13 | use tokio::sync::mpsc; 14 | 15 | use crate::decoder::Mp3Decoder; 16 | 17 | pub struct Player; 18 | 19 | impl Player { 20 | pub fn new(cmd_rx: Arc>>) -> Self { 21 | thread::spawn(move || { 22 | let internal = PlayerInternal::new(cmd_rx); 23 | futures::executor::block_on(internal); 24 | }); 25 | Self {} 26 | } 27 | } 28 | 29 | #[derive(Debug)] 30 | pub enum PlayerCommand { 31 | Play(String), 32 | PlayOrPause, 33 | Stop, 34 | } 35 | 36 | struct PlayerInternal { 37 | sink: Arc>, 38 | stream: OutputStream, 39 | handle: OutputStreamHandle, 40 | commands: Arc>>, 41 | decoder: Option>, 42 | } 43 | 44 | impl PlayerInternal { 45 | fn new(cmd_rx: Arc>>) -> Self { 46 | let (stream, handle) = rodio::OutputStream::try_default().unwrap(); 47 | Self { 48 | sink: Arc::new(Mutex::new(rodio::Sink::try_new(&handle).unwrap())), 49 | stream, 50 | handle, 51 | commands: cmd_rx, 52 | decoder: None, 53 | } 54 | } 55 | 56 | fn handle_play(&mut self, url: String) -> Result<(), Error> { 57 | let (stream, handle) = rodio::OutputStream::try_default().unwrap(); 58 | self.stream = stream; 59 | self.sink = Arc::new(Mutex::new(rodio::Sink::try_new(&handle).unwrap())); 60 | self.handle = handle; 61 | let sink = self.sink.clone(); 62 | 63 | thread::spawn(move || { 64 | let (frame_tx, _frame_rx) = std::sync::mpsc::channel::(); 65 | let client = reqwest::blocking::Client::new(); 66 | 67 | let response = client.get(url.clone()).send().unwrap(); 68 | 69 | println!("headers: {:#?}", response.headers()); 70 | let location = response.headers().get("location"); 71 | 72 | let response = match location { 73 | Some(location) => { 74 | let response = client.get(location.to_str().unwrap()).send().unwrap(); 75 | let location = response.headers().get("location"); 76 | match location { 77 | Some(location) => client.get(location.to_str().unwrap()).send().unwrap(), 78 | None => response, 79 | } 80 | } 81 | None => response, 82 | }; 83 | let decoder = Mp3Decoder::new(response, Some(frame_tx)).unwrap(); 84 | 85 | { 86 | let sink = sink.lock().unwrap(); 87 | sink.append(decoder); 88 | sink.play(); 89 | } 90 | 91 | loop { 92 | let sink = sink.lock().unwrap(); 93 | 94 | if sink.empty() { 95 | break; 96 | } 97 | 98 | drop(sink); 99 | 100 | std::thread::sleep(Duration::from_millis(10)); 101 | } 102 | }); 103 | 104 | Ok(()) 105 | } 106 | 107 | fn handle_play_or_pause(&self) -> Result<(), Error> { 108 | let sink = self.sink.lock().unwrap(); 109 | match sink.is_paused() { 110 | true => sink.play(), 111 | false => sink.pause(), 112 | }; 113 | Ok(()) 114 | } 115 | 116 | fn handle_stop(&self) -> Result<(), Error> { 117 | let sink = self.sink.lock().unwrap(); 118 | sink.stop(); 119 | Ok(()) 120 | } 121 | 122 | pub fn handle_command(&mut self, cmd: PlayerCommand) -> Result<(), Error> { 123 | match cmd { 124 | PlayerCommand::Play(url) => self.handle_play(url), 125 | PlayerCommand::PlayOrPause => self.handle_play_or_pause(), 126 | PlayerCommand::Stop => self.handle_stop(), 127 | } 128 | } 129 | } 130 | 131 | impl Future for PlayerInternal { 132 | type Output = (); 133 | fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 134 | loop { 135 | // Process commands that have been sent to the player 136 | let cmd = match self.commands.lock().unwrap().poll_recv(cx) { 137 | Poll::Ready(None) => return Poll::Ready(()), // client has disconnected - shut down. 138 | Poll::Ready(Some(cmd)) => Some(cmd), 139 | _ => None, 140 | }; 141 | 142 | if let Some(cmd) = cmd { 143 | if let Err(e) = self.handle_command(cmd) { 144 | println!("{:?}", e); 145 | } 146 | } 147 | 148 | thread::sleep(Duration::from_millis(500)); 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/server/browse.rs: -------------------------------------------------------------------------------- 1 | use tunein_cli::{ 2 | api::{ 3 | objects::v1alpha1::{Category, Station, StationLinkDetails}, 4 | tunein::v1alpha1::{ 5 | browse_service_server::BrowseService, BrowseCategoryRequest, BrowseCategoryResponse, 6 | GetCategoriesRequest, GetCategoriesResponse, GetStationDetailsRequest, 7 | GetStationDetailsResponse, SearchRequest, SearchResponse, 8 | }, 9 | }, 10 | provider::radiobrowser::Radiobrowser, 11 | }; 12 | 13 | use tunein_cli::provider::{tunein::Tunein, Provider}; 14 | 15 | #[derive(Default)] 16 | pub struct Browse; 17 | 18 | #[tonic::async_trait] 19 | impl BrowseService for Browse { 20 | async fn get_categories( 21 | &self, 22 | request: tonic::Request, 23 | ) -> Result, tonic::Status> { 24 | let req = request.into_inner(); 25 | let provider = req.provider.as_deref(); 26 | 27 | let client: Box = match provider { 28 | Some("tunein") => Box::new(Tunein::new()), 29 | Some("radiobrowser") => Box::new(Radiobrowser::new().await), 30 | None => Box::new(Tunein::new()), 31 | _ => { 32 | return Err(tonic::Status::internal("Unsupported provider")); 33 | } 34 | }; 35 | 36 | let offset = req.offset.unwrap_or(0); 37 | let limit = req.limit.unwrap_or(100); 38 | let result = client 39 | .categories(offset, limit) 40 | .await 41 | .map_err(|e| tonic::Status::internal(e.to_string()))?; 42 | 43 | Ok(tonic::Response::new(GetCategoriesResponse { 44 | categories: result.into_iter().map(Category::from).collect(), 45 | })) 46 | } 47 | 48 | async fn browse_category( 49 | &self, 50 | request: tonic::Request, 51 | ) -> Result, tonic::Status> { 52 | let req = request.into_inner(); 53 | let category_id = req.category_id; 54 | 55 | let offset = req.offset.unwrap_or(0); 56 | let limit = req.limit.unwrap_or(100); 57 | 58 | let provider = req.provider.as_deref(); 59 | 60 | let client: Box = match provider { 61 | Some("tunein") => Box::new(Tunein::new()), 62 | Some("radiobrowser") => Box::new(Radiobrowser::new().await), 63 | None => Box::new(Tunein::new()), 64 | _ => { 65 | return Err(tonic::Status::internal("Unsupported provider")); 66 | } 67 | }; 68 | 69 | let results = client 70 | .browse(category_id, offset, limit) 71 | .await 72 | .map_err(|e| tonic::Status::internal(e.to_string()))?; 73 | 74 | let categories = results.into_iter().map(Category::from).collect(); 75 | Ok(tonic::Response::new(BrowseCategoryResponse { categories })) 76 | } 77 | 78 | async fn get_station_details( 79 | &self, 80 | request: tonic::Request, 81 | ) -> Result, tonic::Status> { 82 | let req = request.into_inner(); 83 | let station_id = req.id; 84 | 85 | let provider = req.provider.as_deref(); 86 | 87 | let client: Box = match provider { 88 | Some("tunein") => Box::new(Tunein::new()), 89 | Some("radiobrowser") => Box::new(Radiobrowser::new().await), 90 | None => Box::new(Tunein::new()), 91 | _ => { 92 | return Err(tonic::Status::internal("Unsupported provider")); 93 | } 94 | }; 95 | 96 | let result = client 97 | .get_station(station_id) 98 | .await 99 | .map_err(|e| tonic::Status::internal(e.to_string()))?; 100 | 101 | let station = match result { 102 | Some(station) => station, 103 | None => return Err(tonic::Status::internal("No station found")), 104 | }; 105 | Ok(tonic::Response::new(GetStationDetailsResponse { 106 | station_link_details: Some(StationLinkDetails::from(station)), 107 | })) 108 | } 109 | 110 | async fn search( 111 | &self, 112 | request: tonic::Request, 113 | ) -> Result, tonic::Status> { 114 | let req = request.into_inner(); 115 | let provider = req.provider.as_deref(); 116 | 117 | let client: Box = match provider { 118 | Some("tunein") => Box::new(Tunein::new()), 119 | Some("radiobrowser") => Box::new(Radiobrowser::new().await), 120 | None => Box::new(Tunein::new()), 121 | _ => { 122 | return Err(tonic::Status::internal("Unsupported provider")); 123 | } 124 | }; 125 | 126 | let results = client 127 | .search(req.query) 128 | .await 129 | .map_err(|e| tonic::Status::internal(e.to_string()))?; 130 | let station = results.into_iter().map(Station::from).collect(); 131 | Ok(tonic::Response::new(SearchResponse { station })) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/provider/radiobrowser.rs: -------------------------------------------------------------------------------- 1 | use crate::types::Station; 2 | 3 | use super::{is_valid_uuid, Provider}; 4 | use anyhow::Error; 5 | use async_trait::async_trait; 6 | use radiobrowser::{ApiStation, RadioBrowserAPI}; 7 | use std::process::exit; 8 | 9 | pub struct Radiobrowser { 10 | client: RadioBrowserAPI, 11 | } 12 | 13 | impl Radiobrowser { 14 | pub async fn new() -> Self { 15 | let client = RadioBrowserAPI::new().await; 16 | 17 | if client.is_err() { 18 | eprintln!("Failed to create a RadioBrowserAPI client"); 19 | exit(1); 20 | } 21 | 22 | let client = client.unwrap(); 23 | Self { client } 24 | } 25 | } 26 | 27 | #[async_trait] 28 | impl Provider for Radiobrowser { 29 | async fn search(&self, name: String) -> Result, Error> { 30 | let stations = self 31 | .client 32 | .get_stations() 33 | .name(&name) 34 | .send() 35 | .await 36 | .map_err(|e| anyhow::anyhow!(format!("{}", e)))?; 37 | let stations = stations.into_iter().map(|x| Station::from(x)).collect(); 38 | Ok(stations) 39 | } 40 | 41 | async fn get_station(&self, name_or_uuid: String) -> Result, Error> { 42 | match is_valid_uuid(&name_or_uuid) { 43 | true => { 44 | let servers = RadioBrowserAPI::get_default_servers() 45 | .await 46 | .map_err(|e| anyhow::anyhow!(format!("{}", e)))?; 47 | 48 | if servers.is_empty() { 49 | return Ok(None); 50 | } 51 | 52 | let client = reqwest::Client::new(); 53 | let url = format!( 54 | "https://{}/json/stations/byuuid/{}", 55 | servers[0], name_or_uuid 56 | ); 57 | let results = client 58 | .get(&url) 59 | .send() 60 | .await? 61 | .json::>() 62 | .await?; 63 | 64 | Ok(results.into_iter().next().map(|x| Station::from(x))) 65 | } 66 | false => { 67 | let stations = self 68 | .client 69 | .get_stations() 70 | .name(&name_or_uuid) 71 | .name_exact(true) 72 | .send() 73 | .await 74 | .map_err(|e| anyhow::anyhow!(format!("{}", e)))?; 75 | match stations.len() { 76 | 0 => Ok(None), 77 | _ => Ok(Some(Station::from(stations[0].clone()))), 78 | } 79 | } 80 | } 81 | } 82 | 83 | async fn browse( 84 | &self, 85 | category: String, 86 | offset: u32, 87 | limit: u32, 88 | ) -> Result, Error> { 89 | let stations = self 90 | .client 91 | .get_stations() 92 | .tag(&category) 93 | .offset(&format!("{}", offset)) 94 | .limit(&format!("{}", limit)) 95 | .send() 96 | .await 97 | .map_err(|e| anyhow::anyhow!(format!("{}", e)))?; 98 | let stations = stations.into_iter().map(|x| Station::from(x)).collect(); 99 | Ok(stations) 100 | } 101 | 102 | async fn categories(&self, offset: u32, limit: u32) -> Result, Error> { 103 | let categories = self 104 | .client 105 | .get_tags() 106 | .offset(&format!("{}", offset)) 107 | .limit(&format!("{}", limit)) 108 | .send() 109 | .await 110 | .map_err(|e| anyhow::anyhow!(format!("{}", e)))?; 111 | let categories = categories.into_iter().map(|x| x.name).collect(); 112 | Ok(categories) 113 | } 114 | } 115 | 116 | #[cfg(test)] 117 | mod tests { 118 | use super::*; 119 | 120 | #[tokio::test] 121 | pub async fn test_search() { 122 | let provider = Radiobrowser::new().await; 123 | let name = "alternativeradio"; 124 | let stations = provider.search(name.to_string()).await.unwrap(); 125 | assert!(stations.len() == 1) 126 | } 127 | 128 | #[tokio::test] 129 | pub async fn test_get_station() { 130 | let provider = Radiobrowser::new().await; 131 | let name = "AlternativeRadio.us".to_string(); 132 | let station = provider.get_station(name).await.unwrap(); 133 | assert!(station.is_some()) 134 | } 135 | 136 | #[tokio::test] 137 | pub async fn test_get_station_by_uuid() { 138 | let provider = Radiobrowser::new().await; 139 | let name = "964da563-0601-11e8-ae97-52543be04c81".to_string(); 140 | let station = provider.get_station(name).await.unwrap(); 141 | assert!(station.is_some()) 142 | } 143 | 144 | #[tokio::test] 145 | pub async fn test_browse() { 146 | let provider = Radiobrowser::new().await; 147 | let stations = provider.browse("music".to_string(), 0, 100).await.unwrap(); 148 | let stations = stations 149 | .into_iter() 150 | .map(|x| Station::from(x)) 151 | .collect::>(); 152 | assert!(stations.len() == 100) 153 | } 154 | 155 | #[tokio::test] 156 | pub async fn test_categories() { 157 | let provider = Radiobrowser::new().await; 158 | let categories = provider.categories(0, 100).await.unwrap(); 159 | assert!(categories.len() > 0) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "TuneIn CLI - Browse and listen to thousands of radio stations across the globe right from your terminal 🌎 📻 🎵✨"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | 7 | crane = { 8 | url = "github:ipetkov/crane"; 9 | inputs.nixpkgs.follows = "nixpkgs"; 10 | }; 11 | 12 | fenix = { 13 | url = "github:nix-community/fenix"; 14 | inputs.nixpkgs.follows = "nixpkgs"; 15 | inputs.rust-analyzer-src.follows = ""; 16 | }; 17 | 18 | flake-utils.url = "github:numtide/flake-utils"; 19 | 20 | advisory-db = { 21 | url = "github:rustsec/advisory-db"; 22 | flake = false; 23 | }; 24 | }; 25 | 26 | outputs = { self, nixpkgs, crane, fenix, flake-utils, advisory-db, ... }: 27 | flake-utils.lib.eachDefaultSystem (system: 28 | let 29 | pkgs = import nixpkgs { 30 | inherit system; 31 | }; 32 | 33 | inherit (pkgs) lib; 34 | 35 | craneLib = crane.mkLib pkgs; 36 | 37 | protoFilter = path: _type: builtins.match ".*proto$" path != null; 38 | protoOrCargo = path: type: 39 | (protoFilter path type) || (craneLib.filterCargoSources path type); 40 | 41 | src = lib.cleanSourceWith { 42 | src = craneLib.path ./.; # The original, unfiltered source 43 | filter = protoOrCargo; 44 | }; 45 | 46 | # Common arguments can be set here to avoid repeating them later 47 | commonArgs = { 48 | inherit src; 49 | 50 | pname = "tunein"; 51 | version = "0.4.1"; 52 | 53 | buildInputs = [ 54 | # Add additional build inputs here 55 | pkgs.openssl 56 | pkgs.openssl.dev 57 | pkgs.pkg-config 58 | pkgs.gnumake 59 | pkgs.perl 60 | pkgs.protobuf 61 | pkgs.alsa-lib.dev 62 | pkgs.dbus 63 | ] ++ lib.optionals pkgs.stdenv.isDarwin [ 64 | # Additional darwin specific inputs can be set here 65 | pkgs.libiconv 66 | pkgs.darwin.Security 67 | ]; 68 | 69 | # Additional environment variables can be set directly 70 | # MY_CUSTOM_VAR = "some value"; 71 | }; 72 | 73 | craneLibLLvmTools = craneLib.overrideToolchain 74 | (fenix.packages.${system}.complete.withComponents [ 75 | "cargo" 76 | "llvm-tools" 77 | "rustc" 78 | ]); 79 | 80 | # Build *just* the cargo dependencies, so we can reuse 81 | # all of that work (e.g. via cachix) when running in CI 82 | cargoArtifacts = craneLib.buildDepsOnly commonArgs; 83 | 84 | # Build the actual crate itself, reusing the dependency 85 | # artifacts from above. 86 | tunein = craneLib.buildPackage (commonArgs // { 87 | inherit cargoArtifacts; 88 | }); 89 | 90 | in 91 | { 92 | checks = { 93 | # Build the crate as part of `nix flake check` for convenience 94 | inherit tunein; 95 | 96 | # Run clippy (and deny all warnings) on the crate source, 97 | # again, resuing the dependency artifacts from above. 98 | # 99 | # Note that this is done as a separate derivation so that 100 | # we can block the CI if there are issues here, but not 101 | # prevent downstream consumers from building our crate by itself. 102 | tunein-clippy = craneLib.cargoClippy (commonArgs // { 103 | inherit cargoArtifacts; 104 | cargoClippyExtraArgs = "--all-targets -- --deny warnings"; 105 | }); 106 | 107 | tunein-doc = craneLib.cargoDoc (commonArgs // { 108 | inherit cargoArtifacts; 109 | }); 110 | 111 | # Check formatting 112 | tunein-fmt = craneLib.cargoFmt { 113 | inherit src; 114 | }; 115 | 116 | # Audit dependencies 117 | tunein-audit = craneLib.cargoAudit { 118 | inherit src advisory-db; 119 | }; 120 | 121 | # Run tests with cargo-nextest 122 | # Consider setting `doCheck = false` on `tunein` if you do not want 123 | # the tests to run twice 124 | tunein-nextest = craneLib.cargoNextest (commonArgs // { 125 | inherit cargoArtifacts; 126 | partitions = 1; 127 | partitionType = "count"; 128 | }); 129 | } // lib.optionalAttrs (system == "x86_64-linux") { 130 | # NB: cargo-tarpaulin only supports x86_64 systems 131 | # Check code coverage (note: this will not upload coverage anywhere) 132 | tunein-coverage = craneLib.cargoTarpaulin (commonArgs // { 133 | inherit cargoArtifacts; 134 | }); 135 | }; 136 | 137 | packages = { 138 | default = tunein; 139 | tunein-llvm-coverage = craneLibLLvmTools.cargoLlvmCov (commonArgs // { 140 | inherit cargoArtifacts; 141 | }); 142 | }; 143 | 144 | apps.default = flake-utils.lib.mkApp { 145 | drv = tunein; 146 | }; 147 | 148 | devShells.default = pkgs.mkShell { 149 | inputsFrom = builtins.attrValues self.checks.${system}; 150 | 151 | # Additional dev-shell environment variables can be set directly 152 | # MY_CUSTOM_DEVELOPMENT_VAR = "something else"; 153 | 154 | # Extra inputs can be added here 155 | nativeBuildInputs = with pkgs; [ 156 | cargo 157 | rustc 158 | ]; 159 | }; 160 | }); 161 | } 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![Cover](./.github/assets/preview.png) 3 | 4 | # TuneIn CLI 📻 🎵 ✨ 5 | 6 |

7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | GitHub Downloads (all assets, all releases) 18 | 19 | 20 | License: MIT 21 | 22 | 23 | 24 | 25 |

26 | 27 | A command line interface for [TuneIn Radio](https://tunein.com) / [Radio Browser](https://www.radio-browser.info/).
28 | You can search for stations, play them, and see what's currently playing. 29 | 30 | ![Made with VHS](https://vhs.charm.sh/vhs-4UhZFFRvVAuaZnapZUlp6R.gif) 31 | 32 | ## 🚚 Installation 33 | 34 | Compile from source, without Nix: 35 | 36 | ```bash 37 | # Install dependencies 38 | brew install protobuf # macOS 39 | sudo apt-get install -y libasound2-dev protobuf-compiler libdbus-1-dev # Ubuntu/Debian 40 | # Compile and install 41 | git clone https://github.com/tsirysndr/tunein-cli 42 | cd tunein-cli 43 | cargo install --path . 44 | ``` 45 | 46 | With Nix: 47 | 48 | ```bash 49 | git clone https://github.com/tsirysndr/tunein-cli 50 | cd tunein-cli 51 | nix develop --experimental-features "nix-command flakes" 52 | cargo install --path . 53 | ``` 54 | 55 | ### macOS/Linux 56 | 57 | Using Bash: 58 | 59 | ```bash 60 | curl -fsSL https://cdn.jsdelivr.net/gh/tsirysndr/tunein-cli@ab6a1ab/install.sh | bash 61 | ``` 62 | 63 | Using [Homebrew](https://brew.sh): 64 | 65 | ```bash 66 | brew install tsirysndr/tap/tunein 67 | ``` 68 | 69 | Using [Nix](https://nixos.org/nix/): 70 | 71 | ```bash 72 | cachix use tsirysndr 73 | nix profile install --experimental-features "nix-command flakes" github:tsirysndr/tunein-cli 74 | ``` 75 | 76 | ### Ubuntu/Debian 77 | 78 | ```bash 79 | echo "deb [trusted=yes] https://apt.fury.io/tsiry/ /" | sudo tee /etc/apt/sources.list.d/fury.list 80 | sudo apt-get update 81 | sudo apt-get install tunein-cli 82 | ``` 83 | 84 | ### Fedora 85 | 86 | Add the following to `/etc/yum.repos.d/fury.repo`: 87 | 88 | ``` 89 | [fury] 90 | name=Gemfury Private Repo 91 | baseurl=https://yum.fury.io/tsiry/ 92 | enabled=1 93 | gpgcheck=0 94 | ``` 95 | 96 | Then run: 97 | ```bash 98 | dnf install tunein-cli 99 | ``` 100 | 101 | ### Arch Linux 102 | Using [paru](https://github.com/Morganamilo/paru): 103 | 104 | ```bash 105 | paru -S tunein-cli-bin 106 | ``` 107 | 108 | Or download the latest release for your platform [here](https://github.com/tsirysndr/tunein-cli/releases). 109 | 110 | ## 📦 Downloads 111 | - `Mac`: arm64: [tunein_v0.4.1_aarch64-apple-darwin.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.4.1/tunein_v0.4.1_aarch64-apple-darwin.tar.gz) intel: [tunein_v0.4.1_x86_64-apple-darwin.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.4.1/tunein_v0.4.1_x86_64-apple-darwin.tar.gz) 112 | - `Linux`: [tunein_v0.4.1_x86_64-unknown-linux-gnu.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.4.1/tunein_v0.4.1_x86_64-unknown-linux-gnu.tar.gz) 113 | 114 | ## 🚀 Usage 115 | ``` 116 | USAGE: 117 | tunein 118 | 119 | OPTIONS: 120 | -h, --help Print help information 121 | -p, --provider The radio provider to use, can be 'tunein' or 'radiobrowser'. 122 | Default is 'tunein' [default: tunein] 123 | -V, --version Print version information 124 | 125 | SUBCOMMANDS: 126 | browse Browse radio stations 127 | help Print this message or the help of the given subcommand(s) 128 | play Play a radio station 129 | search Search for a radio station 130 | server Start the server 131 | service Manage systemd service for tunein-cli server 132 | ``` 133 | 134 | Search for a radio station: 135 | ```bash 136 | tunein search "BBC Radio 1" 137 | ``` 138 | Result: 139 | ``` 140 | BBC Radio 1 | The best new music | id: s24939 141 | BBC Radio 1Xtra | Remi Burgz | id: s20277 142 | ``` 143 | 144 | Play a radio station: 145 | ```bash 146 | tunein play "alternativeradio.us" 147 | # Or by station ID 148 | tunein play s221580 149 | ``` 150 | 151 | ## 🧙 Systemd Service 152 | 153 | Tunein daemon can be started as a systemd service. To enable and start the service, run the following command: 154 | 155 | ```bash 156 | tunein service install 157 | ``` 158 | 159 | To disable and stop the service, run the following command: 160 | 161 | ```bash 162 | tunein service uninstall 163 | ``` 164 | 165 | To check the status of the service, run the following command: 166 | 167 | ```bash 168 | tunein service status 169 | ``` 170 | 171 | 172 | ## API Documentation 173 | [https://buf.build/tsiry/tuneinserverapis/docs/main:tunein.v1alpha1](https://buf.build/tsiry/tuneinserverapis/docs/main:tunein.v1alpha1) 174 | 175 | You can start the server locally by running: 176 | ```bash 177 | tunein server 178 | ``` 179 | 180 | and then use [Buf Studio](https://studio.buf.build/tsiry/tuneinserverapis?selectedProtocol=grpc-web&target=http%3A%2F%2Flocalhost%3A8090) to make requests to the server 181 | 182 | 183 | 184 | 185 | ## 📝 License 186 | [MIT](LICENSE) 187 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [GitHub Issues](https://github.com/tsirysndr/tunein-cli/issues). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /.fluentci/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [GitHub Issues](https://github.com/fluent-ci-templates/rust-pipeline/issues). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /src/play.rs: -------------------------------------------------------------------------------- 1 | use std::{process, thread, time::Duration}; 2 | 3 | use anyhow::Error; 4 | use hyper::header::HeaderValue; 5 | use tunein_cli::os_media_controls::OsMediaControls; 6 | 7 | use crate::{ 8 | app::{App, CurrentDisplayMode, State, Volume}, 9 | cfg::{SourceOptions, UiOptions}, 10 | decoder::Mp3Decoder, 11 | provider::{radiobrowser::Radiobrowser, tunein::Tunein, Provider}, 12 | tui, 13 | }; 14 | 15 | pub async fn exec( 16 | name_or_id: &str, 17 | provider: &str, 18 | volume: f32, 19 | display_mode: CurrentDisplayMode, 20 | enable_os_media_controls: bool, 21 | poll_events_every: Duration, 22 | poll_events_every_while_paused: Duration, 23 | ) -> Result<(), Error> { 24 | let _provider = provider; 25 | let provider: Box = match provider { 26 | "tunein" => Box::new(Tunein::new()), 27 | "radiobrowser" => Box::new(Radiobrowser::new().await), 28 | _ => { 29 | return Err(anyhow::anyhow!(format!( 30 | "Unsupported provider '{}'", 31 | provider 32 | ))) 33 | } 34 | }; 35 | let station = provider.get_station(name_or_id.to_string()).await?; 36 | if station.is_none() { 37 | return Err(Error::msg("No station found")); 38 | } 39 | 40 | let station = station.unwrap(); 41 | let stream_url = station.stream_url.clone(); 42 | let id = station.id.clone(); 43 | let now_playing = station.playing.clone().unwrap_or_default(); 44 | 45 | let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); 46 | let (sink_cmd_tx, mut sink_cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); 47 | let (frame_tx, frame_rx) = std::sync::mpsc::channel::(); 48 | 49 | let ui = UiOptions { 50 | scale: 1.0, 51 | scatter: false, 52 | no_reference: true, 53 | no_ui: true, 54 | no_braille: false, 55 | }; 56 | 57 | let opts = SourceOptions { 58 | channels: 2, 59 | buffer: 1152, 60 | sample_rate: 44100, 61 | tune: None, 62 | }; 63 | 64 | let os_media_controls = if enable_os_media_controls { 65 | OsMediaControls::new() 66 | .inspect_err(|err| { 67 | eprintln!( 68 | "error: failed to initialize os media controls due to `{}`", 69 | err 70 | ); 71 | }) 72 | .ok() 73 | } else { 74 | None 75 | }; 76 | 77 | let mut app = App::new( 78 | &ui, 79 | &opts, 80 | frame_rx, 81 | display_mode, 82 | os_media_controls, 83 | poll_events_every, 84 | poll_events_every_while_paused, 85 | ); 86 | let station_name = station.name.clone(); 87 | 88 | thread::spawn(move || { 89 | let client = reqwest::blocking::Client::new(); 90 | 91 | let response = client.get(stream_url).send().unwrap(); 92 | 93 | let headers = response.headers(); 94 | let volume = Volume::new(volume, false); 95 | 96 | cmd_tx 97 | .send(State { 98 | name: match headers 99 | .get("icy-name") 100 | .unwrap_or(&HeaderValue::from_static("Unknown")) 101 | .to_str() 102 | .unwrap() 103 | { 104 | "Unknown" => station_name, 105 | name => name.to_string(), 106 | }, 107 | now_playing, 108 | genre: headers 109 | .get("icy-genre") 110 | .unwrap_or(&HeaderValue::from_static("Unknown")) 111 | .to_str() 112 | .unwrap() 113 | .to_string(), 114 | description: headers 115 | .get("icy-description") 116 | .unwrap_or(&HeaderValue::from_static("Unknown")) 117 | .to_str() 118 | .unwrap() 119 | .to_string(), 120 | br: headers 121 | .get("icy-br") 122 | .unwrap_or(&HeaderValue::from_static("")) 123 | .to_str() 124 | .unwrap() 125 | .to_string(), 126 | volume: volume.clone(), 127 | }) 128 | .unwrap(); 129 | let location = response.headers().get("location"); 130 | 131 | let response = match location { 132 | Some(location) => { 133 | let response = client.get(location.to_str().unwrap()).send().unwrap(); 134 | let location = response.headers().get("location"); 135 | match location { 136 | Some(location) => client.get(location.to_str().unwrap()).send().unwrap(), 137 | None => response, 138 | } 139 | } 140 | None => response, 141 | }; 142 | 143 | let (_stream, handle) = rodio::OutputStream::try_default().unwrap(); 144 | let sink = rodio::Sink::try_new(&handle).unwrap(); 145 | sink.set_volume(volume.volume_ratio()); 146 | let decoder = Mp3Decoder::new(response, Some(frame_tx)).unwrap(); 147 | sink.append(decoder); 148 | 149 | loop { 150 | while let Ok(sink_cmd) = sink_cmd_rx.try_recv() { 151 | match sink_cmd { 152 | SinkCommand::Play => { 153 | sink.play(); 154 | } 155 | SinkCommand::Pause => { 156 | sink.pause(); 157 | } 158 | SinkCommand::SetVolume(volume) => { 159 | sink.set_volume(volume); 160 | } 161 | } 162 | } 163 | std::thread::sleep(Duration::from_millis(10)); 164 | } 165 | }); 166 | 167 | let mut terminal = tui::init()?; 168 | app.run(&mut terminal, cmd_rx, sink_cmd_tx, &id).await; 169 | tui::restore()?; 170 | 171 | process::exit(0); 172 | } 173 | 174 | /// Command for a sink. 175 | #[derive(Debug, Clone, PartialEq)] 176 | pub enum SinkCommand { 177 | /// Play. 178 | Play, 179 | /// Pause. 180 | Pause, 181 | /// Set the volume. 182 | SetVolume(f32), 183 | } 184 | -------------------------------------------------------------------------------- /src/provider/tunein.rs: -------------------------------------------------------------------------------- 1 | use crate::types::Station; 2 | 3 | use super::Provider; 4 | use anyhow::Error; 5 | use async_trait::async_trait; 6 | use tunein::TuneInClient; 7 | 8 | pub struct Tunein { 9 | client: TuneInClient, 10 | } 11 | 12 | impl Tunein { 13 | pub fn new() -> Self { 14 | Self { 15 | client: TuneInClient::new(), 16 | } 17 | } 18 | } 19 | 20 | #[async_trait] 21 | impl Provider for Tunein { 22 | async fn search(&self, name: String) -> Result, Error> { 23 | let results = self 24 | .client 25 | .search(&name) 26 | .await 27 | .map_err(|e| Error::msg(e.to_string()))?; 28 | let stations = results.into_iter().map(|x| Station::from(x)).collect(); 29 | Ok(stations) 30 | } 31 | 32 | async fn get_station(&self, id: String) -> Result, Error> { 33 | let stations = self 34 | .client 35 | .get_station(&id) 36 | .await 37 | .map_err(|e| Error::msg(e.to_string()))?; 38 | match stations.len() { 39 | 0 => { 40 | let results = self.search(id.clone()).await?; 41 | let station = results.first().cloned(); 42 | match station { 43 | Some(st) => { 44 | let stations = self 45 | .client 46 | .get_station(&st.id) 47 | .await 48 | .map_err(|e| Error::msg(e.to_string()))?; 49 | let mut station = Station::from(stations[0].clone()); 50 | station.id = st.id.clone(); 51 | station.name = st.name.clone(); 52 | station.playing = st.playing.clone(); 53 | return Ok(Some(station)); 54 | } 55 | None => Ok(None), 56 | } 57 | } 58 | _ => { 59 | let mut station = Station::from(stations[0].clone()); 60 | // Preserve the original station ID since StationLinkDetails doesn't contain it 61 | station.id = id; 62 | Ok(Some(station)) 63 | } 64 | } 65 | } 66 | 67 | async fn browse( 68 | &self, 69 | category: String, 70 | _offset: u32, 71 | _limit: u32, 72 | ) -> Result, Error> { 73 | let guide_id = category.clone(); 74 | let category = match category.to_lowercase().as_str() { 75 | "by location" => Some(tunein::types::Category::ByLocation), 76 | "by language" => Some(tunein::types::Category::ByLanguage), 77 | "sports" => Some(tunein::types::Category::Sports), 78 | "talk" => Some(tunein::types::Category::Talk), 79 | "music" => Some(tunein::types::Category::Music), 80 | "local radio" => Some(tunein::types::Category::LocalRadio), 81 | "podcasts" => Some(tunein::types::Category::Podcasts), 82 | _ => None, 83 | }; 84 | 85 | if category.is_none() { 86 | let category_stations = self 87 | .client 88 | .browse_by_id(&guide_id) 89 | .await 90 | .map_err(|e| Error::msg(e.to_string()))?; 91 | 92 | let mut stations = vec![]; 93 | 94 | for st in category_stations { 95 | if let Some(children) = st.clone().children { 96 | stations = [stations, vec![Box::new(st.clone())], children].concat(); 97 | } 98 | } 99 | 100 | let stations = stations.into_iter().map(|x| Station::from(x)).collect(); 101 | return Ok(stations); 102 | } 103 | 104 | let category_stations = self 105 | .client 106 | .browse(category) 107 | .await 108 | .map_err(|e| Error::msg(e.to_string()))?; 109 | 110 | let stations = category_stations 111 | .clone() 112 | .into_iter() 113 | .map(|x| Station::from(x)) 114 | .collect::>(); 115 | 116 | let mut _stations = vec![]; 117 | for st in category_stations { 118 | if let Some(children) = st.children { 119 | _stations = [_stations, children].concat(); 120 | } 121 | } 122 | let _stations = _stations 123 | .into_iter() 124 | .map(|x| Station::from(x)) 125 | .collect::>(); 126 | 127 | Ok([stations, _stations].concat()) 128 | } 129 | 130 | async fn categories(&self, _offset: u32, _limit: u32) -> Result, Error> { 131 | let categories = self 132 | .client 133 | .browse(None) 134 | .await 135 | .map_err(|e| Error::msg(e.to_string()))?; 136 | let categories = categories.into_iter().map(|x| x.text).collect(); 137 | Ok(categories) 138 | } 139 | } 140 | 141 | #[cfg(test)] 142 | mod tests { 143 | use super::*; 144 | 145 | #[tokio::test] 146 | pub async fn test_search() { 147 | let provider = Tunein::new(); 148 | let name = "alternativeradio"; 149 | let stations = provider.search(name.to_string()).await.unwrap(); 150 | println!("Search: {:#?}", stations); 151 | assert!(stations.len() > 0) 152 | } 153 | 154 | #[tokio::test] 155 | pub async fn test_get_station() { 156 | let provider = Tunein::new(); 157 | let name = "s288303".to_string(); 158 | let station = provider.get_station(name).await.unwrap(); 159 | println!("Station: {:#?}", station); 160 | assert!(station.is_some()) 161 | } 162 | 163 | #[tokio::test] 164 | pub async fn test_browse() { 165 | let provider = Tunein::new(); 166 | let stations = provider.browse("music".to_string(), 0, 100).await.unwrap(); 167 | println!("Browse: {:#?}", stations); 168 | assert!(stations.len() > 0) 169 | } 170 | 171 | #[tokio::test] 172 | pub async fn test_browse_by_id() { 173 | let provider = Tunein::new(); 174 | let stations = provider.browse("c57942".to_string(), 0, 100).await.unwrap(); 175 | println!("Browse by category id: {:#?}", stations); 176 | assert!(stations.len() > 0) 177 | } 178 | 179 | #[tokio::test] 180 | pub async fn test_categories() { 181 | let provider = Tunein::new(); 182 | let categories = provider.categories(0, 100).await.unwrap(); 183 | println!("Categories: {:#?}", categories); 184 | assert!(categories.len() > 0) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use anyhow::Error; 4 | use app::CurrentDisplayMode; 5 | use clap::{arg, builder::ValueParser, Command}; 6 | 7 | mod app; 8 | mod audio; 9 | mod browse; 10 | mod cfg; 11 | mod decoder; 12 | mod extract; 13 | mod favorites; 14 | mod input; 15 | mod interactive; 16 | mod music; 17 | mod play; 18 | mod player; 19 | mod provider; 20 | mod search; 21 | mod server; 22 | mod service; 23 | mod tags; 24 | mod tui; 25 | mod types; 26 | mod visualization; 27 | 28 | fn cli() -> Command<'static> { 29 | const VESRION: &str = env!("CARGO_PKG_VERSION"); 30 | Command::new("tunein") 31 | .version(VESRION) 32 | .author("Tsiry Sandratraina ") 33 | .about( 34 | r#" 35 | ______ ____ _______ ____ 36 | /_ __/_ _____ ___ / _/__ / ___/ / / _/ 37 | / / / // / _ \/ -_)/ // _ \ / /__/ /___/ / 38 | /_/ \_,_/_//_/\__/___/_//_/ \___/____/___/ 39 | 40 | A simple CLI to listen to radio stations"#, 41 | ) 42 | .arg( 43 | arg!(-p --provider "The radio provider to use, can be 'tunein' or 'radiobrowser'. Default is 'tunein'").default_value("tunein") 44 | ) 45 | .subcommand( 46 | Command::new("search") 47 | .about("Search for a radio station") 48 | .arg(arg!( "The query to search for")), 49 | ) 50 | .subcommand( 51 | Command::new("play") 52 | .about("Play a radio station") 53 | .arg(arg!( "The station to play")) 54 | .arg(arg!(--volume "Set the initial volume (as a percent)").default_value("100")) 55 | .arg(clap::Arg::new("display-mode").long("display-mode").help("Set the display mode to start with").default_value("Spectroscope")) 56 | .arg(clap::Arg::new("enable-os-media-controls").long("enable-os-media-controls").help("Should enable OS media controls?").default_value("true").value_parser(ValueParser::bool())) 57 | .arg(clap::Arg::new("poll-events-every").long("poll-events-every").help("Poll for events every specified milliseconds.").default_value("16")) 58 | .arg(clap::Arg::new("poll-events-every-while-paused").long("poll-events-every-while-paused").help("Poll for events every specified milliseconds while player is paused.").default_value("100")), 59 | ) 60 | .subcommand( 61 | Command::new("browse") 62 | .about("Browse radio stations") 63 | .arg(arg!([category] "The category (category name or id) to browse")) 64 | .arg(arg!(--offset "The offset to start from").default_value("0")) 65 | .arg(arg!(--limit "The number of results to show").default_value("100")), 66 | ) 67 | .subcommand( 68 | Command::new("server") 69 | .about("Start the server") 70 | .arg(arg!([port] "The port to listen on").default_value("8090")), 71 | ) 72 | .subcommand( 73 | Command::new("service") 74 | .about("Manage systemd service for tunein-cli server") 75 | .subcommand( 76 | Command::new("install") 77 | .about("Install systemd service for tunein-cli server") 78 | ) 79 | .subcommand( 80 | Command::new("uninstall") 81 | .about("Uninstall systemd service for tunein-cli server") 82 | ) 83 | .subcommand( 84 | Command::new("status") 85 | .about("Check status of tunein-cli systemd service") 86 | ) 87 | ) 88 | } 89 | 90 | #[tokio::main] 91 | async fn main() -> Result<(), Error> { 92 | let matches = cli().get_matches(); 93 | let provider = matches.value_of("provider").unwrap().to_string(); 94 | 95 | match matches.subcommand() { 96 | Some(("search", args)) => { 97 | let query = args.value_of("query").unwrap(); 98 | search::exec(query, provider.as_str()).await?; 99 | } 100 | Some(("play", args)) => { 101 | let station = args.value_of("station").unwrap(); 102 | let volume = args.value_of("volume").unwrap().parse::().unwrap(); 103 | let display_mode = args 104 | .value_of("display-mode") 105 | .unwrap() 106 | .parse::() 107 | .unwrap(); 108 | let enable_os_media_controls = args.get_one("enable-os-media-controls").unwrap(); 109 | let poll_events_every = 110 | Duration::from_millis(args.value_of("poll-events-every").unwrap().parse().unwrap()); 111 | let poll_events_every_while_paused = Duration::from_millis( 112 | args.value_of("poll-events-every-while-paused") 113 | .unwrap() 114 | .parse() 115 | .unwrap(), 116 | ); 117 | play::exec( 118 | station, 119 | provider.as_str(), 120 | volume, 121 | display_mode, 122 | *enable_os_media_controls, 123 | poll_events_every, 124 | poll_events_every_while_paused, 125 | ) 126 | .await?; 127 | } 128 | Some(("browse", args)) => { 129 | let category = args.value_of("category"); 130 | let offset = args.value_of("offset").unwrap(); 131 | let limit = args.value_of("limit").unwrap(); 132 | browse::exec( 133 | category, 134 | offset.parse::()?, 135 | limit.parse::()?, 136 | provider.as_str(), 137 | ) 138 | .await?; 139 | } 140 | Some(("server", args)) => { 141 | let port = args.value_of("port").unwrap(); 142 | let port = port.parse::().unwrap(); 143 | server::exec(port).await?; 144 | } 145 | Some(("service", sub_m)) => match sub_m.subcommand() { 146 | Some(("install", _)) => service::install()?, 147 | Some(("uninstall", _)) => service::uninstall()?, 148 | Some(("status", _)) => service::status()?, 149 | _ => { 150 | println!("Invalid subcommand. Use `tunein service --help` for more information"); 151 | std::process::exit(1); 152 | } 153 | }, 154 | None => { 155 | interactive::run(provider.as_str()).await?; 156 | } 157 | Some((other, _)) => { 158 | eprintln!( 159 | "Unknown subcommand '{}'. Use `tunein --help` for available commands.", 160 | other 161 | ); 162 | std::process::exit(1); 163 | } 164 | } 165 | 166 | Ok(()) 167 | } 168 | -------------------------------------------------------------------------------- /src/visualization/oscilloscope.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{Event, KeyCode, KeyModifiers}; 2 | use ratatui::{ 3 | style::Style, 4 | text::Span, 5 | widgets::{Axis, GraphType}, 6 | }; 7 | 8 | use crate::{ 9 | app::{update_value_f, update_value_i}, 10 | input::Matrix, 11 | }; 12 | 13 | use super::{DataSet, Dimension, DisplayMode, GraphConfig}; 14 | 15 | #[derive(Default)] 16 | pub struct Oscilloscope { 17 | pub triggering: bool, 18 | pub falling_edge: bool, 19 | pub threshold: f64, 20 | pub depth: u32, 21 | pub peaks: bool, 22 | } 23 | 24 | impl DisplayMode for Oscilloscope { 25 | fn from_args(_opts: &crate::cfg::SourceOptions) -> Self { 26 | Oscilloscope::default() 27 | } 28 | 29 | fn mode_str(&self) -> &'static str { 30 | "oscillo" 31 | } 32 | 33 | fn channel_name(&self, index: usize) -> String { 34 | match index { 35 | 0 => "L".into(), 36 | 1 => "R".into(), 37 | _ => format!("{}", index), 38 | } 39 | } 40 | 41 | fn header(&self, _: &GraphConfig) -> String { 42 | if self.triggering { 43 | format!( 44 | "{} {:.0}{} trigger", 45 | if self.falling_edge { "v" } else { "^" }, 46 | self.threshold, 47 | if self.depth > 1 { 48 | format!(":{}", self.depth) 49 | } else { 50 | "".into() 51 | }, 52 | ) 53 | } else { 54 | "live".into() 55 | } 56 | } 57 | 58 | fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_> { 59 | let (name, bounds) = match dimension { 60 | Dimension::X => ("time -", [0.0, cfg.samples as f64]), 61 | Dimension::Y => ("| amplitude", [-cfg.scale, cfg.scale]), 62 | }; 63 | let mut a = Axis::default(); 64 | if cfg.show_ui { 65 | // TODO don't make it necessary to check show_ui inside here 66 | a = a.title(Span::styled(name, Style::default().fg(cfg.labels_color))); 67 | } 68 | a.style(Style::default().fg(cfg.axis_color)).bounds(bounds) 69 | } 70 | 71 | fn references(&self, cfg: &GraphConfig) -> Vec { 72 | vec![DataSet::new( 73 | None, 74 | vec![(0.0, 0.0), (cfg.samples as f64, 0.0)], 75 | cfg.marker_type, 76 | GraphType::Line, 77 | cfg.axis_color, 78 | )] 79 | } 80 | 81 | fn process(&mut self, cfg: &GraphConfig, data: &Matrix) -> Vec { 82 | let mut out = Vec::new(); 83 | 84 | let mut trigger_offset = 0; 85 | if self.depth == 0 { 86 | self.depth = 1 87 | } 88 | if self.triggering { 89 | for i in 0..data[0].len() { 90 | if triggered(&data[0], i, self.threshold, self.depth, self.falling_edge) { 91 | // triggered 92 | break; 93 | } 94 | trigger_offset += 1; 95 | } 96 | } 97 | 98 | if self.triggering { 99 | out.push(DataSet::new( 100 | Some("T".into()), 101 | vec![(0.0, self.threshold)], 102 | cfg.marker_type, 103 | GraphType::Scatter, 104 | cfg.labels_color, 105 | )); 106 | } 107 | 108 | for (n, channel) in data.iter().enumerate().rev() { 109 | let (mut min, mut max) = (0.0, 0.0); 110 | let mut tmp = Vec::new(); 111 | for (i, sample) in channel.iter().enumerate() { 112 | if *sample < min { 113 | min = *sample 114 | }; 115 | if *sample > max { 116 | max = *sample 117 | }; 118 | if i >= trigger_offset { 119 | tmp.push(((i - trigger_offset) as f64, *sample)); 120 | } 121 | } 122 | 123 | if self.peaks { 124 | out.push(DataSet::new( 125 | None, 126 | vec![(0.0, min), (0.0, max)], 127 | cfg.marker_type, 128 | GraphType::Scatter, 129 | cfg.palette(n), 130 | )) 131 | } 132 | 133 | out.push(DataSet::new( 134 | Some(self.channel_name(n)), 135 | tmp, 136 | cfg.marker_type, 137 | if cfg.scatter { 138 | GraphType::Scatter 139 | } else { 140 | GraphType::Line 141 | }, 142 | cfg.palette(n), 143 | )); 144 | } 145 | 146 | out 147 | } 148 | 149 | fn handle(&mut self, event: Event) { 150 | if let Event::Key(key) = event { 151 | let magnitude = match key.modifiers { 152 | KeyModifiers::SHIFT => 10.0, 153 | KeyModifiers::CONTROL => 5.0, 154 | KeyModifiers::ALT => 0.2, 155 | _ => 1.0, 156 | }; 157 | match key.code { 158 | KeyCode::PageUp => { 159 | update_value_f(&mut self.threshold, 250.0, magnitude, 0.0..32768.0) 160 | } 161 | KeyCode::PageDown => { 162 | update_value_f(&mut self.threshold, -250.0, magnitude, 0.0..32768.0) 163 | } 164 | KeyCode::Char('t') => self.triggering = !self.triggering, 165 | KeyCode::Char('e') => self.falling_edge = !self.falling_edge, 166 | KeyCode::Char('p') => self.peaks = !self.peaks, 167 | KeyCode::Char('=') => update_value_i(&mut self.depth, true, 1, 1.0, 1..65535), 168 | KeyCode::Char('-') => update_value_i(&mut self.depth, false, 1, 1.0, 1..65535), 169 | KeyCode::Char('+') => update_value_i(&mut self.depth, true, 10, 1.0, 1..65535), 170 | KeyCode::Char('_') => update_value_i(&mut self.depth, false, 10, 1.0, 1..65535), 171 | KeyCode::Esc => { 172 | self.triggering = false; 173 | } 174 | _ => {} 175 | } 176 | } 177 | } 178 | } 179 | 180 | #[allow(clippy::collapsible_else_if)] // TODO can this be made nicer? 181 | fn triggered(data: &[f64], index: usize, threshold: f64, depth: u32, falling_edge: bool) -> bool { 182 | if data.len() < index + (1 + depth as usize) { 183 | return false; 184 | } 185 | if falling_edge { 186 | if data[index] >= threshold { 187 | for i in 1..=depth as usize { 188 | if data[index + i] >= threshold { 189 | return false; 190 | } 191 | } 192 | true 193 | } else { 194 | false 195 | } 196 | } else { 197 | if data[index] <= threshold { 198 | for i in 1..=depth as usize { 199 | if data[index + i] <= threshold { 200 | return false; 201 | } 202 | } 203 | true 204 | } else { 205 | false 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /.fluentci/sdk/utils.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | import { 3 | ClientError, 4 | gql, 5 | GraphQLClient, 6 | GraphQLRequestError, 7 | TooManyNestedObjectsError, 8 | UnknownDaggerError, 9 | NotAwaitedRequestError, 10 | ExecError, 11 | } from "../deps.ts"; 12 | 13 | import { Metadata, QueryTree } from "./client.gen.ts"; 14 | 15 | /** 16 | * Format argument into GraphQL query format. 17 | */ 18 | function buildArgs(args: any): string { 19 | const metadata: Metadata = args.__metadata || {}; 20 | 21 | // Remove unwanted quotes 22 | const formatValue = (key: string, value: string) => { 23 | // Special treatment for enumeration, they must be inserted without quotes 24 | if (metadata[key]?.is_enum) { 25 | return JSON.stringify(value).replace(/['"]+/g, ""); 26 | } 27 | 28 | return JSON.stringify(value).replace( 29 | /\{"[a-zA-Z]+":|,"[a-zA-Z]+":/gi, 30 | (str) => { 31 | return str.replace(/"/g, ""); 32 | } 33 | ); 34 | }; 35 | 36 | if (args === undefined || args === null) { 37 | return ""; 38 | } 39 | 40 | const formattedArgs = Object.entries(args).reduce( 41 | (acc: any, [key, value]) => { 42 | // Ignore internal metadata key 43 | if (key === "__metadata") { 44 | return acc; 45 | } 46 | 47 | if (value !== undefined && value !== null) { 48 | acc.push(`${key}: ${formatValue(key, value as string)}`); 49 | } 50 | 51 | return acc; 52 | }, 53 | [] 54 | ); 55 | 56 | if (formattedArgs.length === 0) { 57 | return ""; 58 | } 59 | 60 | return `(${formattedArgs})`; 61 | } 62 | 63 | /** 64 | * Find QueryTree, convert them into GraphQl query 65 | * then compute and return the result to the appropriate field 66 | */ 67 | async function computeNestedQuery( 68 | query: QueryTree[], 69 | client: GraphQLClient 70 | ): Promise { 71 | // Check if there is a nested queryTree to be executed 72 | const isQueryTree = (value: any) => value["_queryTree"] !== undefined; 73 | 74 | // Check if there is a nested array of queryTree to be executed 75 | const isArrayQueryTree = (value: any[]) => 76 | value.every((v) => v instanceof Object && isQueryTree(v)); 77 | 78 | // Prepare query tree for final query by computing nested queries 79 | // and building it with their results. 80 | const computeQueryTree = async (value: any): Promise => { 81 | // Resolve sub queries if operation's args is a subquery 82 | for (const op of value["_queryTree"]) { 83 | await computeNestedQuery([op], client); 84 | } 85 | 86 | // push an id that will be used by the container 87 | return buildQuery([ 88 | ...value["_queryTree"], 89 | { 90 | operation: "id", 91 | }, 92 | ]); 93 | }; 94 | 95 | // Remove all undefined args and assert args type 96 | const queryToExec = query.filter((q): q is Required => !!q.args); 97 | 98 | for (const q of queryToExec) { 99 | await Promise.all( 100 | // Compute nested query for single object 101 | Object.entries(q.args).map(async ([key, value]: any) => { 102 | if (value instanceof Object && isQueryTree(value)) { 103 | // push an id that will be used by the container 104 | const getQueryTree = await computeQueryTree(value); 105 | 106 | q.args[key] = await compute(getQueryTree, client); 107 | } 108 | 109 | // Compute nested query for array of object 110 | if (Array.isArray(value) && isArrayQueryTree(value)) { 111 | const tmp: any = q.args[key]; 112 | 113 | for (let i = 0; i < value.length; i++) { 114 | // push an id that will be used by the container 115 | const getQueryTree = await computeQueryTree(value[i]); 116 | 117 | tmp[i] = await compute(getQueryTree, client); 118 | } 119 | 120 | q.args[key] = tmp; 121 | } 122 | }) 123 | ); 124 | } 125 | } 126 | 127 | /** 128 | * Convert the queryTree into a GraphQL query 129 | * @param q 130 | * @returns 131 | */ 132 | export function buildQuery(q: QueryTree[]): string { 133 | const query = q.reduce((acc, { operation, args }, i) => { 134 | const qLen = q.length; 135 | 136 | acc += ` ${operation} ${args ? `${buildArgs(args)}` : ""} ${ 137 | qLen - 1 !== i ? "{" : "}".repeat(qLen - 1) 138 | }`; 139 | 140 | return acc; 141 | }, ""); 142 | 143 | return `{${query} }`; 144 | } 145 | 146 | /** 147 | * Convert querytree into a Graphql query then compute it 148 | * @param q | QueryTree[] 149 | * @param client | GraphQLClient 150 | * @returns 151 | */ 152 | export async function computeQuery( 153 | q: QueryTree[], 154 | client: GraphQLClient 155 | ): Promise { 156 | await computeNestedQuery(q, client); 157 | 158 | const query = buildQuery(q); 159 | 160 | return await compute(query, client); 161 | } 162 | 163 | /** 164 | * Return a Graphql query result flattened 165 | * @param response any 166 | * @returns 167 | */ 168 | export function queryFlatten(response: any): T { 169 | // Recursion break condition 170 | // If our response is not an object or an array we assume we reached the value 171 | if (!(response instanceof Object) || Array.isArray(response)) { 172 | return response; 173 | } 174 | 175 | const keys = Object.keys(response); 176 | 177 | if (keys.length != 1) { 178 | // Dagger is currently expecting to only return one value 179 | // If the response is nested in a way were more than one object is nested inside throw an error 180 | throw new TooManyNestedObjectsError( 181 | "Too many nested objects inside graphql response", 182 | { response: response } 183 | ); 184 | } 185 | 186 | const nestedKey = keys[0]; 187 | 188 | return queryFlatten(response[nestedKey]); 189 | } 190 | 191 | /** 192 | * Send a GraphQL document to the server 193 | * return a flatten result 194 | * @hidden 195 | */ 196 | export async function compute( 197 | query: string, 198 | client: GraphQLClient 199 | ): Promise { 200 | let computeQuery: Awaited; 201 | try { 202 | computeQuery = await client.request( 203 | gql` 204 | ${query} 205 | ` 206 | ); 207 | } catch (e: any) { 208 | if (e instanceof ClientError) { 209 | const msg = e.response.errors?.[0]?.message ?? `API Error`; 210 | const ext = e.response.errors?.[0]?.extensions; 211 | 212 | if (ext?._type === "EXEC_ERROR") { 213 | throw new ExecError(msg, { 214 | cmd: (ext.cmd as string[]) ?? [], 215 | exitCode: (ext.exitCode as number) ?? -1, 216 | stdout: (ext.stdout as string) ?? "", 217 | stderr: (ext.stderr as string) ?? "", 218 | }); 219 | } 220 | 221 | throw new GraphQLRequestError(msg, { 222 | request: e.request, 223 | response: e.response, 224 | cause: e, 225 | }); 226 | } 227 | 228 | // Looking for connection error in case the function has not been awaited. 229 | if (e.errno === "ECONNREFUSED") { 230 | throw new NotAwaitedRequestError( 231 | "Encountered an error while requesting data via graphql through a synchronous call. Make sure the function called is awaited.", 232 | { cause: e } 233 | ); 234 | } 235 | 236 | // Just throw the unknown error 237 | throw new UnknownDaggerError( 238 | "Encountered an unknown error while requesting data via graphql", 239 | { cause: e } 240 | ); 241 | } 242 | 243 | return queryFlatten(computeQuery); 244 | } 245 | -------------------------------------------------------------------------------- /src/audio.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use std::thread; 3 | use std::time::Duration; 4 | 5 | use anyhow::{Context, Error}; 6 | use hyper::header::HeaderValue; 7 | use rodio::{OutputStream, OutputStreamHandle, Sink}; 8 | use tokio::sync::mpsc; 9 | 10 | use crate::decoder::Mp3Decoder; 11 | use crate::types::Station; 12 | 13 | /// Commands sent to the audio worker thread. 14 | #[derive(Debug)] 15 | enum AudioCommand { 16 | Play { 17 | station: Station, 18 | volume_percent: f32, 19 | }, 20 | SetVolume(f32), 21 | Stop, 22 | } 23 | 24 | /// Playback events emitted by the audio worker. 25 | #[derive(Debug, Clone)] 26 | pub enum PlaybackEvent { 27 | Started(PlaybackState), 28 | Error(String), 29 | Stopped, 30 | } 31 | 32 | /// Public interface for receiving playback events. 33 | pub struct PlaybackEvents { 34 | rx: mpsc::UnboundedReceiver, 35 | } 36 | 37 | impl PlaybackEvents { 38 | pub async fn recv(&mut self) -> Option { 39 | self.rx.recv().await 40 | } 41 | } 42 | 43 | /// Snapshot of the current playback metadata. 44 | #[derive(Debug, Clone)] 45 | pub struct PlaybackState { 46 | pub station: Station, 47 | pub stream_name: String, 48 | pub now_playing: String, 49 | pub genre: String, 50 | pub description: String, 51 | pub bitrate: String, 52 | } 53 | 54 | /// Controller that owns the command channel to the audio worker. 55 | pub struct AudioController { 56 | cmd_tx: mpsc::UnboundedSender, 57 | } 58 | 59 | impl AudioController { 60 | /// Spawn a new audio worker thread and return a controller plus event receiver. 61 | pub fn new() -> Result<(Self, PlaybackEvents), Error> { 62 | let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::(); 63 | let (event_tx, event_rx) = mpsc::unbounded_channel::(); 64 | 65 | thread::Builder::new() 66 | .name("tunein-audio-worker".into()) 67 | .spawn({ 68 | let events = event_tx.clone(); 69 | move || { 70 | let mut worker = AudioWorker::new(event_tx); 71 | if let Err(err) = worker.run(&mut cmd_rx) { 72 | let _ = events.send(PlaybackEvent::Error(err.to_string())); 73 | } 74 | } 75 | }) 76 | .context("failed to spawn audio worker thread")?; 77 | 78 | Ok((Self { cmd_tx }, PlaybackEvents { rx: event_rx })) 79 | } 80 | 81 | pub fn play(&self, station: Station, volume_percent: f32) -> Result<(), Error> { 82 | self.cmd_tx 83 | .send(AudioCommand::Play { 84 | station, 85 | volume_percent, 86 | }) 87 | .map_err(|e| Error::msg(e.to_string())) 88 | } 89 | 90 | pub fn set_volume(&self, volume_percent: f32) -> Result<(), Error> { 91 | self.cmd_tx 92 | .send(AudioCommand::SetVolume(volume_percent)) 93 | .map_err(|e| Error::msg(e.to_string())) 94 | } 95 | 96 | pub fn stop(&self) -> Result<(), Error> { 97 | self.cmd_tx 98 | .send(AudioCommand::Stop) 99 | .map_err(|e| Error::msg(e.to_string())) 100 | } 101 | } 102 | 103 | struct AudioWorker { 104 | _stream: OutputStream, 105 | handle: OutputStreamHandle, 106 | sink: Option>, 107 | current_volume: f32, 108 | events: mpsc::UnboundedSender, 109 | } 110 | 111 | impl AudioWorker { 112 | fn new(events: mpsc::UnboundedSender) -> Self { 113 | let (stream, handle) = 114 | OutputStream::try_default().expect("failed to acquire default audio output device"); 115 | Self { 116 | _stream: stream, 117 | handle, 118 | sink: None, 119 | current_volume: 100.0, 120 | events, 121 | } 122 | } 123 | 124 | fn run(&mut self, cmd_rx: &mut mpsc::UnboundedReceiver) -> Result<(), Error> { 125 | while let Some(cmd) = cmd_rx.blocking_recv() { 126 | match cmd { 127 | AudioCommand::Play { 128 | station, 129 | volume_percent, 130 | } => self.handle_play(station, volume_percent)?, 131 | AudioCommand::SetVolume(volume_percent) => { 132 | self.current_volume = volume_percent.max(0.0); 133 | if let Some(sink) = &self.sink { 134 | sink.set_volume(self.current_volume / 100.0); 135 | } 136 | } 137 | AudioCommand::Stop => { 138 | if let Some(sink) = self.sink.take() { 139 | sink.stop(); 140 | } 141 | let _ = self.events.send(PlaybackEvent::Stopped); 142 | } 143 | } 144 | } 145 | 146 | Ok(()) 147 | } 148 | 149 | fn handle_play(&mut self, station: Station, volume_percent: f32) -> Result<(), Error> { 150 | if let Some(sink) = self.sink.take() { 151 | sink.stop(); 152 | thread::sleep(Duration::from_millis(50)); 153 | } 154 | 155 | let stream_url = station.stream_url.clone(); 156 | let client = reqwest::blocking::Client::new(); 157 | let response = client 158 | .get(&stream_url) 159 | .send() 160 | .with_context(|| format!("failed to open stream {}", stream_url))?; 161 | 162 | let headers = response.headers().clone(); 163 | let now_playing = station.playing.clone().unwrap_or_default(); 164 | 165 | let display_name = header_to_string(headers.get("icy-name")) 166 | .filter(|name| name != "Unknown") 167 | .unwrap_or_else(|| station.name.clone()); 168 | let genre = header_to_string(headers.get("icy-genre")).unwrap_or_default(); 169 | let description = header_to_string(headers.get("icy-description")).unwrap_or_default(); 170 | let bitrate = header_to_string(headers.get("icy-br")).unwrap_or_default(); 171 | 172 | let response = follow_redirects(client, response)?; 173 | 174 | let sink = Arc::new(Sink::try_new(&self.handle)?); 175 | sink.set_volume(volume_percent.max(0.0) / 100.0); 176 | 177 | let decoder = Mp3Decoder::new(response, None).map_err(|_| { 178 | Error::msg("stream is not in MP3 format or failed to initialize decoder") 179 | })?; 180 | sink.append(decoder); 181 | sink.play(); 182 | 183 | self.current_volume = volume_percent; 184 | self.sink = Some(sink.clone()); 185 | 186 | let state = PlaybackState { 187 | station, 188 | stream_name: display_name, 189 | now_playing, 190 | genre, 191 | description, 192 | bitrate, 193 | }; 194 | 195 | let _ = self.events.send(PlaybackEvent::Started(state)); 196 | 197 | Ok(()) 198 | } 199 | } 200 | 201 | fn follow_redirects( 202 | client: reqwest::blocking::Client, 203 | response: reqwest::blocking::Response, 204 | ) -> Result { 205 | let mut current = response; 206 | for _ in 0..3 { 207 | if let Some(location) = current.headers().get("location") { 208 | let url = location 209 | .to_str() 210 | .map_err(|_| Error::msg("invalid redirect location header"))?; 211 | current = client.get(url).send()?; 212 | } else { 213 | return Ok(current); 214 | } 215 | } 216 | Ok(current) 217 | } 218 | 219 | fn header_to_string(value: Option<&HeaderValue>) -> Option { 220 | value 221 | .and_then(|header| header.to_str().ok()) 222 | .map(|s| s.to_string()) 223 | } 224 | -------------------------------------------------------------------------------- /.fluentci/src/jobs.ts: -------------------------------------------------------------------------------- 1 | import { dag } from "../sdk/client.gen.ts"; 2 | import { buildRustFlags, getDirectory } from "./lib.ts"; 3 | 4 | export enum Job { 5 | test = "test", 6 | build = "build", 7 | } 8 | 9 | export const exclude = ["target", ".git", ".devbox", ".fluentci"]; 10 | 11 | export const test = async (src = ".", options: string[] = []) => { 12 | const context = await getDirectory(src); 13 | const ctr = dag 14 | .container() 15 | .from("rust:1.89-bullseye") 16 | .withDirectory("/app", context, { exclude }) 17 | .withWorkdir("/app") 18 | .withMountedCache("/app/target", dag.cacheVolume("target")) 19 | .withMountedCache("/root/cargo/registry", dag.cacheVolume("registry")) 20 | .withExec(["cargo", "test", ...options]); 21 | 22 | return ctr.stdout(); 23 | }; 24 | 25 | export const build = async (src = ".") => { 26 | const rustflags = buildRustFlags(); 27 | const context = await getDirectory(src); 28 | const ctr = dag 29 | .container() 30 | .from("rust:1.89-bullseye") 31 | .withExec(["dpkg", "--add-architecture", "armhf"]) 32 | .withExec(["dpkg", "--add-architecture", "arm64"]) 33 | .withExec(["apt-get", "update"]) 34 | .withExec([ 35 | "apt-get", 36 | "install", 37 | "-y", 38 | "build-essential", 39 | "libasound2-dev", 40 | "protobuf-compiler", 41 | ]) 42 | .withExec([ 43 | "apt-get", 44 | "install", 45 | "-y", 46 | "-qq", 47 | "gcc-arm-linux-gnueabihf", 48 | "libc6-armhf-cross", 49 | "libc6-dev-armhf-cross", 50 | "gcc-aarch64-linux-gnu", 51 | "libc6-arm64-cross", 52 | "libc6-dev-arm64-cross", 53 | "libc6-armel-cross", 54 | "libc6-dev-armel-cross", 55 | "binutils-arm-linux-gnueabi", 56 | "gcc-arm-linux-gnueabi", 57 | "libncurses5-dev", 58 | "bison", 59 | "flex", 60 | "libssl-dev", 61 | "bc", 62 | "pkg-config", 63 | "libudev-dev", 64 | "libdbus-1-dev", 65 | ]) 66 | .withExec(["mkdir", "-p", "/build/sysroot"]) 67 | .withExec([ 68 | "apt-get", 69 | "download", 70 | "libasound2:armhf", 71 | "libasound2-dev:armhf", 72 | "libdbus-1-dev:armhf", 73 | "libdbus-1-3:armhf", 74 | "libsystemd-dev:armhf", 75 | "libsystemd0:armhf", 76 | "libcap2:armhf", 77 | "libcap-dev:armhf", 78 | "libgcrypt20:armhf", 79 | "libgcrypt20-dev:armhf", 80 | "libgpg-error0:armhf", 81 | "libgpg-error-dev:armhf", 82 | "liblz4-1:armhf", 83 | "liblz4-dev:armhf", 84 | "libxxhash0:armhf", 85 | "libxxhash-dev:armhf", 86 | "liblzma5:armhf", 87 | "liblzma-dev:armhf", 88 | "libzstd1:armhf", 89 | "libzstd-dev:armhf", 90 | 91 | "libasound2:arm64", 92 | "libasound2-dev:arm64", 93 | "libdbus-1-dev:arm64", 94 | "libdbus-1-3:arm64", 95 | "libsystemd-dev:arm64", 96 | "libsystemd0:arm64", 97 | "libcap2:arm64", 98 | "libcap-dev:arm64", 99 | "libgcrypt20:arm64", 100 | "libgcrypt20-dev:arm64", 101 | "libgpg-error0:arm64", 102 | "libgpg-error-dev:arm64", 103 | "liblz4-1:arm64", 104 | "liblz4-dev:arm64", 105 | "libxxhash0:arm64", 106 | "libxxhash-dev:arm64", 107 | "liblzma5:arm64", 108 | "liblzma-dev:arm64", 109 | "libzstd1:arm64", 110 | "libzstd-dev:arm64", 111 | ]) 112 | .withExec([ 113 | "dpkg", 114 | "-x", 115 | "libasound2-dev_1.2.4-1.1_arm64.deb", 116 | "/build/sysroot/", 117 | ]) 118 | .withExec([ 119 | "dpkg", 120 | "-x", 121 | "libasound2_1.2.4-1.1_arm64.deb", 122 | "/build/sysroot/", 123 | ]) 124 | .withExec([ 125 | "dpkg", 126 | "-x", 127 | "libasound2-dev_1.2.4-1.1_armhf.deb", 128 | "/build/sysroot/", 129 | ]) 130 | .withExec([ 131 | "dpkg", 132 | "-x", 133 | "libasound2_1.2.4-1.1_armhf.deb", 134 | "/build/sysroot/", 135 | ]) 136 | .withExec([ 137 | "dpkg", 138 | "-x", 139 | "libdbus-1-dev_1.12.28-0+deb11u1_armhf.deb", 140 | "/build/sysroot/", 141 | ]) 142 | .withExec([ 143 | "dpkg", 144 | "-x", 145 | "libdbus-1-3_1.12.28-0+deb11u1_armhf.deb", 146 | "/build/sysroot/", 147 | ]) 148 | .withExec([ 149 | "dpkg", 150 | "-x", 151 | "libsystemd-dev_247.3-7+deb11u7_armhf.deb", 152 | "/build/sysroot/", 153 | ]) 154 | .withExec([ 155 | "dpkg", 156 | "-x", 157 | "libsystemd0_247.3-7+deb11u7_armhf.deb", 158 | "/build/sysroot/", 159 | ]) 160 | .withExec([ 161 | "dpkg", 162 | "-x", 163 | "libcap-dev_1%3a2.44-1+deb11u1_armhf.deb", 164 | "/build/sysroot/", 165 | ]) 166 | .withExec([ 167 | "dpkg", 168 | "-x", 169 | "libcap2_1%3a2.44-1+deb11u1_armhf.deb", 170 | "/", 171 | ]) 172 | .withExec([ 173 | "dpkg", 174 | "-x", 175 | "libgcrypt20-dev_1.8.7-6_armhf.deb", 176 | "/build/sysroot/", 177 | ]) 178 | .withExec([ 179 | "dpkg", 180 | "-x", 181 | "libgcrypt20_1.8.7-6_armhf.deb", 182 | "/build/sysroot/", 183 | ]) 184 | .withExec([ 185 | "dpkg", 186 | "-x", 187 | "libgpg-error-dev_1.38-2_armhf.deb", 188 | "/build/sysroot/", 189 | ]) 190 | .withExec([ 191 | "dpkg", 192 | "-x", 193 | "libgpg-error0_1.38-2_armhf.deb", 194 | "/", 195 | ]) 196 | .withExec([ 197 | "dpkg", 198 | "-x", 199 | "liblz4-1_1.9.3-2_armhf.deb", 200 | "/build/sysroot/", 201 | ]) 202 | .withExec([ 203 | "dpkg", 204 | "-x", 205 | "liblz4-dev_1.9.3-2_armhf.deb", 206 | "/build/sysroot/", 207 | ]) 208 | .withExec([ 209 | "dpkg", 210 | "-x", 211 | "liblzma-dev_5.2.5-2.1~deb11u1_armhf.deb", 212 | "/build/sysroot/", 213 | ]) 214 | .withExec([ 215 | "dpkg", 216 | "-x", 217 | "liblzma5_5.2.5-2.1~deb11u1_armhf.deb", 218 | "/", 219 | ]) 220 | .withExec([ 221 | "dpkg", 222 | "-x", 223 | "libxxhash-dev_0.8.0-2_armhf.deb", 224 | "/build/sysroot/", 225 | ]) 226 | .withExec([ 227 | "dpkg", 228 | "-x", 229 | "libxxhash0_0.8.0-2_armhf.deb", 230 | "/build/sysroot/", 231 | ]) 232 | .withExec([ 233 | "dpkg", 234 | "-x", 235 | "libzstd1_1.4.8+dfsg-2.1_armhf.deb", 236 | "/build/sysroot/", 237 | ]) 238 | .withExec([ 239 | "dpkg", 240 | "-x", 241 | "libzstd-dev_1.4.8+dfsg-2.1_armhf.deb", 242 | "/build/sysroot/", 243 | ]) 244 | .withExec([ 245 | "dpkg", 246 | "-x", 247 | "libdbus-1-dev_1.12.28-0+deb11u1_arm64.deb", 248 | "/build/sysroot/", 249 | ]) 250 | .withExec([ 251 | "dpkg", 252 | "-x", 253 | "libdbus-1-3_1.12.28-0+deb11u1_arm64.deb", 254 | "/build/sysroot/", 255 | ]) 256 | .withExec([ 257 | "dpkg", 258 | "-x", 259 | "libsystemd-dev_247.3-7+deb11u7_arm64.deb", 260 | "/build/sysroot/", 261 | ]) 262 | .withExec([ 263 | "dpkg", 264 | "-x", 265 | "libsystemd0_247.3-7+deb11u7_arm64.deb", 266 | "/build/sysroot/", 267 | ]) 268 | .withExec([ 269 | "dpkg", 270 | "-x", 271 | "libcap-dev_1%3a2.44-1+deb11u1_arm64.deb", 272 | "/build/sysroot/", 273 | ]) 274 | .withExec([ 275 | "dpkg", 276 | "-x", 277 | "libcap2_1%3a2.44-1+deb11u1_arm64.deb", 278 | "/", 279 | ]) 280 | .withExec([ 281 | "dpkg", 282 | "-x", 283 | "libgcrypt20-dev_1.8.7-6_arm64.deb", 284 | "/build/sysroot/", 285 | ]) 286 | .withExec([ 287 | "dpkg", 288 | "-x", 289 | "libgcrypt20_1.8.7-6_arm64.deb", 290 | "/build/sysroot/", 291 | ]) 292 | .withExec([ 293 | "dpkg", 294 | "-x", 295 | "libgpg-error-dev_1.38-2_arm64.deb", 296 | "/build/sysroot/", 297 | ]) 298 | .withExec([ 299 | "dpkg", 300 | "-x", 301 | "libgpg-error0_1.38-2_arm64.deb", 302 | "/", 303 | ]) 304 | .withExec([ 305 | "dpkg", 306 | "-x", 307 | "liblz4-1_1.9.3-2_arm64.deb", 308 | "/build/sysroot/", 309 | ]) 310 | .withExec([ 311 | "dpkg", 312 | "-x", 313 | "liblz4-dev_1.9.3-2_arm64.deb", 314 | "/build/sysroot/", 315 | ]) 316 | .withExec([ 317 | "dpkg", 318 | "-x", 319 | "liblzma-dev_5.2.5-2.1~deb11u1_arm64.deb", 320 | "/build/sysroot/", 321 | ]) 322 | .withExec([ 323 | "dpkg", 324 | "-x", 325 | "liblzma5_5.2.5-2.1~deb11u1_arm64.deb", 326 | "/", 327 | ]) 328 | .withExec([ 329 | "dpkg", 330 | "-x", 331 | "libxxhash-dev_0.8.0-2_arm64.deb", 332 | "/build/sysroot/", 333 | ]) 334 | .withExec([ 335 | "dpkg", 336 | "-x", 337 | "libxxhash0_0.8.0-2_arm64.deb", 338 | "/build/sysroot/", 339 | ]) 340 | .withExec([ 341 | "dpkg", 342 | "-x", 343 | "libzstd1_1.4.8+dfsg-2.1_arm64.deb", 344 | "/build/sysroot/", 345 | ]) 346 | .withExec([ 347 | "dpkg", 348 | "-x", 349 | "libzstd-dev_1.4.8+dfsg-2.1_arm64.deb", 350 | "/build/sysroot/", 351 | ]) 352 | .withDirectory("/app", context, { exclude }) 353 | .withDirectory("/app", context, { exclude }) 354 | .withWorkdir("/app") 355 | .withMountedCache("/app/target", dag.cacheVolume("target")) 356 | .withMountedCache("/root/cargo/registry", dag.cacheVolume("registry")) 357 | .withMountedCache("/assets", dag.cacheVolume("gh-release-assets")) 358 | .withEnvVariable("RUSTFLAGS", rustflags) 359 | .withEnvVariable( 360 | "PKG_CONFIG_ALLOW_CROSS", 361 | Deno.env.get("TARGET") !== "x86_64-unknown-linux-gnu" ? "1" : "0", 362 | ) 363 | .withEnvVariable( 364 | "C_INCLUDE_PATH", 365 | Deno.env.get("TARGET") !== "x86_64-unknown-linux-gnu" 366 | ? "/build/sysroot/usr/include" 367 | : "/usr/include", 368 | ) 369 | .withEnvVariable("TAG", Deno.env.get("TAG") || "latest") 370 | .withEnvVariable( 371 | "TARGET", 372 | Deno.env.get("TARGET") || "x86_64-unknown-linux-gnu", 373 | ) 374 | .withExec([ 375 | "sh", 376 | "-c", 377 | "mv /usr/bin/protoc /usr/bin/_protoc && cp tools/protoc /usr/bin/protoc && chmod a+x /usr/bin/protoc", 378 | ]) 379 | .withExec(["sh", "-c", "rustup target add $TARGET"]) 380 | .withExec(["sh", "-c", "cargo build --release --target $TARGET"]) 381 | .withExec(["sh", "-c", "cp target/${TARGET}/release/tunein ."]) 382 | .withExec([ 383 | "sh", 384 | "-c", 385 | "tar czvf /assets/tunein_${TAG}_${TARGET}.tar.gz tunein README.md LICENSE", 386 | ]) 387 | .withExec([ 388 | "sh", 389 | "-c", 390 | "shasum -a 256 /assets/tunein_${TAG}_${TARGET}.tar.gz > /assets/tunein_${TAG}_${TARGET}.tar.gz.sha256", 391 | ]) 392 | .withExec(["sh", "-c", "cp /assets/tunein_${TAG}_${TARGET}.tar.gz ."]) 393 | .withExec([ 394 | "sh", 395 | "-c", 396 | "cp /assets/tunein_${TAG}_${TARGET}.tar.gz.sha256 .", 397 | ]); 398 | 399 | const exe = await ctr.file( 400 | `/app/tunein_${Deno.env.get("TAG")}_${Deno.env.get("TARGET")}.tar.gz`, 401 | ); 402 | await exe.export( 403 | `./tunein_${Deno.env.get("TAG")}_${Deno.env.get("TARGET")}.tar.gz`, 404 | ); 405 | 406 | const sha = await ctr.file( 407 | `/app/tunein_${Deno.env.get("TAG")}_${ 408 | Deno.env.get("TARGET") 409 | }.tar.gz.sha256`, 410 | ); 411 | await sha.export( 412 | `./tunein_${Deno.env.get("TAG")}_${Deno.env.get("TARGET")}.tar.gz.sha256`, 413 | ); 414 | return ctr.stdout(); 415 | }; 416 | 417 | export type JobExec = (src?: string) => 418 | | Promise 419 | | (( 420 | src?: string, 421 | options?: { 422 | ignore: string[]; 423 | }, 424 | ) => Promise); 425 | 426 | export const runnableJobs: Record = { 427 | [Job.test]: test, 428 | [Job.build]: build, 429 | }; 430 | 431 | export const jobDescriptions: Record = { 432 | [Job.test]: "Run tests", 433 | [Job.build]: "Build the project", 434 | }; 435 | -------------------------------------------------------------------------------- /src/visualization/spectroscope.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | 3 | use crossterm::event::{Event, KeyCode}; 4 | use ratatui::{ 5 | style::Style, 6 | text::Span, 7 | widgets::{Axis, GraphType}, 8 | }; 9 | 10 | use crate::{app::update_value_i, input::Matrix}; 11 | 12 | use super::{DataSet, Dimension, DisplayMode, GraphConfig}; 13 | 14 | use rustfft::{num_complex::Complex, FftPlanner}; 15 | 16 | #[derive(Default)] 17 | pub struct Spectroscope { 18 | pub sampling_rate: u32, 19 | pub buffer_size: u32, 20 | pub average: u32, 21 | pub buf: Vec>>, 22 | pub window: bool, 23 | pub log_y: bool, 24 | } 25 | 26 | fn magnitude(c: Complex) -> f64 { 27 | let squared = (c.re * c.re) + (c.im * c.im); 28 | squared.sqrt() 29 | } 30 | 31 | // got this from https://github.com/phip1611/spectrum-analyzer/blob/3c079ec2785b031d304bb381ff5f5fe04e6bcf71/src/windows.rs#L40 32 | pub fn hann_window(samples: &[f64]) -> Vec { 33 | let mut windowed_samples = Vec::with_capacity(samples.len()); 34 | let samples_len = samples.len() as f64; 35 | for (i, sample) in samples.iter().enumerate() { 36 | let two_pi_i = 2.0 * std::f64::consts::PI * i as f64; 37 | let idontknowthename = (two_pi_i / samples_len).cos(); 38 | let multiplier = 0.5 * (1.0 - idontknowthename); 39 | windowed_samples.push(sample * multiplier) 40 | } 41 | windowed_samples 42 | } 43 | 44 | impl DisplayMode for Spectroscope { 45 | fn from_args(opts: &crate::cfg::SourceOptions) -> Self { 46 | Spectroscope { 47 | sampling_rate: opts.sample_rate, 48 | buffer_size: opts.buffer, 49 | average: 1, 50 | buf: Vec::new(), 51 | window: false, 52 | log_y: true, 53 | } 54 | } 55 | 56 | fn mode_str(&self) -> &'static str { 57 | "spectro" 58 | } 59 | 60 | fn channel_name(&self, index: usize) -> String { 61 | match index { 62 | 0 => "L".into(), 63 | 1 => "R".into(), 64 | _ => format!("{}", index), 65 | } 66 | } 67 | 68 | fn header(&self, _: &GraphConfig) -> String { 69 | let window_marker = if self.window { "-|-" } else { "---" }; 70 | if self.average <= 1 { 71 | format!( 72 | "live {} {:.3}Hz bins", 73 | window_marker, 74 | self.sampling_rate as f64 / self.buffer_size as f64 75 | ) 76 | } else { 77 | format!( 78 | "{}x avg ({:.1}s) {} {:.3}Hz bins", 79 | self.average, 80 | (self.average * self.buffer_size) as f64 / self.sampling_rate as f64, 81 | window_marker, 82 | self.sampling_rate as f64 / (self.buffer_size * self.average) as f64, 83 | ) 84 | } 85 | } 86 | 87 | fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_> { 88 | let (name, bounds) = match dimension { 89 | Dimension::X => ( 90 | "frequency -", 91 | [ 92 | 20.0f64.ln(), 93 | ((cfg.samples as f64 / cfg.width as f64) * 20000.0).ln(), 94 | ], 95 | ), 96 | Dimension::Y => ( 97 | if self.log_y { "| level" } else { "| amplitude" }, 98 | [if self.log_y { 0. } else { 0.0 }, cfg.scale * 7.5], // very arbitrary but good default 99 | ), 100 | // TODO super arbitraty! wtf! also ugly inline ifs, get this thing together! 101 | }; 102 | let mut a = Axis::default(); 103 | if cfg.show_ui { 104 | // TODO don't make it necessary to check show_ui inside here 105 | a = a.title(Span::styled(name, Style::default().fg(cfg.labels_color))); 106 | } 107 | a.style(Style::default().fg(cfg.axis_color)).bounds(bounds) 108 | } 109 | 110 | fn process(&mut self, cfg: &GraphConfig, data: &Matrix) -> Vec { 111 | if self.average == 0 { 112 | self.average = 1 113 | } // otherwise fft breaks 114 | if !cfg.pause { 115 | for (i, chan) in data.iter().enumerate() { 116 | if self.buf.len() <= i { 117 | self.buf.push(VecDeque::new()); 118 | } 119 | self.buf[i].push_back(chan.clone()); 120 | while self.buf[i].len() > self.average as usize { 121 | self.buf[i].pop_front(); 122 | } 123 | } 124 | } 125 | 126 | let mut out = Vec::new(); 127 | let mut planner: FftPlanner = FftPlanner::new(); 128 | let sample_len = self.buffer_size * self.average; 129 | let resolution = self.sampling_rate as f64 / sample_len as f64; 130 | let fft = planner.plan_fft_forward(sample_len as usize); 131 | 132 | for (n, chan_queue) in self.buf.iter().enumerate().rev() { 133 | let mut chunk = chan_queue.iter().flatten().copied().collect::>(); 134 | if self.window { 135 | chunk = hann_window(chunk.as_slice()); 136 | } 137 | let mut max_val = *chunk 138 | .iter() 139 | .max_by(|a, b| a.total_cmp(b)) 140 | .expect("empty dataset?"); 141 | if max_val < 1. { 142 | max_val = 1.; 143 | } 144 | let mut tmp: Vec> = chunk 145 | .iter() 146 | .map(|x| Complex { 147 | re: *x / max_val, 148 | im: 0.0, 149 | }) 150 | .collect(); 151 | fft.process(tmp.as_mut_slice()); 152 | out.push(DataSet::new( 153 | Some(self.channel_name(n)), 154 | tmp[..=tmp.len() / 2] 155 | .iter() 156 | .enumerate() 157 | .map(|(i, x)| { 158 | ( 159 | (i as f64 * resolution).ln(), 160 | if self.log_y { 161 | magnitude(*x).ln() 162 | } else { 163 | magnitude(*x) 164 | }, 165 | ) 166 | }) 167 | .collect(), 168 | cfg.marker_type, 169 | if cfg.scatter { 170 | GraphType::Scatter 171 | } else { 172 | GraphType::Line 173 | }, 174 | cfg.palette(n), 175 | )); 176 | } 177 | 178 | out 179 | } 180 | 181 | fn handle(&mut self, event: Event) { 182 | if let Event::Key(key) = event { 183 | match key.code { 184 | KeyCode::PageUp => update_value_i(&mut self.average, true, 1, 1., 1..65535), 185 | KeyCode::PageDown => update_value_i(&mut self.average, false, 1, 1., 1..65535), 186 | KeyCode::Char('w') => self.window = !self.window, 187 | KeyCode::Char('l') => self.log_y = !self.log_y, 188 | _ => {} 189 | } 190 | } 191 | } 192 | 193 | fn references(&self, cfg: &GraphConfig) -> Vec { 194 | let lower = 0.; // if self.log_y { -(cfg.scale * 5.) } else { 0. }; 195 | let upper = cfg.scale * 7.5; 196 | vec![ 197 | DataSet::new( 198 | None, 199 | vec![(0.0, 0.0), ((cfg.samples as f64).ln(), 0.0)], 200 | cfg.marker_type, 201 | GraphType::Line, 202 | cfg.axis_color, 203 | ), 204 | // TODO can we auto generate these? lol... 205 | DataSet::new( 206 | None, 207 | vec![(20.0f64.ln(), lower), (20.0f64.ln(), upper)], 208 | cfg.marker_type, 209 | GraphType::Line, 210 | cfg.axis_color, 211 | ), 212 | DataSet::new( 213 | None, 214 | vec![(30.0f64.ln(), lower), (30.0f64.ln(), upper)], 215 | cfg.marker_type, 216 | GraphType::Line, 217 | cfg.axis_color, 218 | ), 219 | DataSet::new( 220 | None, 221 | vec![(40.0f64.ln(), lower), (40.0f64.ln(), upper)], 222 | cfg.marker_type, 223 | GraphType::Line, 224 | cfg.axis_color, 225 | ), 226 | DataSet::new( 227 | None, 228 | vec![(50.0f64.ln(), lower), (50.0f64.ln(), upper)], 229 | cfg.marker_type, 230 | GraphType::Line, 231 | cfg.axis_color, 232 | ), 233 | DataSet::new( 234 | None, 235 | vec![(60.0f64.ln(), lower), (60.0f64.ln(), upper)], 236 | cfg.marker_type, 237 | GraphType::Line, 238 | cfg.axis_color, 239 | ), 240 | DataSet::new( 241 | None, 242 | vec![(70.0f64.ln(), lower), (70.0f64.ln(), upper)], 243 | cfg.marker_type, 244 | GraphType::Line, 245 | cfg.axis_color, 246 | ), 247 | DataSet::new( 248 | None, 249 | vec![(80.0f64.ln(), lower), (80.0f64.ln(), upper)], 250 | cfg.marker_type, 251 | GraphType::Line, 252 | cfg.axis_color, 253 | ), 254 | DataSet::new( 255 | None, 256 | vec![(90.0f64.ln(), lower), (90.0f64.ln(), upper)], 257 | cfg.marker_type, 258 | GraphType::Line, 259 | cfg.axis_color, 260 | ), 261 | DataSet::new( 262 | None, 263 | vec![(100.0f64.ln(), lower), (100.0f64.ln(), upper)], 264 | cfg.marker_type, 265 | GraphType::Line, 266 | cfg.axis_color, 267 | ), 268 | DataSet::new( 269 | None, 270 | vec![(200.0f64.ln(), lower), (200.0f64.ln(), upper)], 271 | cfg.marker_type, 272 | GraphType::Line, 273 | cfg.axis_color, 274 | ), 275 | DataSet::new( 276 | None, 277 | vec![(300.0f64.ln(), lower), (300.0f64.ln(), upper)], 278 | cfg.marker_type, 279 | GraphType::Line, 280 | cfg.axis_color, 281 | ), 282 | DataSet::new( 283 | None, 284 | vec![(400.0f64.ln(), lower), (400.0f64.ln(), upper)], 285 | cfg.marker_type, 286 | GraphType::Line, 287 | cfg.axis_color, 288 | ), 289 | DataSet::new( 290 | None, 291 | vec![(500.0f64.ln(), lower), (500.0f64.ln(), upper)], 292 | cfg.marker_type, 293 | GraphType::Line, 294 | cfg.axis_color, 295 | ), 296 | DataSet::new( 297 | None, 298 | vec![(600.0f64.ln(), lower), (600.0f64.ln(), upper)], 299 | cfg.marker_type, 300 | GraphType::Line, 301 | cfg.axis_color, 302 | ), 303 | DataSet::new( 304 | None, 305 | vec![(700.0f64.ln(), lower), (700.0f64.ln(), upper)], 306 | cfg.marker_type, 307 | GraphType::Line, 308 | cfg.axis_color, 309 | ), 310 | DataSet::new( 311 | None, 312 | vec![(800.0f64.ln(), lower), (800.0f64.ln(), upper)], 313 | cfg.marker_type, 314 | GraphType::Line, 315 | cfg.axis_color, 316 | ), 317 | DataSet::new( 318 | None, 319 | vec![(900.0f64.ln(), lower), (900.0f64.ln(), upper)], 320 | cfg.marker_type, 321 | GraphType::Line, 322 | cfg.axis_color, 323 | ), 324 | DataSet::new( 325 | None, 326 | vec![(1000.0f64.ln(), lower), (1000.0f64.ln(), upper)], 327 | cfg.marker_type, 328 | GraphType::Line, 329 | cfg.axis_color, 330 | ), 331 | DataSet::new( 332 | None, 333 | vec![(2000.0f64.ln(), lower), (2000.0f64.ln(), upper)], 334 | cfg.marker_type, 335 | GraphType::Line, 336 | cfg.axis_color, 337 | ), 338 | DataSet::new( 339 | None, 340 | vec![(3000.0f64.ln(), lower), (3000.0f64.ln(), upper)], 341 | cfg.marker_type, 342 | GraphType::Line, 343 | cfg.axis_color, 344 | ), 345 | DataSet::new( 346 | None, 347 | vec![(4000.0f64.ln(), lower), (4000.0f64.ln(), upper)], 348 | cfg.marker_type, 349 | GraphType::Line, 350 | cfg.axis_color, 351 | ), 352 | DataSet::new( 353 | None, 354 | vec![(5000.0f64.ln(), lower), (5000.0f64.ln(), upper)], 355 | cfg.marker_type, 356 | GraphType::Line, 357 | cfg.axis_color, 358 | ), 359 | DataSet::new( 360 | None, 361 | vec![(6000.0f64.ln(), lower), (6000.0f64.ln(), upper)], 362 | cfg.marker_type, 363 | GraphType::Line, 364 | cfg.axis_color, 365 | ), 366 | DataSet::new( 367 | None, 368 | vec![(7000.0f64.ln(), lower), (7000.0f64.ln(), upper)], 369 | cfg.marker_type, 370 | GraphType::Line, 371 | cfg.axis_color, 372 | ), 373 | DataSet::new( 374 | None, 375 | vec![(8000.0f64.ln(), lower), (8000.0f64.ln(), upper)], 376 | cfg.marker_type, 377 | GraphType::Line, 378 | cfg.axis_color, 379 | ), 380 | DataSet::new( 381 | None, 382 | vec![(9000.0f64.ln(), lower), (9000.0f64.ln(), upper)], 383 | cfg.marker_type, 384 | GraphType::Line, 385 | cfg.axis_color, 386 | ), 387 | DataSet::new( 388 | None, 389 | vec![(10000.0f64.ln(), lower), (10000.0f64.ln(), upper)], 390 | cfg.marker_type, 391 | GraphType::Line, 392 | cfg.axis_color, 393 | ), 394 | DataSet::new( 395 | None, 396 | vec![(20000.0f64.ln(), lower), (20000.0f64.ln(), upper)], 397 | cfg.marker_type, 398 | GraphType::Line, 399 | cfg.axis_color, 400 | ), 401 | ] 402 | } 403 | } 404 | --------------------------------------------------------------------------------