├── .cargo
└── config.toml
├── radio-webapp
├── src
│ ├── vite-env.d.ts
│ ├── assets
│ │ ├── noimage.png
│ │ ├── touhoudb.jpg
│ │ └── youtube.png
│ ├── index.tsx
│ ├── index.css
│ ├── App.tsx
│ ├── Player.tsx
│ ├── App.css
│ └── Mediainfo.tsx
├── vite.config.ts
├── tsconfig.node.json
├── .gitignore
├── index.html
├── package.json
├── tsconfig.json
├── README.md
└── pnpm-lock.yaml
├── .gitignore
├── Cargo.toml
├── src
├── files.rs
├── audio.rs
├── cmd.rs
├── config.rs
├── main.rs
└── player.rs
├── README.md
└── Cargo.lock
/.cargo/config.toml:
--------------------------------------------------------------------------------
1 | [env]
2 | BASE_URL = "/"
3 |
--------------------------------------------------------------------------------
/radio-webapp/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | *.mp3
3 | *.flac
4 | info.json
5 | mediainfo.json
6 | *.exe
7 | *.js
8 | audacity
9 |
--------------------------------------------------------------------------------
/radio-webapp/src/assets/noimage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiftoo/radio/master/radio-webapp/src/assets/noimage.png
--------------------------------------------------------------------------------
/radio-webapp/src/assets/touhoudb.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiftoo/radio/master/radio-webapp/src/assets/touhoudb.jpg
--------------------------------------------------------------------------------
/radio-webapp/src/assets/youtube.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiftoo/radio/master/radio-webapp/src/assets/youtube.png
--------------------------------------------------------------------------------
/radio-webapp/vite.config.ts:
--------------------------------------------------------------------------------
1 | import {defineConfig} from "vite";
2 | import solid from "vite-plugin-solid";
3 |
4 | export default defineConfig({
5 | plugins: [solid()],
6 | base: process.env.BASE_URL,
7 | });
8 |
--------------------------------------------------------------------------------
/radio-webapp/src/index.tsx:
--------------------------------------------------------------------------------
1 | /* @refresh reload */
2 | import {render} from "solid-js/web";
3 |
4 | import "./index.css";
5 | import App from "./App";
6 |
7 | const root = document.getElementById("root");
8 |
9 | render(() => , root!);
10 |
--------------------------------------------------------------------------------
/radio-webapp/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true
9 | },
10 | "include": ["vite.config.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/radio-webapp/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/radio-webapp/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Minecraft, Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3 |
4 | color-scheme: light dark;
5 | background-color: #272726;
6 | }
7 |
8 | body {
9 | margin: 0;
10 | }
11 |
12 | #root {
13 | display: flex;
14 | flex-direction: row;
15 | justify-content: center;
16 | }
17 |
18 | * {
19 | box-sizing: border-box;
20 | }
21 |
--------------------------------------------------------------------------------
/radio-webapp/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Now playing: ...
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/radio-webapp/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "radio-webapp",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "start": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "solid-js": "^1.8.15"
13 | },
14 | "devDependencies": {
15 | "@types/node": "^20.11.24",
16 | "typescript": "^5.2.2",
17 | "vite": "^5.1.4",
18 | "vite-plugin-solid": "^2.10.1"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/radio-webapp/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "module": "ESNext",
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "preserve",
16 | "jsxImportSource": "solid-js",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": false,
21 | "noUnusedParameters": false,
22 | "noFallthroughCasesInSwitch": true
23 | },
24 | "include": ["src"],
25 | "references": [{"path": "./tsconfig.node.json"}]
26 | }
27 |
--------------------------------------------------------------------------------
/radio-webapp/README.md:
--------------------------------------------------------------------------------
1 | ## Usage
2 |
3 | ```bash
4 | $ npm install # or pnpm install or yarn install
5 | ```
6 |
7 | ### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
8 |
9 | ## Available Scripts
10 |
11 | In the project directory, you can run:
12 |
13 | ### `npm run dev`
14 |
15 | Runs the app in the development mode.
16 | Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
17 |
18 | ### `npm run build`
19 |
20 | Builds the app for production to the `dist` folder.
21 | It correctly bundles Solid in production mode and optimizes the build for the best performance.
22 |
23 | The build is minified and the filenames include the hashes.
24 | Your app is ready to be deployed!
25 |
26 | ## Deployment
27 |
28 | Learn more about deploying your application with the [documentations](https://vitejs.dev/guide/static-deploy.html)
29 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "radio"
3 | version = "0.13.0"
4 | edition = "2021"
5 |
6 | [dependencies]
7 | async-trait = "0.1.77"
8 | axum = { version = "0.7.4", features = [
9 | "macros",
10 | "ws",
11 | "http1",
12 | "query",
13 | ], default-features = false }
14 | clap = { version = "4.5.1", features = ["derive"] }
15 | futures-core = "0.3.30"
16 | is-root = "0.1.3"
17 | jwalk = "0.8.1"
18 | mime_guess = "2.0.4"
19 | rand = "0.8.5"
20 | rayon = "1.9.0"
21 | rust-embed = { version = "8.3.0", features = ["axum"], optional = true }
22 | serde = { version = "1.0.197", features = ["derive"] }
23 | serde_json = "1.0.114"
24 | tokio = { version = "1.36.0", features = ["rt-multi-thread", "process"] }
25 | tokio-stream = { version = "0.1.14", default-features = false, features = [
26 | "sync",
27 | ] }
28 | toml = "0.8.10"
29 | tower-http = { version = "0.5.2", features = ["cors"] }
30 |
31 | [target.'cfg(windows)'.dependencies]
32 | windirs = "1.0.1"
33 |
34 | [features]
35 | default = ["webapp"]
36 | webapp = ["dep:rust-embed"]
37 | h2 = ["axum/http2"]
38 |
--------------------------------------------------------------------------------
/radio-webapp/src/App.tsx:
--------------------------------------------------------------------------------
1 | import {createSignal} from "solid-js";
2 | import "./App.css";
3 | import {Mediainfo} from "./Mediainfo";
4 | import {Player} from "./Player";
5 |
6 | const HOSTNAME = new URL(import.meta.env.BASE_URL, location.origin);
7 | // const HOSTNAME = new URL(import.meta.env.BASE_URL, "http://localhost:9005");
8 |
9 | export function makeUrl(protocol: "http" | "ws", path: string) {
10 | let newProtocol: string = protocol;
11 | if (protocol === "ws") {
12 | if (location.protocol === "http:") {
13 | newProtocol = "ws:";
14 | } else {
15 | newProtocol = "wss:";
16 | }
17 | } else {
18 | newProtocol = location.protocol;
19 | }
20 |
21 | let newUrl = new URL(HOSTNAME);
22 | newUrl.protocol = newProtocol;
23 | let url = newUrl.toString();
24 | if (path.startsWith("/")) {
25 | path = path.slice(1);
26 | }
27 | if (!url.endsWith("/")) {
28 | url += "/";
29 | }
30 | // console.log("url + path", newUrl + path);
31 | return newUrl + path;
32 | }
33 |
34 | export default function App() {
35 | return (
36 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/radio-webapp/src/Player.tsx:
--------------------------------------------------------------------------------
1 | import {JSX, createSignal} from "solid-js";
2 | import {makeUrl} from "./App";
3 |
4 | export function Player() {
5 | const [playing, setPlaying] = createSignal(false);
6 | const [volume, setVolume] = createSignal(33);
7 |
8 | let audioRef: undefined | HTMLAudioElement;
9 |
10 | const changeVolume: JSX.EventHandlerUnion = (ev) => {
11 | setVolume(+ev.currentTarget.value / 100);
12 | audioRef!.volume = +ev.currentTarget.value / 100;
13 | };
14 |
15 | const togglePlay = () => {
16 | const audio = audioRef!;
17 | setPlaying((v) => {
18 | if (v) {
19 | audio.pause();
20 | } else {
21 | if (isFinite(audio.duration)) {
22 | audio.currentTime = Math.max(audio.duration - 1, 0);
23 | }
24 | audio.play();
25 | }
26 | return !v;
27 | });
28 | };
29 |
30 | return (
31 |
32 |
33 |
36 |
Volume:
37 |
38 |
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/src/files.rs:
--------------------------------------------------------------------------------
1 | use std::{
2 | path::{Path, PathBuf},
3 | sync::Arc,
4 | };
5 |
6 | use rayon::{prelude::*, ThreadPool};
7 |
8 | use crate::config::{self, DirectoryConfig};
9 |
10 | const SUPPORTED_FORMATS: [&str; 4] = ["mp3", "flac", "opus", "wav"];
11 |
12 | pub fn collect(path: &[config::DirectoryConfig]) -> Vec {
13 | let pool = Arc::new(rayon::ThreadPoolBuilder::new().build().unwrap());
14 |
15 | pool.install(|| path.iter().flat_map(|x| walk(x.clone(), pool.clone())).collect())
16 | }
17 |
18 | fn walk(path: DirectoryConfig, pool: Arc) -> Vec {
19 | jwalk::WalkDir::new(path.root)
20 | .parallelism(jwalk::Parallelism::RayonExistingPool { pool, busy_timeout: None })
21 | .into_iter()
22 | .par_bridge()
23 | .flatten()
24 | .filter(|x| match &path.mode {
25 | config::DirectoryConfigMode::Exclude(dirs) => {
26 | if dirs.is_empty() {
27 | return true;
28 | }
29 | dirs.iter().any(|y| !x.path().starts_with(y))
30 | }
31 | config::DirectoryConfigMode::Include(dirs) => {
32 | dirs.iter().any(|y| x.path().starts_with(y))
33 | }
34 | })
35 | .flat_map(|x| {
36 | let cond = x.file_type.is_file()
37 | && Path::new(&x.file_name)
38 | .extension()
39 | .and_then(|x| x.to_str())
40 | .is_some_and(|x| SUPPORTED_FORMATS.contains(&x));
41 | cond.then(|| x.path())
42 | })
43 | .collect::>()
44 | }
45 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Stream mp3 audio to the world. Wrote this for myself to be able to listen to my music collection from anywhere.
2 |
3 | Recursively walks a directory and serves all (mp3, flac, opus, wav) files from the directory tree.
4 | Requires `ffmpeg` and `ffprobe`, probably any version as long as it can read the formats above and has libmp3lame enabled.
5 |
6 | Here's the output of help as of now:
7 | ```
8 | Usage: radio [OPTIONS] --root
9 |
10 | Options:
11 | --generate-config []
12 | Overwrite existing or create a new config file. Optionally pass a path to the config file to be created (not directory).
13 | Doesn't work right yet.
14 |
15 | --use-config []
16 | Use the config file instead of the command line. Generates a new config if none exists.
17 | All arguments except '--generate-config' are ignored if this is present.
18 | Optionally pass a path to the config file to be created/read (not directory).
19 |
20 | --host
21 | The host to bind to.
22 |
23 | [default: 127.0.0.1]
24 |
25 | --port
26 | [default: 9005]
27 |
28 | --enable-webui
29 | not implemented.
30 |
31 | --shuffle
32 | Choose next song randomly.
33 |
34 | --bitrate
35 | The bitrate to use for transcoding. Plain value for bps and suffixed with 'k' for kbps.
36 |
37 | [default: 128k]
38 |
39 | --enable-mediainfo
40 | Enable /mediainfo endpoint. It serves metadata for the current song in JSON format.
41 |
42 | --mediainfo-history
43 | The size of song history to keep track of. Must be greater than 0.
44 |
45 | [default: 16]
46 |
47 | --transcode-all[=]
48 | Transcode files that can be sent without transcoding. Set to true if you want to reduce bandwidth a little.
49 |
50 | [default: false]
51 | [possible values: true, false]
52 |
53 | --root
54 | The root directory to recursively search for music.
55 | Note: --use-config allows to specify multiple root directories.
56 |
57 | --include
58 | Include these directories or files.
59 |
60 | --exclude
61 | Exclude these directories or files.
62 |
63 | -h, --help
64 | Print help (see a summary with '-h')
65 |
66 | -V, --version
67 | Print version
68 |
69 | ```
70 | ---
71 | Here's a lucky commit hash I pulled:
72 |
73 |
--------------------------------------------------------------------------------
/radio-webapp/src/App.css:
--------------------------------------------------------------------------------
1 | #container {
2 | display: flex;
3 | flex-direction: column;
4 | align-items: center;
5 | width: 800px;
6 | padding: 4px;
7 | }
8 |
9 | #mediainfo {
10 | display: flex;
11 | flex-flow: row;
12 | justify-content: flex-start;
13 | align-items: center;
14 | gap: 1em;
15 | flex-shrink: 1;
16 | }
17 |
18 | #mediainfo > #image {
19 | padding: 4px;
20 | width: 400px;
21 | height: 400px;
22 | object-fit: contain;
23 | }
24 |
25 | #mediainfo > #image > img {
26 | width: 100%;
27 | height: 100%;
28 | object-fit: contain;
29 | border-radius: 5px;
30 | }
31 |
32 | #mediainfo > #info {
33 | display: flex;
34 | flex-direction: column;
35 | justify-content: center;
36 | padding-left: 1em;
37 | padding-right: 1em;
38 | width: 350px;
39 | }
40 |
41 | #mediainfo > #info > ul {
42 | margin: 0;
43 | display: flex;
44 | flex-direction: column;
45 | justify-content: space-between;
46 | padding: 0;
47 | list-style: none;
48 | gap: 2px;
49 | }
50 |
51 | #player {
52 | height: 50px;
53 | padding: 4px;
54 | width: 100%;
55 | }
56 |
57 | #player > #controls {
58 | display: flex;
59 | align-items: center;
60 | gap: 4px;
61 | }
62 |
63 | #player > #controls > button {
64 | appearance: none;
65 | font-size: 16px;
66 | background-color: #cf8439;
67 | border: 1px solid #ab6b2a;
68 | border-radius: 8px;
69 | padding: 0.5em 1.33em;
70 | margin-right: 1em;
71 | cursor: pointer;
72 | }
73 |
74 | #player > #controls > button:active {
75 | transform: scale(0.95);
76 | }
77 |
78 | #player > #controls > input[type="range"] {
79 | -webkit-appearance: none;
80 | appearance: none;
81 | width: 33%;
82 | height: 10px;
83 | border-radius: 5px;
84 | background: #d3d3d3;
85 | outline: none;
86 | }
87 |
88 | #player > #controls > input[type="range"]::-moz-range-thumb {
89 | appearance: none;
90 | width: 20px;
91 | height: 20px;
92 | border-radius: 50%;
93 | background: #cf8439;
94 | cursor: pointer;
95 | border: 1px solid #ab6b2a;
96 | }
97 |
98 | #player > #controls > input[type="range"]::-webkit-slider-thumb {
99 | appearance: none;
100 | width: 20px;
101 | height: 20px;
102 | border-radius: 50%;
103 | background: #cf8439;
104 | cursor: pointer;
105 | border: 1px solid #ab6b2a;
106 | }
107 |
108 | @media (width <= 768px) {
109 | #container {
110 | width: 100%;
111 | height: 100vh;
112 | height: 100dvh;
113 | justify-content: space-between;
114 | }
115 | #mediainfo {
116 | flex-grow: 1;
117 | flex-direction: column;
118 | }
119 | #mediainfo > #info {
120 | flex-grow: 1;
121 | align-self: flex-start;
122 | }
123 | #mediainfo > #info > ul {
124 | margin-bottom: auto;
125 | }
126 | #mediainfo > #image {
127 | width: 100%;
128 | height: auto;
129 | flex-shrink: 1;
130 | }
131 | #mediainfo > #image > img {
132 | max-height: 60vh;
133 | }
134 | }
135 |
136 | #media-links {
137 | display: flex;
138 | flex-direction: row;
139 | padding-top: 4px;
140 | gap: 4px;
141 | }
142 |
143 | #media-links img {
144 | width: 32px;
145 | height: 32px;
146 | }
147 |
--------------------------------------------------------------------------------
/src/audio.rs:
--------------------------------------------------------------------------------
1 | use std::{
2 | path::{Path, PathBuf},
3 | time::Duration,
4 | };
5 |
6 | use tokio::io::AsyncReadExt;
7 |
8 | use crate::cmd::{self};
9 |
10 | #[derive(Debug)]
11 | pub enum Data {
12 | Audio(usize),
13 | Error(String),
14 | }
15 |
16 | #[async_trait::async_trait]
17 | pub trait AudioReader: Send {
18 | async fn read_data(&mut self, buf: &mut [u8]) -> Result;
19 | async fn read_metadata(&mut self) -> Result;
20 | }
21 |
22 | pub struct FFMpegAudioReader {
23 | file: PathBuf,
24 | metadata: Option,
25 | error_buf: Vec,
26 | handle: tokio::process::Child,
27 | stdout: tokio::process::ChildStdout,
28 | stderr: tokio::process::ChildStderr,
29 | }
30 |
31 | impl FFMpegAudioReader {
32 | pub fn start(
33 | input: impl AsRef,
34 | sweeper: Option>,
35 | bitrate: u32,
36 | copy_codec: bool,
37 | ) -> Self {
38 | let mut handle = cmd::spawn_ffmpeg(
39 | input.as_ref(),
40 | sweeper.as_ref().map(|x| x.as_ref()),
41 | bitrate,
42 | copy_codec,
43 | );
44 | let stdout = handle.stdout.take().unwrap();
45 | let stderr = handle.stderr.take().unwrap();
46 | Self {
47 | file: input.as_ref().to_path_buf(),
48 | error_buf: Default::default(),
49 | handle,
50 | stdout,
51 | stderr,
52 | metadata: None,
53 | }
54 | }
55 | }
56 |
57 | #[async_trait::async_trait]
58 | impl AudioReader for FFMpegAudioReader {
59 | async fn read_data(&mut self, buf: &mut [u8]) -> Result {
60 | tokio::select! {
61 | biased;
62 | read_result = self.stdout.read(buf) => {
63 | match read_result {
64 | Ok(read) => Ok(Data::Audio(read)),
65 | Err(e) => Err(e),
66 | }
67 | },
68 | Ok(x) = self.stderr.read_buf(&mut self.error_buf) => {
69 | if x > 0 {
70 | // i'm scared of locking up the whole thing on read_buf
71 | // the following is tested and doesn't work very well.
72 | // tokio::time::sleep(Duration::from_millis(200)).await;
73 | // tokio::select! {
74 | // biased;
75 | // _ = self.stderr.read_buf(&mut self.error_buf) => {},
76 | // _ = std::future::ready(()) => {},
77 | // }
78 |
79 | let (tx, mut rx) = tokio::sync::oneshot::channel();
80 | tokio::spawn(async move {
81 | tokio::time::sleep(Duration::from_millis(200)).await;
82 | tx.send(()).unwrap();
83 | });
84 | loop {
85 | tokio::select! {
86 | _ = self.stderr.read_buf(&mut self.error_buf) => {},
87 | _ = &mut rx => break,
88 | }
89 | }
90 | Ok(Data::Error(String::from_utf8_lossy(&self.error_buf).to_string()))
91 | } else {
92 | Ok(Data::Audio(0))
93 | }
94 | },
95 | }
96 | }
97 |
98 | async fn read_metadata(&mut self) -> Result {
99 | match self.metadata {
100 | Some(ref x) => Ok(x.clone()),
101 | None => {
102 | let metadata = cmd::mediainfo(&self.file).await?;
103 | self.metadata = Some(metadata.clone());
104 | Ok(metadata)
105 | }
106 | }
107 | }
108 | }
109 |
110 | impl Drop for FFMpegAudioReader {
111 | fn drop(&mut self) {
112 | self.handle.start_kill().unwrap();
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/radio-webapp/src/Mediainfo.tsx:
--------------------------------------------------------------------------------
1 | import {JSX, Show, createSignal} from "solid-js";
2 | import {makeUrl} from "./App";
3 | import youtubeIcon from "./assets/youtube.png";
4 | import touhoudbIcon from "./assets/touhoudb.jpg";
5 | import noImage from "./assets/noimage.png";
6 |
7 | async function fetchMediainfo() {
8 | return await fetch(makeUrl("http", "/mediainfo"))
9 | .then((v) => v.json())
10 | .catch(() => {});
11 | }
12 |
13 | function snakeCaseToTitleCase(str: string) {
14 | return str
15 | .split("_")
16 | .map((v) => v.charAt(0).toUpperCase() + v.slice(1))
17 | .join(" ");
18 | }
19 |
20 | // rotate between foo and bar to actually update the image when url changes
21 | function makeImageUrl(filename: string) {
22 | return makeUrl("http", `/album_art?n=${encodeURIComponent(filename)}`);
23 | }
24 |
25 | export function Mediainfo() {
26 | const [mediainfo, setMediainfo] = createSignal>>(null);
27 |
28 | // const ws = new WebSocket("wss://" + window.location.host + "/mediainfo/ws");
29 | const ws = new WebSocket(makeUrl("ws", "/mediainfo/ws"));
30 | ws.onmessage = async () => {
31 | setMediainfo(await fetchMediainfo());
32 | document.title = "Now playing: " + joinTitleDate(bestTitleData(mediainfo()![0]));
33 | };
34 |
35 | ws.onmessage(null as any);
36 |
37 | const lastSong = () => {
38 | return mediainfo()![0];
39 | };
40 |
41 | return (
42 |
64 | );
65 | }
66 |
67 | function MediainfoEntry(props: {key: string; value: string}) {
68 | const value = () => {
69 | if (!props.value) {
70 | return "N/A";
71 | } else if (props.key === "bitrate") {
72 | return Math.floor(+props.value / 1000) + "kbps";
73 | }
74 | return props.value;
75 | };
76 | return (
77 |
78 | {snakeCaseToTitleCase(props.key)}:
79 | {value()}
80 |
81 | );
82 | }
83 |
84 | function bestTitleData(mediainfo: Record): {title: string; artist: string | null} {
85 | let title = null;
86 | if (mediainfo.title) {
87 | title = mediainfo.title;
88 | } else if (mediainfo.publisher) {
89 | title = mediainfo.publisher;
90 | } else {
91 | title = mediainfo.filename;
92 | }
93 |
94 | let artist = null;
95 | if (mediainfo.album_artist) {
96 | artist = mediainfo.album_artist;
97 | } else if (mediainfo.artist) {
98 | artist = mediainfo.artist;
99 | }
100 |
101 | return {title, artist};
102 | }
103 |
104 | function joinTitleDate(data: {title: string; artist: string | null}) {
105 | return (data.artist ? data.artist + " - " : "") + data.title;
106 | }
107 |
108 | function MediaLinks(props: {mediainfo: Record}) {
109 | const youtube = () => {
110 | const titleData = bestTitleData(props.mediainfo);
111 | const query = joinTitleDate(titleData);
112 | const url = `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`;
113 | return {url, query};
114 | };
115 | const touhoudb = () => {
116 | const {title} = bestTitleData(props.mediainfo);
117 | const url = `https://touhoudb.com/Search?searchType=Song&filter=${encodeURIComponent(title)}`;
118 | return {url, query: title};
119 | };
120 | return (
121 |
129 | );
130 | }
131 |
--------------------------------------------------------------------------------
/src/cmd.rs:
--------------------------------------------------------------------------------
1 | use std::{
2 | path::{Path, PathBuf},
3 | process::Stdio,
4 | };
5 |
6 | use serde::{Deserialize, Serialize};
7 | use tokio::process::Command;
8 |
9 | pub fn check_executables() -> (bool, Vec<(String, bool)>) {
10 | let info = ["ffmpeg", "ffprobe"]
11 | .into_iter()
12 | .map(|x| {
13 | (x.to_string(), { std::process::Command::new(x).spawn().map(|mut x| x.kill()).is_ok() })
14 | })
15 | .collect::>();
16 | (info.iter().all(|x| x.1), info)
17 | }
18 |
19 | #[allow(clippy::option_if_let_else)]
20 | pub fn spawn_ffmpeg(
21 | input: &Path,
22 | sweeper: Option<&Path>,
23 | bitrate_bps: u32,
24 | copy_codec: bool,
25 | ) -> tokio::process::Child {
26 | if let Some(sweeper) = sweeper {
27 | build_with_sweeper(input, sweeper, bitrate_bps)
28 | } else {
29 | build_without_sweeper(input, bitrate_bps, copy_codec)
30 | }
31 | .kill_on_drop(true)
32 | .spawn()
33 | .unwrap()
34 | }
35 |
36 | fn build_without_sweeper(input: &Path, bitrate_bps: u32, copy_codec: bool) -> Command {
37 | let mut cmd = Command::new("ffmpeg");
38 | cmd.args(["-hide_banner", "-loglevel", "fatal"])
39 | .args(["-re", "-threads", "1", "-i"])
40 | .arg(input);
41 | if copy_codec {
42 | cmd.args(["-c:a", "copy"]);
43 | } else {
44 | cmd.args(["-c:a", "mp3", "-b:a", &bitrate_bps.to_string()]);
45 | }
46 | cmd.args([
47 | "-write_xing",
48 | "0",
49 | "-id3v2_version",
50 | "0",
51 | "-map_metadata",
52 | "-1",
53 | "-vn",
54 | // this speeds up encoding a little for some reason
55 | "-map",
56 | "0:a",
57 | "-f",
58 | "mp3",
59 | "-",
60 | ])
61 | .stdout(Stdio::piped())
62 | .stderr(Stdio::piped())
63 | .stdin(Stdio::null());
64 | cmd
65 | }
66 |
67 | // temporarily permanent i think
68 | pub const SWEEPER_DIR: &str = "./sweepers";
69 |
70 | pub fn build_with_sweeper(
71 | input: impl AsRef,
72 | sweeper: impl AsRef,
73 | bitrate_bps: u32,
74 | ) -> Command {
75 | let mut cmd = Command::new("ffmpeg");
76 | cmd.args(["-hide_banner", "-loglevel", "fatal"])
77 | .args(["-re", "-threads", "1"])
78 | .arg("-i")
79 | .arg(input.as_ref())
80 | .arg("-i")
81 | .arg(sweeper.as_ref())
82 | .args(["-c:a", "mp3", "-b:a", &bitrate_bps.to_string()]);
83 | cmd.args([
84 | "-write_xing",
85 | "0",
86 | "-id3v2_version",
87 | "0",
88 | "-map_metadata",
89 | "-1",
90 | "-vn",
91 | "-filter_complex",
92 | "[0]atrim=0:1[in];[1]adelay=1s:all=1[voice];[in][voice][0]amix=inputs=3:weights='1, 1, 0.1':dropout_transition=0.5[out]",
93 | "-map",
94 | "[out]",
95 | "-f",
96 | "mp3",
97 | "-",
98 | ])
99 | .stdout(Stdio::piped())
100 | .stderr(Stdio::piped())
101 | .stdin(Stdio::null());
102 | cmd
103 | }
104 |
105 | #[derive(Debug, Clone, Serialize, Deserialize, Default)]
106 | pub struct Mediainfo {
107 | pub filename: PathBuf,
108 | pub title: Option,
109 | pub album: Option,
110 | pub artist: Option,
111 | pub album_artist: Option,
112 | pub publisher: Option,
113 | pub disc: Option,
114 | pub track: Option,
115 | pub genre: Option,
116 | pub bitrate: Option,
117 | pub codec: String,
118 | }
119 |
120 | pub async fn mediainfo(input: &Path) -> Result {
121 | let child = Command::new("ffprobe")
122 | .args([
123 | "-loglevel",
124 | "fatal",
125 | "-select_streams",
126 | "a:0",
127 | "-show_entries",
128 | "format_tags:stream=codec_name,bit_rate:format=filename,bit_rate",
129 | "-of",
130 | "json=c=1",
131 | ])
132 | .arg(input)
133 | .stdout(Stdio::piped())
134 | .stderr(Stdio::piped())
135 | .stdin(Stdio::null())
136 | .spawn()
137 | .unwrap();
138 | let output = child.wait_with_output().await.unwrap();
139 |
140 | if !output.status.success() {
141 | return Err(format!("ffprobe failed: {}", String::from_utf8_lossy(&output.stderr)));
142 | }
143 |
144 | #[derive(Deserialize)]
145 | struct P {
146 | streams: [PStreams; 1],
147 | format: PFormat,
148 | }
149 |
150 | #[derive(Deserialize)]
151 | struct PStreams {
152 | codec_name: String,
153 | bit_rate: Option,
154 | }
155 |
156 | #[derive(Deserialize)]
157 | struct PFormat {
158 | filename: PathBuf,
159 | bit_rate: Option,
160 | tags: Option,
161 | }
162 |
163 | #[derive(Deserialize)]
164 | pub struct PMediainfo {
165 | #[serde(alias = "TITLE")]
166 | pub title: Option,
167 | #[serde(alias = "ALBUM")]
168 | pub album: Option,
169 | #[serde(alias = "ARTIST")]
170 | pub artist: Option,
171 | #[serde(alias = "ALBUM_ARTIST")]
172 | pub album_artist: Option,
173 | #[serde(alias = "PUBLISHER")]
174 | pub publisher: Option,
175 | #[serde(alias = "DISC")]
176 | pub disc: Option,
177 | #[serde(alias = "TRACK")]
178 | pub track: Option,
179 | #[serde(alias = "GENRE")]
180 | pub genre: Option,
181 | }
182 |
183 | let output: P = match serde_json::from_str(&String::from_utf8_lossy(&output.stdout)) {
184 | Ok(x) => x,
185 | Err(e) => {
186 | return Err(format!("ffprobe failed for {}: {}", input.display(), e));
187 | }
188 | };
189 |
190 | let [stream] = output.streams;
191 | let Some(tags) = output.format.tags else {
192 | return Ok(Mediainfo {
193 | filename: output.format.filename.file_name().unwrap_or_default().into(),
194 | title: None,
195 | album: None,
196 | artist: None,
197 | album_artist: None,
198 | publisher: None,
199 | disc: None,
200 | track: None,
201 | genre: None,
202 | bitrate: stream.bit_rate.or(output.format.bit_rate).and_then(|x| x.parse().ok()),
203 | codec: stream.codec_name,
204 | });
205 | };
206 | Ok(Mediainfo {
207 | filename: output.format.filename.file_name().unwrap_or_default().into(),
208 | title: tags.title,
209 | album: tags.album,
210 | artist: tags.artist,
211 | album_artist: tags.album_artist,
212 | publisher: tags.publisher,
213 | disc: tags.disc,
214 | track: tags.track,
215 | genre: tags.genre,
216 | bitrate: stream.bit_rate.or(output.format.bit_rate).and_then(|x| x.parse().ok()),
217 | codec: stream.codec_name,
218 | })
219 | }
220 |
221 | /// returns true if album art was found
222 | /// sets length to 0 if no art is found
223 | pub async fn album_art_png(input: &Path) -> Result