mirror of
https://github.com/CDrummond/bliss-analyser.git
synced 2026-10-22 22:32:08 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d42f28d1f2 | ||
|
|
0cf766cfeb | ||
|
|
3f997b979b | ||
|
|
39229efd99 | ||
|
|
50c564cde0 | ||
|
|
0202c68579 | ||
|
|
f27804a2bb | ||
|
|
9a7f316419 | ||
|
|
bc68f73de9 | ||
|
|
5a11b8b3e5 | ||
|
|
b1508d27c5 | ||
|
|
172bcac11a | ||
|
|
f6d99afd9e | ||
|
|
9d4db09660 | ||
|
|
f6d15c41c5 | ||
|
|
30d5b15f08 | ||
|
|
23d491db55 | ||
|
|
50331e518a | ||
|
|
d046add1ea | ||
|
|
8a26eea76a | ||
|
|
cb4e7e97f9 | ||
|
|
bb4c268f82 | ||
|
|
e7a9156289 | ||
|
|
8a54a38673 | ||
|
|
6af1865691 | ||
|
|
75955c15cb | ||
|
|
4f74a6ea58 | ||
|
|
9709909378 | ||
|
|
b8a2f248af | ||
|
|
7bcc176ac2 | ||
|
|
e488903d7f | ||
|
|
2d2ddc7b17 | ||
|
|
9e107c5fc2 | ||
|
|
4714f8f4ef | ||
|
|
fd38256dc0 | ||
|
|
b4eb7562cf | ||
|
|
c7d436c13b | ||
|
|
e04b22ebcc | ||
|
|
6055fa2af7 | ||
|
|
0a1b9d8790 | ||
|
|
e395ff515b | ||
|
|
4794a9b747 | ||
|
|
b623c1939b | ||
|
|
de232d2a33 | ||
|
|
4830ef92b3 | ||
|
|
520190c52e | ||
|
|
d4f32d8b4d | ||
|
|
0250117b15 | ||
|
|
d2b3db43f7 | ||
|
|
2759f7f47c | ||
|
|
a79712cb24 | ||
|
|
5722b04c85 | ||
|
|
50a55ee61a | ||
|
|
b5c9356ef2 | ||
|
|
9d9117e8f7 | ||
|
|
417ac5f652 | ||
|
|
342440f04b | ||
|
|
85a2e985bf | ||
|
|
c999297430 | ||
|
|
db9b12be3f | ||
|
|
2468a83d4b | ||
|
|
22149c2349 | ||
|
|
0dda8a17e7 | ||
|
|
a2ff1aade1 | ||
|
|
471063b53d | ||
|
|
94011d12ea | ||
|
|
2210c44614 | ||
|
|
00be6015f7 | ||
|
|
e4522cc683 | ||
|
|
5c7b33a5d3 | ||
|
|
6fd78df3ef | ||
|
|
f23202e956 | ||
|
|
7ea6c3befd | ||
|
|
302609ed1b | ||
|
|
5e5bd9841a | ||
|
|
32610f22b7 | ||
|
|
751dca7091 | ||
|
|
ac87ebb66c | ||
|
|
98b9cd61e0 | ||
|
|
786b7d2c2d | ||
|
|
1e00e9593a | ||
|
|
0d877fe79e | ||
|
|
6cc26c399e | ||
|
|
bff4ba18b4 | ||
|
|
6fc9ac7f2e | ||
|
|
3a387be3bf | ||
|
|
2cb7dc0fa0 | ||
|
|
b91c2edafd | ||
|
|
f559abb395 | ||
|
|
cca929a13e | ||
|
|
6dcd61d9bb | ||
|
|
f8ea9947ac | ||
|
|
b275568000 | ||
|
|
0249bf8edb | ||
|
|
6d5b7c80ef | ||
|
|
b078736224 | ||
|
|
a610dab7c0 | ||
|
|
05fb19e2f9 | ||
|
|
375abe91d0 | ||
|
|
f9984e8ca0 | ||
|
|
73d4ae72cd | ||
|
|
92c2a90d25 | ||
|
|
dbe36c35db | ||
|
|
decbbd18e5 | ||
|
|
36f44d6550 | ||
|
|
01be5d107a | ||
|
|
388e571571 | ||
|
|
5f247ef10f | ||
|
|
eb7214644a | ||
|
|
25362b9635 | ||
|
|
4a98282ce2 | ||
|
|
331d770d46 | ||
|
|
0d1c33b131 | ||
|
|
c9d72b3f71 | ||
|
|
bfd73ea0bc | ||
|
|
b643272d1f | ||
|
|
9321be0ad5 | ||
|
|
55f944fbdd | ||
|
|
d7d7c87582 | ||
|
|
4e6d522829 | ||
|
|
25f399f932 | ||
|
|
a5db8f48ce | ||
|
|
136651ada7 | ||
|
|
e5ef67f8c6 | ||
|
|
6b9cb960a9 | ||
|
|
c6f9a7faf5 | ||
|
|
2ac4b9d17f | ||
|
|
7071ea14e7 | ||
|
|
e23aba88e3 | ||
|
|
5fa745975c | ||
|
|
22133436b9 | ||
|
|
68ed1fc3b0 | ||
|
|
3b465543d7 | ||
|
|
56ed0b9045 | ||
|
|
62c956bc78 | ||
|
|
a880fff993 | ||
|
|
777d8e3c6c | ||
|
|
319f72d2a4 | ||
|
|
297ed3304e | ||
|
|
04baedbfc2 | ||
|
|
609b6faff9 | ||
|
|
fe65d35066 | ||
|
|
75aeee128f | ||
|
|
65e40acce4 | ||
|
|
a9f819f39b | ||
|
|
4704624b2f | ||
|
|
c3083d9a1a | ||
|
|
90d4dfec23 | ||
|
|
e89aee1f4e | ||
|
|
703e39508c | ||
|
|
eb16d8379d | ||
|
|
a220b4d0c5 | ||
|
|
6d61616dda | ||
|
|
d85424d27c | ||
|
|
144c8a7ffe | ||
|
|
ab0adc96ca | ||
|
|
7cf62c3ad0 | ||
|
|
d9841b3527 | ||
|
|
3b6872b2a0 | ||
|
|
45b20902d6 | ||
|
|
9feb80bbd1 | ||
|
|
b9b8fdabfc | ||
|
|
a818628706 | ||
|
|
370c533a49 | ||
|
|
9aae7825d0 | ||
|
|
ebddf8febe | ||
|
|
143fb2bf62 | ||
|
|
323978cbeb | ||
|
|
a749c03195 | ||
|
|
bfd2a44e2e | ||
|
|
943227c16b | ||
|
|
b7259cd0ae | ||
|
|
f023faa336 | ||
|
|
074be07d18 | ||
|
|
a12971df17 | ||
|
|
8ba10a0f8e | ||
|
|
cf0e6cf5fb | ||
|
|
de2c2dfaa3 | ||
|
|
1a45791e58 | ||
|
|
b5f35dcf36 | ||
|
|
cd12e805c3 | ||
|
|
fa87a7e135 | ||
|
|
df17032377 | ||
|
|
e47c2b477e | ||
|
|
133e3e2204 | ||
|
|
3dec74d328 | ||
|
|
cb323f3efe | ||
|
|
69303d62c6 | ||
|
|
f05e11da2e |
No files matched your search
+138
-22
@@ -7,19 +7,19 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
Linux:
|
||||
runs-on: ubuntu-20.04
|
||||
Linux_x86_symphonia:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v1
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Packages
|
||||
run: sudo apt-get update && sudo apt-get install build-essential yasm libavutil-dev libavcodec-dev libavformat-dev libavfilter-dev libavfilter-dev libavdevice-dev libswresample-dev libfftw3-dev ffmpeg
|
||||
|
||||
run: sudo apt-get update && sudo apt-get install build-essential yasm -y
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
cargo build --release
|
||||
cargo build --release --features=symphonia
|
||||
strip target/release/bliss-analyser
|
||||
mkdir releases
|
||||
cp target/release/bliss-analyser releases/bliss-analyser
|
||||
@@ -28,14 +28,79 @@ jobs:
|
||||
cp configs/linux.ini releases/config.ini
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bliss-analyser-linux
|
||||
name: bliss-analyser-linux-x86-symphonia
|
||||
path: releases/
|
||||
|
||||
|
||||
macOS:
|
||||
runs-on: macos-11.0
|
||||
Linux_x86_ffmpeg:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Packages
|
||||
run: sudo apt-get update && sudo apt-get install build-essential yasm -y
|
||||
|
||||
- name: Build x86 static-ffmpeg version
|
||||
run: |
|
||||
cargo build --release --features=libav,staticlibav
|
||||
strip target/release/bliss-analyser
|
||||
mkdir releases
|
||||
cp target/release/bliss-analyser releases/bliss-analyser
|
||||
cp UserGuide.md releases/README.md
|
||||
cp LICENSE releases/
|
||||
cp configs/linux.ini releases/config.ini
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bliss-analyser-linux-x86-ffmpeg
|
||||
path: releases/
|
||||
|
||||
|
||||
Linux_arm_symphonia:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
docker build --pull --no-cache -t bliss-analyser-cross - < docker/Dockerfile_Bullseye_symphonia
|
||||
docker run --rm -v $PWD/target:/build -v $PWD:/src bliss-analyser-cross
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bliss-analyser-linux-arm-symphonia
|
||||
path: releases/
|
||||
|
||||
|
||||
Linux_arm_ffmpeg:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build ARM static-ffmpeg on Debian
|
||||
run: |
|
||||
docker build -t bliss-analyser-cross - < docker/Dockerfile_Bullseye_static
|
||||
docker run --rm -v $PWD/target:/build -v $PWD:/src bliss-analyser-cross
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bliss-analyser-linux-arm-ffmpeg
|
||||
path: releases/
|
||||
|
||||
|
||||
macOS_symphonia:
|
||||
runs-on: macos-13
|
||||
|
||||
steps:
|
||||
- name: Install Rust
|
||||
@@ -48,27 +113,77 @@ jobs:
|
||||
|
||||
- name: Install deps
|
||||
run: |
|
||||
brew install ffmpeg@5
|
||||
brew install pkg-config
|
||||
|
||||
- name: Install Rust support for ARM64 & prepare environment
|
||||
run: |
|
||||
rustup target add aarch64-apple-darwin
|
||||
mkdir releases
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
mkdir releases
|
||||
cargo build --release
|
||||
cargo build --release --features update-aubio-bindings,symphonia
|
||||
strip target/release/bliss-analyser
|
||||
cp target/release/bliss-analyser releases/bliss-analyser
|
||||
cp target/release/bliss-analyser releases/bliss-analyser-x86_64
|
||||
cargo build --target=aarch64-apple-darwin --release --features update-aubio-bindings,symphonia
|
||||
strip target/aarch64-apple-darwin/release/bliss-analyser
|
||||
cp target/aarch64-apple-darwin/release/bliss-analyser releases/bliss-analyser-arm64
|
||||
cp UserGuide.md releases/README.md
|
||||
cp LICENSE releases/
|
||||
cp configs/macos.ini releases/config.ini
|
||||
|
||||
- name: Build fat binary
|
||||
run: |
|
||||
lipo -create \
|
||||
-arch x86_64 releases/bliss-analyser-x86_64 \
|
||||
-arch arm64 releases/bliss-analyser-arm64 \
|
||||
-output releases/bliss-analyser
|
||||
|
||||
- name: Remove unused binaries
|
||||
run:
|
||||
rm releases/bliss-analyser-x86_64 releases/bliss-analyser-arm64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bliss-analyser-mac
|
||||
name: bliss-analyser-mac-symphonia
|
||||
path: releases/
|
||||
|
||||
|
||||
Windows:
|
||||
runs-on: windows-2019
|
||||
Windows_symphonia:
|
||||
runs-on: windows-2022
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
cargo build --release --features=symphonia
|
||||
strip target/release/bliss-analyser.exe
|
||||
mkdir releases
|
||||
cp target/release/bliss-analyser.exe releases/bliss-analyser.exe
|
||||
cp UserGuide.md releases/README.md
|
||||
cp LICENSE releases/
|
||||
cp configs/windows.ini releases/config.ini
|
||||
cp c:\Windows\system32\vcruntime140.dll releases
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bliss-analyser-windows-symphonia
|
||||
path: releases/
|
||||
|
||||
|
||||
Windows_ffmpeg:
|
||||
runs-on: windows-2022
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -78,7 +193,7 @@ jobs:
|
||||
run: |
|
||||
$VCINSTALLDIR = $(& "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath)
|
||||
Add-Content $env:GITHUB_ENV "LIBCLANG_PATH=${VCINSTALLDIR}\VC\Tools\LLVM\x64\bin`n"
|
||||
Invoke-WebRequest "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full-shared.7z" -OutFile ffmpeg-shared.7z
|
||||
Invoke-WebRequest "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-7.1.1-full_build-shared.7z" -OutFile ffmpeg-shared.7z
|
||||
7z x ffmpeg-shared.7z
|
||||
mkdir deps
|
||||
mv ffmpeg-*/* deps/
|
||||
@@ -94,7 +209,8 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
cargo build --release
|
||||
cargo build --release --features=libav
|
||||
strip target/release/bliss-analyser.exe
|
||||
mkdir releases
|
||||
cp target/release/bliss-analyser.exe releases/bliss-analyser.exe
|
||||
cp deps/bin/*.dll releases/
|
||||
@@ -104,7 +220,7 @@ jobs:
|
||||
cp c:\Windows\system32\vcruntime140.dll releases
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bliss-analyser-windows
|
||||
name: bliss-analyser-windows-ffmpeg
|
||||
path: releases/
|
||||
Generated
+1137
-465
File diff suppressed because it is too large.
Load diff
+29
-7
@@ -1,27 +1,49 @@
|
||||
[package]
|
||||
name = "bliss-analyser"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
authors = ["Craig Drummond <craig.p.drummond@gmail.com>"]
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-only"
|
||||
description = "Analyse audio files with bliss-rs"
|
||||
repository = "https://github.com/CDrummond/bliss-analyser"
|
||||
keywords = ["audio", "song", "similarity"]
|
||||
readme = "README.md"
|
||||
|
||||
[profile.release]
|
||||
strip=true
|
||||
|
||||
[dependencies]
|
||||
bliss-audio = "0.5.0"
|
||||
argparse = "0.2.2"
|
||||
anyhow = "1.0.40"
|
||||
rusqlite = { version = "0.25.0", features = ["bundled"] }
|
||||
rusqlite = { version = "0.34.0", features = ["bundled"] }
|
||||
log = "0.4.14"
|
||||
env_logger = "0.8.4"
|
||||
env_logger = "0.11.7"
|
||||
indicatif = "0.16.2"
|
||||
lofty = "0.6.1"
|
||||
lofty = "0.22.2"
|
||||
filetime = "0.2"
|
||||
dirs = "1"
|
||||
chrono = "0.4.19"
|
||||
chrono = "0.4.40"
|
||||
regex = "1"
|
||||
substring = "1.4.5"
|
||||
ureq = "2.4.0"
|
||||
configparser = "3.0.0"
|
||||
if_chain = "1.0.2"
|
||||
num_cpus = "1.13.0"
|
||||
which = { version = "7.0.2", optional = true }
|
||||
rcue = { version = "0.1.3", optional = true }
|
||||
hhmmss = { version = "0.1.0", optional = true }
|
||||
ctrlc = "3.4"
|
||||
|
||||
[features]
|
||||
libav = ["bliss-audio/ffmpeg"]
|
||||
update-aubio-bindings = ["bliss-audio/update-aubio-bindings"]
|
||||
staticlibav = ["bliss-audio/build-ffmpeg", "bliss-audio/ffmpeg-static"]
|
||||
symphonia = ["bliss-audio/symphonia-all", "bliss-audio/symphonia-aiff", "bliss-audio/symphonia-alac"]
|
||||
rpi = ["bliss-audio/rpi"]
|
||||
|
||||
[dependencies.bliss-audio]
|
||||
default-features = false
|
||||
features = ["aubio-static"]
|
||||
version = "0.11.1"
|
||||
#git = "https://github.com/Polochon-street/bliss-rs.git"
|
||||
#rev = "006927ac16752ff2e00bfe0d6b7756f67fa822c0"
|
||||
@@ -1,8 +1,64 @@
|
||||
0.5.0
|
||||
-----
|
||||
1. Update to bliss-rs 0.11.1
|
||||
2. Only build static-libav and symphonia by default.
|
||||
3. Don't crash if file has no tags.
|
||||
4. Set log level for tag reader library.
|
||||
5. Multi-threaded read of existing analysis tags.
|
||||
6. Store file failures in DB so don't re-analyse unless timestamp of file
|
||||
changes.
|
||||
7. Drop support for using ffmpeg via commandline.
|
||||
|
||||
0.4.0
|
||||
-----
|
||||
1. Add action to export results from DB to files.
|
||||
2. Add option to preserve file modification time when writing tags.
|
||||
3. Store analysis in BLISS_ANALYSIS tag, not in a COMMENT tag.
|
||||
4. Fix reading of tags during analysis phase.
|
||||
5. Check for multiple genre tags, and if found add as semi-colon separated
|
||||
list to DB.
|
||||
6. When analysing files, always read tags (if present), but only write tags
|
||||
if '--tags' is specified on commandline.
|
||||
7. Use max of 16 significant digits (trailing zeros stripped) when writing
|
||||
analysis results to file tags.
|
||||
8. Strip symbols from release binaries.
|
||||
9. Gracefully handle Ctrl-C.
|
||||
|
||||
0.3.0
|
||||
-----
|
||||
1. Add support for (DSD) WavPack - thanks to Bart Lauret
|
||||
2. Update version of tag reader library.
|
||||
3. Update version of bliss library.
|
||||
4. Allow builds to either use dynamic ffmpeg libraries, static ffmpeg
|
||||
libraries, symphonia, or ffmpeg on commandline.
|
||||
5. Add ability to specify LMS JSONRPC port.
|
||||
6. If new files analysed and 'ignore' file exists then update DB's 'ignore'
|
||||
flags.
|
||||
7. Add option to write analysis results to files, and use for future scans.
|
||||
8. If log level set to 'trace' then set this level for the bliss library too.
|
||||
9. Enable support for '.dsf' files.
|
||||
|
||||
0.2.3
|
||||
-----
|
||||
1. Add option to limit number of concurrent threads.
|
||||
2. Update version of tag reader library.
|
||||
3. Update version of bliss library.
|
||||
|
||||
0.2.2
|
||||
-----
|
||||
1. Update version of tag reader library.
|
||||
2. Update version of bliss library.
|
||||
|
||||
0.2.1
|
||||
-----
|
||||
1. Update version of tag reader library.
|
||||
2. Fix checking if CUE already analysed.
|
||||
|
||||
0.2.0
|
||||
-----
|
||||
1. Tidy up code, thanks to Serial-ATA
|
||||
2. Update version of tag reader library, should now support ID3v2 in FLAC.
|
||||
3. Show error message if can't open, or create, database file.
|
||||
3. Show error message if can't open, or create, DB file.
|
||||
4. Update version of bliss-rs, this now handles CUE processing internally.
|
||||
|
||||
0.1.0
|
||||
|
||||
@@ -7,7 +7,25 @@ bliss analysis. This is then intended to be used by [Bliss Mixer](https://github
|
||||
|
||||
# Building
|
||||
|
||||
clang, pkg-config, and ffmpeg are required to build, as well as
|
||||
This application can be built in 4 variants:
|
||||
|
||||
1. Using `libavcodec`, etc, to decode files.
|
||||
2. Using `libavcodec`, etc, to decode files, but statically linked to `libavcodec`, etc.
|
||||
3. Using `symphonia` to decode files.
|
||||
|
||||
|
||||
`libavcodec` is the fastest (~15% faster than `symphonia`), but might have issues with
|
||||
library, versioning, etc., unless these libraries are statically linked in. `libavcodec`
|
||||
statically linked may reduce supported file formats, but is more portable.
|
||||
file formats, but is more portable.
|
||||
|
||||
`symphonia` also produces a more portable application, is only slightly slower to decode
|
||||
files, but has more limited codec support, and can fail on more files.
|
||||
|
||||
|
||||
## Build for 'libavcodec' library usage
|
||||
|
||||
`clang`, `pkg-config`, and `ffmpeg` are required to build, as well as
|
||||
[Rust](https://www.rust-lang.org/tools/install)
|
||||
|
||||
To install dependencies on a Debian system:
|
||||
@@ -16,7 +34,63 @@ To install dependencies on a Debian system:
|
||||
apt install -y clang libavcodec-dev libavformat-dev libavutil-dev libavfilter-dev libavdevice-dev pkg-config
|
||||
```
|
||||
|
||||
Build with `cargo build --release`
|
||||
To install dependencies on a Fedora system:
|
||||
```
|
||||
dnf install ffmpeg-devel clang pkg-config
|
||||
```
|
||||
|
||||
Build with `cargo build --release --features=libav`
|
||||
|
||||
If building on a Raspberry Pi, then `rpi` also needs to be passed to `--features`, e.g.
|
||||
`cargo build --release --features=libav,rpi`
|
||||
|
||||
The resultant application will be less portable, due to dependencies on `libavcodec` libraries (and
|
||||
their dependencies).
|
||||
|
||||
|
||||
|
||||
## Build for 'libavcodec' library usage, statically linked
|
||||
|
||||
`clang`, `pkg-config`, and `ffmpeg` are required to build, as well as
|
||||
[Rust](https://www.rust-lang.org/tools/install)
|
||||
|
||||
To install dependencies on a Debian system:
|
||||
|
||||
```
|
||||
apt install -y clang libavcodec-dev libavformat-dev libavutil-dev libavfilter-dev libavdevice-dev pkg-config
|
||||
```
|
||||
|
||||
To install dependencies on a Fedora system:
|
||||
```
|
||||
dnf install ffmpeg-devel clang pkg-config
|
||||
```
|
||||
|
||||
Build with `cargo build --release --features=libav,staticlibav`
|
||||
|
||||
If building on a Raspberry Pi, then `rpi` also needs to be passed to `--features`, e.g.
|
||||
`cargo build --release --features=libav,staticlibav,rpi`
|
||||
|
||||
|
||||
|
||||
## Build for 'symphonia'
|
||||
|
||||
`clang`, and `pkg-config` are required to build, as well as
|
||||
[Rust](https://www.rust-lang.org/tools/install)
|
||||
|
||||
To install dependencies on a Debian system:
|
||||
|
||||
```
|
||||
apt install -y clang pkg-config
|
||||
```
|
||||
|
||||
To install dependencies on a Fedora system:
|
||||
```
|
||||
dnf install clang pkg-config
|
||||
```
|
||||
|
||||
Build with `cargo build --release --features=symphonia`
|
||||
|
||||
|
||||
|
||||
|
||||
# Usage
|
||||
|
||||
+95
-61
@@ -10,14 +10,13 @@ feature.
|
||||
PowerShell for Windows), as there is no graphical user interface.
|
||||
|
||||
|
||||
|
||||
Quick guide
|
||||
===========
|
||||
|
||||
1. Install the `Bliss Mixer` LMS plugin.
|
||||
|
||||
2. Install ffmpeg if using Linux or macOS.
|
||||
|
||||
3. Edit the supplied `config.ini` in the current folder to set appropiate values
|
||||
2. Edit the supplied `config.ini` in the current folder to set appropriate values
|
||||
for `music` and `lms` - e.g.:
|
||||
```
|
||||
[Bliss]
|
||||
@@ -25,65 +24,19 @@ music=/home/user/Music
|
||||
lms=127.0.0.1
|
||||
```
|
||||
|
||||
4. Analyse your tracks:
|
||||
3. Analyse your tracks:
|
||||
```
|
||||
./bliss-analyser analyse
|
||||
```
|
||||
|
||||
5. Upload analysis database to LMS:
|
||||
4. Upload analysis database to LMS:
|
||||
```
|
||||
./bliss-analyser upload
|
||||
```
|
||||
|
||||
6. Set LMS to use `Bliss` in `Don't Stop the Music`
|
||||
|
||||
7. Play some music!
|
||||
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
For Windows no extra installation steps are required, as all dependencies are
|
||||
bundled within its ZIP file. However, both the Linux and macOS versions require
|
||||
that `ffmpeg` be installed.
|
||||
|
||||
|
||||
Linux
|
||||
-----
|
||||
|
||||
Debian based systems (e.g. Ubuntu):
|
||||
```
|
||||
sudo apt install ffmpeg
|
||||
```
|
||||
|
||||
RedHat based systems (e.g. Fedora):
|
||||
```
|
||||
sudo yum install ffmpeg
|
||||
```
|
||||
|
||||
|
||||
macOS
|
||||
-----
|
||||
|
||||
First install `HomeBrew`
|
||||
|
||||
High Sierra, Sierra, El Capitan, or earlier:
|
||||
```
|
||||
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
|
||||
Otherwise:
|
||||
```
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
|
||||
Then install `ffmpeg`:
|
||||
```
|
||||
brew install ffmpeg@5
|
||||
brew link ffmpeg@5
|
||||
```
|
||||
5. Set LMS to use `Bliss` in `Don't Stop the Music`
|
||||
|
||||
6. Play some music!
|
||||
|
||||
|
||||
Configuration
|
||||
@@ -100,6 +53,8 @@ music=/home/user/Music
|
||||
db=bliss.db
|
||||
lms=127.0.0.1
|
||||
ignore=ignore.txt
|
||||
readtags=true
|
||||
writetags=true
|
||||
```
|
||||
|
||||
The following items are supported:
|
||||
@@ -109,10 +64,18 @@ music folders may be specified via `music_1`, `music_2`, `music_3`, and `music_4
|
||||
* `db` specifies the name and location of the database file used to store the
|
||||
analysis results. This will default to `bliss.db` in the current folder.
|
||||
* `lms` specifies the hostname, or IP address, of your LMS server. This is used
|
||||
when uploading the database file to LMS. This defaults to `127.0.0.1`
|
||||
when uploading the database file to LMS. This defaults to `127.0.0.1` If your LMS is
|
||||
password protected then use `user:pass@server` - e.g. `lms=pi:abc123@127.0.0.1`
|
||||
* `json` specifies the JSONRPC port number of your LMS server. This will default
|
||||
to `9000`.
|
||||
* `ignore` specifies the name and location of a file containing items to ignore
|
||||
in mixes. See the `Ignore` section later on for more details.
|
||||
|
||||
* `readtags` specifies whether analysis results should be read from files whe
|
||||
analysing.
|
||||
* `writetags` specifies whether analysis results should be written to files when
|
||||
analysed.
|
||||
* `preserve` specifies whether file modification time should be preserved when
|
||||
writing tags. Set to `true` or `false`.
|
||||
|
||||
|
||||
Command-line parameters
|
||||
@@ -133,22 +96,32 @@ analysis will be performed, instead the logging will inform you how many new
|
||||
tracks are to be analysed and how many old tracks are left in the database.
|
||||
* `-i` / `--ignore` Name and location of the file containing items to ignore.
|
||||
* `-L` / `--lms` Hostname, or IP address, of your LMS server.
|
||||
* `-J` / `--json` JSONRPC port number of your LMS server.
|
||||
* `-n` / `--numtracks` Specify maximum number of tracks to analyse.
|
||||
* `-R` / `--readtags` When using the `analyse` task, read analysis results from a
|
||||
files a `BLISS_ANALYSIS` tag (it present) in files.
|
||||
* `-W` / `--writetags` When using the `analyse` task, write analysis results to files
|
||||
within a `BLISS_ANALYSIS` tag.
|
||||
* `-p' / '--preserve` Attempt to preserve file modification time when writing tags.
|
||||
|
||||
Equivalent items specied in the INI config file (detailed above) will override
|
||||
any specified on the commandline.
|
||||
Equivalent items specified in the INI config file (detailed above) will override
|
||||
any specified on the command-line.
|
||||
|
||||
`bliss-analyser` requires one extra parameter, which is used to determine the
|
||||
required task. This takes the following values:
|
||||
|
||||
* `analyse` Performs analysis of tracks.
|
||||
* `upload` Uploads the database to LMS.
|
||||
* `stopmixer` Asks LMS plugin to stop it instance of `bliss-mixer`
|
||||
* `stopmixer` Asks LMS plugin to stop its instance of `bliss-mixer`
|
||||
* `tags` Re-reads tags from your music collection, and updates the database for
|
||||
any changes.
|
||||
* `ignore` Reads the `ignore` file and updates the database to flag tracks as
|
||||
to be ignored for mixes.
|
||||
|
||||
* `export` Exports tags from database and stores within the audio files.
|
||||
* `clearfail` If analysis fails for a track then the track path and its modification
|
||||
time is stored in the database, so that running analysis again ignores these files.
|
||||
This action clears this information, so that an analysis run will attempt to analyse
|
||||
these files.
|
||||
|
||||
|
||||
Analysing tracks
|
||||
@@ -176,11 +149,28 @@ is shown.
|
||||
As a rough guide, a 2015-era i7 8-core laptop with SSD analyses around 14000
|
||||
tracks/hour.
|
||||
|
||||
If analysis fails for a track, then the track's path and timestamp are stored
|
||||
within the database so that subsquent analysis calls will skip these tracks.
|
||||
|
||||
|
||||
Tags
|
||||
----
|
||||
When analysing tracks, the analyser will first check for a `BLISS_ANALYSIS` tag
|
||||
within the file, and if found this will be used instead of re-analysing the file.
|
||||
|
||||
If `--writetags` is passed, the analysis results will be stored within a
|
||||
`BLISS_ANALYSIS` tag in the file itself. Note, however, that only tracks that are
|
||||
not currently in the database will be analysed - therefore any such tracks would
|
||||
not have the `BLISS_ANALYSIS` tag updated. To export analysis results from the
|
||||
database to files themselves use the `export` task.
|
||||
|
||||
*NOTE* Use of the `BLISS_ANALYSIS` tag is not supported for CUE files.
|
||||
|
||||
|
||||
CUE files
|
||||
---------
|
||||
|
||||
If the anlyser encounters an audio file with a matching CUE file (e.g.
|
||||
If the analyser encounters an audio file with a matching CUE file (e.g.
|
||||
`album.flac` and `album.cue` in same folder) then it will attempt to analyse the
|
||||
individual tracks contained within.
|
||||
|
||||
@@ -301,7 +291,7 @@ The SQL LIKE lines do sub-string matching. So '%Dance%' will match any genre
|
||||
string that contains 'Dance' - e.g. 'Classical Dance'. The 4 lines with 'Rock'
|
||||
show how you can explicitly look for an exact match. The 1st line means 'Rock'
|
||||
is the only genre, 2nd means 'Rock' is the first genre, 3rd means 'Rock' is the
|
||||
last genre, and 4th means 'Rock' is amongs other genres.
|
||||
last genre, and 4th means 'Rock' is amongst other genres.
|
||||
|
||||
Assuming `config.ini` is in the current folder and contains valid entries, this
|
||||
is accomplished as follows:
|
||||
@@ -318,6 +308,50 @@ is accomplished as follows:
|
||||
|
||||
|
||||
|
||||
Exporting Analysis
|
||||
==================
|
||||
|
||||
If you have analysis results stored within the database, and not within
|
||||
the files themselves, then you can use the `export` action to copy these
|
||||
analysis results from the database and into the files.
|
||||
|
||||
(Linux / macOS)
|
||||
```
|
||||
./bliss-analyser export
|
||||
```
|
||||
|
||||
(Windows)
|
||||
```
|
||||
.\bliss-analyser.exe export
|
||||
```
|
||||
|
||||
*NOTE* Exporting of analysis results is not implemented for CUE tracks.
|
||||
|
||||
|
||||
|
||||
Clearing Failed Tracks
|
||||
======================
|
||||
|
||||
Any tracks that could not be analysed are stored in the database so that
|
||||
subsequent analysis runs ignore such files - unless the files themselves
|
||||
have been updated.
|
||||
|
||||
You can remove this list of failed tracks, from database, via the
|
||||
following command:
|
||||
|
||||
|
||||
(Linux / macOS)
|
||||
```
|
||||
./bliss-analyser clearfail
|
||||
```
|
||||
|
||||
(Windows)
|
||||
```
|
||||
.\bliss-analyser.exe clearfail
|
||||
```
|
||||
|
||||
|
||||
|
||||
Credits
|
||||
=======
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Cross compilation environment for bliss-analyser
|
||||
|
||||
FROM debian:bookworm
|
||||
|
||||
RUN dpkg --add-architecture arm64 && \
|
||||
dpkg --add-architecture armhf
|
||||
RUN apt-get update
|
||||
|
||||
RUN apt-get install -y curl git yasm
|
||||
RUN apt-get install -y build-essential clang
|
||||
RUN apt-get install -y crossbuild-essential-armhf crossbuild-essential-arm64
|
||||
|
||||
RUN apt-get install -y pkg-config:arm64 \
|
||||
libavutil-dev:arm64 libavcodec-dev:arm64 libavformat-dev:arm64 \
|
||||
libavfilter-dev:arm64 libavdevice-dev:arm64 libswresample-dev:arm64 libfftw3-dev:arm64
|
||||
|
||||
RUN apt-get install -y pkg-config:armhf \
|
||||
libavutil-dev:armhf libavcodec-dev:armhf libavformat-dev:armhf \
|
||||
libavfilter-dev:armhf libavdevice-dev:armhf libswresample-dev:armhf libfftw3-dev:armhf
|
||||
|
||||
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
|
||||
ENV PATH="/root/.cargo/bin/:${PATH}"
|
||||
RUN rustup target add aarch64-unknown-linux-gnu && \
|
||||
rustup target add arm-unknown-linux-gnueabihf
|
||||
|
||||
RUN mkdir /.cargo && \
|
||||
echo '[target.aarch64-unknown-linux-gnu]\nlinker = "aarch64-linux-gnu-gcc"' > /.cargo/config && \
|
||||
echo '[target.arm-unknown-linux-gnueabihf]\nlinker = "arm-linux-gnueabihf-gcc"' >> /.cargo/config
|
||||
|
||||
RUN mkdir /build
|
||||
ENV CARGO_TARGET_DIR /build
|
||||
ENV CARGO_HOME /build/cache
|
||||
|
||||
RUN mkdir /src
|
||||
|
||||
WORKDIR /src
|
||||
CMD ["/src/docker/docker-build-arm-libav.sh"]
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Cross compilation environment for bliss-anlyser
|
||||
|
||||
FROM debian:bullseye
|
||||
|
||||
RUN dpkg --add-architecture arm64 && \
|
||||
dpkg --add-architecture armhf
|
||||
RUN apt-get update
|
||||
|
||||
RUN apt-get install -y curl git pkg-config yasm
|
||||
RUN apt-get install -y build-essential clang
|
||||
RUN apt-get install -y crossbuild-essential-armhf crossbuild-essential-arm64
|
||||
|
||||
RUN apt-get install -y libavutil-dev:arm64 libavcodec-dev:arm64 libavformat-dev:arm64 \
|
||||
libavfilter-dev:arm64 libavdevice-dev:arm64 libswresample-dev:arm64 libfftw3-dev:arm64
|
||||
|
||||
RUN apt-get install -y libavutil-dev:armhf libavcodec-dev:armhf libavformat-dev:armhf \
|
||||
libavfilter-dev:armhf libavdevice-dev:armhf libswresample-dev:armhf libfftw3-dev:armhf
|
||||
|
||||
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
|
||||
ENV PATH="/root/.cargo/bin/:${PATH}"
|
||||
RUN rustup target add aarch64-unknown-linux-gnu && \
|
||||
rustup target add arm-unknown-linux-gnueabihf
|
||||
|
||||
RUN mkdir /.cargo && \
|
||||
echo '[target.aarch64-unknown-linux-gnu]\nlinker = "aarch64-linux-gnu-gcc"' > /.cargo/config && \
|
||||
echo '[target.arm-unknown-linux-gnueabihf]\nlinker = "arm-linux-gnueabihf-gcc"' >> /.cargo/config
|
||||
|
||||
RUN mkdir /build
|
||||
ENV CARGO_TARGET_DIR /build
|
||||
ENV CARGO_HOME /build/cache
|
||||
|
||||
RUN mkdir /src
|
||||
|
||||
WORKDIR /src
|
||||
CMD ["/src/docker/docker-build-arm-libav.sh"]
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Cross compilation environment for bliss-anlyser
|
||||
|
||||
FROM debian:bullseye
|
||||
|
||||
RUN dpkg --add-architecture arm64 && \
|
||||
dpkg --add-architecture armhf
|
||||
RUN apt-get update
|
||||
|
||||
RUN apt-get install -y curl git pkg-config yasm
|
||||
RUN apt-get install -y build-essential clang
|
||||
RUN apt-get install -y crossbuild-essential-armhf crossbuild-essential-arm64
|
||||
|
||||
RUN apt-get install -y libavutil-dev:arm64 libavcodec-dev:arm64 libavformat-dev:arm64 \
|
||||
libavfilter-dev:arm64 libavdevice-dev:arm64 libswresample-dev:arm64 libfftw3-dev:arm64
|
||||
|
||||
RUN apt-get install -y libavutil-dev:armhf libavcodec-dev:armhf libavformat-dev:armhf \
|
||||
libavfilter-dev:armhf libavdevice-dev:armhf libswresample-dev:armhf libfftw3-dev:armhf
|
||||
|
||||
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
|
||||
ENV PATH="/root/.cargo/bin/:${PATH}"
|
||||
RUN rustup target add aarch64-unknown-linux-gnu && \
|
||||
rustup target add arm-unknown-linux-gnueabihf
|
||||
|
||||
RUN mkdir /.cargo && \
|
||||
echo '[target.aarch64-unknown-linux-gnu]\nlinker = "aarch64-linux-gnu-gcc"' > /.cargo/config && \
|
||||
echo '[target.arm-unknown-linux-gnueabihf]\nlinker = "arm-linux-gnueabihf-gcc"' >> /.cargo/config
|
||||
|
||||
RUN mkdir /build
|
||||
ENV CARGO_TARGET_DIR /build
|
||||
ENV CARGO_HOME /build/cache
|
||||
|
||||
RUN mkdir /src
|
||||
|
||||
WORKDIR /src
|
||||
CMD ["/src/docker/docker-build-arm-staticlibav.sh"]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Cross compilation environment for bliss-anlyser
|
||||
|
||||
FROM debian:bullseye
|
||||
|
||||
RUN dpkg --add-architecture arm64 && \
|
||||
dpkg --add-architecture armhf
|
||||
RUN apt-get update
|
||||
|
||||
RUN apt-get install -y curl git pkg-config yasm
|
||||
RUN apt-get install -y build-essential clang
|
||||
RUN apt-get install -y crossbuild-essential-armhf crossbuild-essential-arm64
|
||||
|
||||
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
|
||||
ENV PATH="/root/.cargo/bin/:${PATH}"
|
||||
RUN rustup target add aarch64-unknown-linux-gnu && \
|
||||
rustup target add arm-unknown-linux-gnueabihf
|
||||
|
||||
RUN mkdir /.cargo && \
|
||||
echo '[target.aarch64-unknown-linux-gnu]\nlinker = "aarch64-linux-gnu-gcc"' > /.cargo/config && \
|
||||
echo '[target.arm-unknown-linux-gnueabihf]\nlinker = "arm-linux-gnueabihf-gcc"' >> /.cargo/config
|
||||
|
||||
RUN mkdir /build
|
||||
ENV CARGO_TARGET_DIR /build
|
||||
ENV CARGO_HOME /build/cache
|
||||
|
||||
RUN mkdir /src
|
||||
|
||||
WORKDIR /src
|
||||
CMD ["/src/docker/docker-build-arm-symphonia.sh"]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Cross compilation environment for bliss-anlyser
|
||||
|
||||
FROM ubuntu:24.04
|
||||
|
||||
RUN apt-get update
|
||||
|
||||
RUN apt-get install -y curl git pkg-config
|
||||
RUN apt-get install -y build-essential yasm clang
|
||||
|
||||
RUN apt-get install -y libavutil-dev libavcodec-dev libavformat-dev \
|
||||
libavfilter-dev libavdevice-dev libswresample-dev libfftw3-dev \
|
||||
ffmpeg
|
||||
|
||||
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
|
||||
ENV PATH="/root/.cargo/bin/:${PATH}"
|
||||
|
||||
RUN mkdir /build
|
||||
ENV CARGO_TARGET_DIR /build
|
||||
ENV CARGO_HOME /build/cache
|
||||
|
||||
RUN mkdir /src
|
||||
|
||||
WORKDIR /src
|
||||
CMD ["/src/docker/docker-build-x86-libav.sh"]
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
## #!/usr/bin/env bash
|
||||
set -eux
|
||||
|
||||
uname -a
|
||||
DESTDIR=/src/releases
|
||||
|
||||
mkdir -p $DESTDIR/bin
|
||||
rm -rf $DESTDIR/bin/*
|
||||
|
||||
function build {
|
||||
echo Building for $1 to $3...
|
||||
|
||||
if [[ ! -f /build/$1/release/bliss-analyser ]]; then
|
||||
export RUST_BACKTRACE=full
|
||||
export PKG_CONFIG=${1//unknown-/}-pkg-config
|
||||
BINDGEN_EXTRA_CLANG_ARGS="--sysroot /usr/${1//unknown-/}" cargo build --release --features=libav --target $1
|
||||
fi
|
||||
|
||||
$2 /build/$1/release/bliss-analyser && cp /build/$1/release/bliss-analyser $DESTDIR/$3
|
||||
}
|
||||
|
||||
build arm-unknown-linux-gnueabihf arm-linux-gnueabihf-strip bin/bliss-analyser-armhf
|
||||
build aarch64-unknown-linux-gnu aarch64-linux-gnu-strip bin/bliss-analyser-aarch64
|
||||
|
||||
cp UserGuide.md $DESTDIR/README.md
|
||||
cp LICENSE $DESTDIR/
|
||||
cp configs/linux.ini $DESTDIR/config.ini
|
||||
cp scripts/bliss-analyser-arm $DESTDIR/bliss-analyser
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
## #!/usr/bin/env bash
|
||||
set -eux
|
||||
|
||||
uname -a
|
||||
DESTDIR=/src/releases
|
||||
|
||||
mkdir -p $DESTDIR/bin
|
||||
rm -rf $DESTDIR/bin/*
|
||||
|
||||
function build {
|
||||
echo Building for $1 to $3...
|
||||
|
||||
if [[ ! -f /build/$1/release/bliss-analyser ]]; then
|
||||
export RUST_BACKTRACE=full
|
||||
export PKG_CONFIG=${1//unknown-/}-pkg-config
|
||||
BINDGEN_EXTRA_CLANG_ARGS="--sysroot /usr/${1//unknown-/}" cargo build --release --features=libav,staticlibav --target $1
|
||||
fi
|
||||
|
||||
$2 /build/$1/release/bliss-analyser && cp /build/$1/release/bliss-analyser $DESTDIR/$3
|
||||
}
|
||||
|
||||
build arm-unknown-linux-gnueabihf arm-linux-gnueabihf-strip bin/bliss-analyser-armhf
|
||||
build aarch64-unknown-linux-gnu aarch64-linux-gnu-strip bin/bliss-analyser-aarch64
|
||||
|
||||
cp UserGuide.md $DESTDIR/README.md
|
||||
cp LICENSE $DESTDIR/
|
||||
cp configs/linux.ini $DESTDIR/config.ini
|
||||
cp scripts/bliss-analyser-arm $DESTDIR/bliss-analyser
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
## #!/usr/bin/env bash
|
||||
set -eux
|
||||
|
||||
uname -a
|
||||
DESTDIR=/src/releases
|
||||
|
||||
mkdir -p $DESTDIR/bin
|
||||
rm -rf $DESTDIR/bin/*
|
||||
|
||||
function build {
|
||||
echo Building for $1 to $3...
|
||||
|
||||
if [[ ! -f /build/$1/release/bliss-analyser ]]; then
|
||||
export RUST_BACKTRACE=full
|
||||
export PKG_CONFIG=${1//unknown-/}-pkg-config
|
||||
BINDGEN_EXTRA_CLANG_ARGS="--sysroot /usr/${1//unknown-/}" cargo build --release --features=symphonia --target $1
|
||||
fi
|
||||
|
||||
$2 /build/$1/release/bliss-analyser && cp /build/$1/release/bliss-analyser $DESTDIR/$3
|
||||
}
|
||||
|
||||
build arm-unknown-linux-gnueabihf arm-linux-gnueabihf-strip bin/bliss-analyser-armhf
|
||||
build aarch64-unknown-linux-gnu aarch64-linux-gnu-strip bin/bliss-analyser-aarch64
|
||||
|
||||
cp UserGuide.md $DESTDIR/README.md
|
||||
cp LICENSE $DESTDIR/
|
||||
cp configs/linux.ini $DESTDIR/config.ini
|
||||
cp scripts/bliss-analyser-arm $DESTDIR/bliss-analyser
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
## #!/usr/bin/env bash
|
||||
set -eux
|
||||
|
||||
uname -a
|
||||
DESTDIR=/src/releases
|
||||
|
||||
mkdir -p $DESTDIR/bin
|
||||
rm -rf $DESTDIR/bin/*
|
||||
|
||||
export RUST_BACKTRACE=full
|
||||
cargo build --release --features=libav
|
||||
|
||||
strip /build/release/bliss-analyser && cp /build/release/bliss-analyser $DESTDIR/bliss-analyser
|
||||
|
||||
cp UserGuide.md $DESTDIR/README.md
|
||||
cp LICENSE $DESTDIR/
|
||||
cp configs/linux.ini $DESTDIR/config.ini
|
||||
+13
-4
@@ -3,7 +3,7 @@
|
||||
#
|
||||
# LMS-BlissMixer
|
||||
#
|
||||
# Copyright (c) 2022 Craig Drummond <craig.p.drummond@gmail.com>
|
||||
# Copyright (c) 2022-2025 Craig Drummond <craig.p.drummond@gmail.com>
|
||||
# MIT license.
|
||||
#
|
||||
|
||||
@@ -11,7 +11,13 @@ import datetime, os, requests, shutil, subprocess, sys, tempfile, time
|
||||
|
||||
GITHUB_TOKEN_FILE = "%s/.config/github-token" % os.path.expanduser('~')
|
||||
GITHUB_REPO = "CDrummond/bliss-analyser"
|
||||
GITHUB_ARTIFACTS = ["bliss-analyser-linux", "bliss-analyser-mac", "bliss-analyser-windows"]
|
||||
LINUX_ARM_ARTIFACTS = ["bliss-analyser-linux-arm-ffmpeg", "bliss-analyser-linux-arm-symphonia"]
|
||||
LINUX_X86_ARTIFACTS = ["bliss-analyser-linux-x86-ffmpeg", "bliss-analyser-linux-x86-symphonia"]
|
||||
MAC_ARTIFACTS = ["bliss-analyser-mac-symphonia"]
|
||||
WINDOWS_ARTIFACTS = ["bliss-analyser-windows-ffmpeg", "bliss-analyser-windows-symphonia"]
|
||||
UNIX_ARTIFACTS = LINUX_ARM_ARTIFACTS + LINUX_X86_ARTIFACTS + MAC_ARTIFACTS
|
||||
GITHUB_ARTIFACTS = UNIX_ARTIFACTS + WINDOWS_ARTIFACTS
|
||||
|
||||
|
||||
def info(s):
|
||||
print("INFO: %s" %s)
|
||||
@@ -64,12 +70,11 @@ def download_artifacts(version):
|
||||
f.write(chunk)
|
||||
if not os.path.exists(dest):
|
||||
info("Failed to download %s" % item)
|
||||
break
|
||||
|
||||
|
||||
def make_executable(version):
|
||||
cwd = os.getcwd()
|
||||
for a in ["bliss-analyser-linux", "bliss-analyser-mac"]:
|
||||
for a in UNIX_ARTIFACTS:
|
||||
archive = "%s-%s.zip" % (a, version)
|
||||
info("Making analyser executable in %s" % archive)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
@@ -77,6 +82,10 @@ def make_executable(version):
|
||||
os.remove(archive)
|
||||
os.chdir(td)
|
||||
subprocess.call(["chmod", "a+x", "%s/bliss-analyser" % td], shell=False)
|
||||
bindir = os.path.join(td, "bin")
|
||||
if os.path.isdir(bindir):
|
||||
for e in os.listdir(bindir):
|
||||
subprocess.call(["chmod", "a+x", os.path.join(bindir, e)], shell=False)
|
||||
shutil.make_archive("%s/%s-%s" % (cwd, a, version), "zip")
|
||||
os.chdir(cwd)
|
||||
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ARCH=`arch`
|
||||
SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]:-$0}"; )" &> /dev/null && pwd 2> /dev/null; )";
|
||||
|
||||
if [ "$ARCH" = "aarch64" ] ; then
|
||||
$SCRIPT_DIR/bin/bliss-analyser-aarch64 $*
|
||||
else
|
||||
$SCRIPT_DIR/bin/bliss-analyser-armhf $*
|
||||
fi
|
||||
+342
-167
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Analyse music with Bliss
|
||||
*
|
||||
* Copyright (c) 2022 Craig Drummond <craig.p.drummond@gmail.com>
|
||||
* Copyright (c) 2022-2025 Craig Drummond <craig.p.drummond@gmail.com>
|
||||
* GPLv3 license.
|
||||
*
|
||||
**/
|
||||
@@ -9,44 +9,100 @@
|
||||
use crate::db;
|
||||
use crate::tags;
|
||||
use anyhow::Result;
|
||||
use bliss_audio::{analyze_paths};
|
||||
use if_chain::if_chain;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use std::collections::HashSet;
|
||||
use std::convert::TryInto;
|
||||
use std::fs::{DirEntry, File};
|
||||
use std::io::{BufRead, BufReader};
|
||||
use filetime::FileTime;
|
||||
use std::fs;
|
||||
use std::fs::DirEntry;
|
||||
use std::num::NonZero;
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(feature = "libav")]
|
||||
use bliss_audio::decoder::ffmpeg::FFmpegDecoder as SongDecoder;
|
||||
#[cfg(feature = "symphonia")]
|
||||
use bliss_audio::decoder::symphonia::SymphoniaDecoder as SongDecoder;
|
||||
use bliss_audio::{AnalysisOptions, decoder::Decoder};
|
||||
use ureq;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::thread;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
const DONT_ANALYSE: &str = ".notmusic";
|
||||
const MAX_ERRORS_TO_SHOW: usize = 100;
|
||||
const MAX_TAG_ERRORS_TO_SHOW: usize = 50;
|
||||
const VALID_EXTENSIONS: [&str; 5] = ["m4a", "mp3", "ogg", "flac", "opus"];
|
||||
const MIN_NOTIF_TIME:u64 = 2;
|
||||
const VALID_EXTENSIONS: [&str; 7] = ["m4a", "mp3", "ogg", "flac", "opus", "wv", "dsf"];
|
||||
|
||||
fn get_file_list(db: &mut db::Db, mpath: &Path, path: &Path, track_paths: &mut Vec<String>) {
|
||||
if !path.is_dir() {
|
||||
return;
|
||||
static mut TERMINATE_ANALYSIS_FLAG: bool = false;
|
||||
|
||||
struct NotifInfo {
|
||||
pub enabled: bool,
|
||||
pub address: String,
|
||||
pub last_send: u64,
|
||||
pub start_time: u64
|
||||
}
|
||||
|
||||
fn terminate_analysis() -> bool {
|
||||
unsafe {
|
||||
return TERMINATE_ANALYSIS_FLAG
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(items) = path.read_dir() {
|
||||
for item in items {
|
||||
if let Ok(entry) = item {
|
||||
check_dir_entry(db, mpath, entry, track_paths);
|
||||
}
|
||||
fn handle_ctrl_c() {
|
||||
unsafe {
|
||||
TERMINATE_ANALYSIS_FLAG = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn send_notif_msg(notifs: &mut NotifInfo, text: &str) {
|
||||
let json = &format!("{{\"id\":1, \"method\":\"slim.request\",\"params\":[\"\",[\"blissmixer\",\"analyser\",\"act:update\",\"msg:{}\"]]}}", text);
|
||||
log::info!("Sending notif to LMS: {}", text);
|
||||
let _ = ureq::post(¬ifs.address).send_string(&json);
|
||||
}
|
||||
|
||||
fn send_notif(notifs: &mut NotifInfo, text: &str) {
|
||||
if notifs.enabled {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).expect("time should go forward").as_secs();
|
||||
if now>=notifs.last_send+MIN_NOTIF_TIME {
|
||||
let dur = now - notifs.start_time;
|
||||
let msg = format!("[{:02}:{:02}:{:02}] {}", (dur/60)/60, (dur/60)%60, dur%60, text);
|
||||
send_notif_msg(notifs, &msg);
|
||||
notifs.last_send = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_dir_entry(db: &mut db::Db, mpath: &Path, entry: DirEntry, track_paths: &mut Vec<String>) {
|
||||
fn get_file_list(db: &mut db::Db, mpath: &Path, path: &Path, track_paths: &mut Vec<String>, file_count:&mut usize, max_num_files: usize,
|
||||
dry_run: bool, notifs: &mut NotifInfo) {
|
||||
if !path.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
send_notif(notifs, &format!("SCAN DIR {}", path.to_string_lossy()));
|
||||
let mut items: Vec<_> = path.read_dir().unwrap().map(|r| r.unwrap()).collect();
|
||||
items.sort_by_key(|dir| dir.path());
|
||||
|
||||
for item in items {
|
||||
check_dir_entry(db, mpath, item, track_paths, file_count, max_num_files, dry_run, notifs);
|
||||
if max_num_files>0 && *file_count>=max_num_files {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_dir_entry(db: &mut db::Db, mpath: &Path, entry: DirEntry, track_paths: &mut Vec<String>, file_count:&mut usize, max_num_files: usize,
|
||||
dry_run: bool, notifs: &mut NotifInfo) {
|
||||
let pb = entry.path();
|
||||
if pb.is_dir() {
|
||||
let check = pb.join(DONT_ANALYSE);
|
||||
if check.exists() {
|
||||
log::info!("Skipping '{}', found '{}'", pb.to_string_lossy(), DONT_ANALYSE);
|
||||
} else {
|
||||
get_file_list(db, mpath, &pb, track_paths);
|
||||
} else if max_num_files<=0 || *file_count<max_num_files {
|
||||
get_file_list(db, mpath, &pb, track_paths, file_count, max_num_files, dry_run, notifs);
|
||||
}
|
||||
} else if pb.is_file() {
|
||||
} else if pb.is_file() && (max_num_files<=0 || *file_count<max_num_files) {
|
||||
if_chain! {
|
||||
if let Some(ext) = pb.extension();
|
||||
let ext = ext.to_string_lossy();
|
||||
@@ -58,19 +114,38 @@ fn check_dir_entry(db: &mut db::Db, mpath: &Path, entry: DirEntry, track_paths:
|
||||
cue_file.set_extension("cue");
|
||||
if cue_file.exists() {
|
||||
// For cue files, check if first track is in DB
|
||||
let mut track_path = pb.clone();
|
||||
let mut cue_track_path = pb.clone();
|
||||
let ext = pb.extension().unwrap().to_string_lossy();
|
||||
track_path.set_extension(format!("{}{}1", ext, db::CUE_MARKER));
|
||||
let db_track = String::from(track_path.to_string_lossy());
|
||||
if let Ok(id) = db.get_rowid(&db_track) {
|
||||
if id<=0 {
|
||||
track_paths.push(String::from(cue_file.to_string_lossy()));
|
||||
cue_track_path.set_extension(format!("{}{}1", ext, db::CUE_MARKER));
|
||||
if let Ok(cue_track_stripped) = cue_track_path.strip_prefix(mpath) {
|
||||
let cue_track_sname = String::from(cue_track_stripped.to_string_lossy());
|
||||
if let Ok(id) = db.get_rowid(&cue_track_sname) {
|
||||
if id<=0 {
|
||||
track_paths.push(String::from(cue_file.to_string_lossy()));
|
||||
*file_count+=1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let Ok(id) = db.get_rowid(&sname) {
|
||||
if id<=0 {
|
||||
track_paths.push(String::from(pb.to_string_lossy()));
|
||||
// Check this file is not in Failures table
|
||||
let ts = db.get_failure_timestamp(&sname);
|
||||
if ts<=0 {
|
||||
// ...nope, not in there so analyse
|
||||
track_paths.push(String::from(pb.to_string_lossy()));
|
||||
*file_count+=1;
|
||||
} else {
|
||||
let path = String::from(pb.to_string_lossy());
|
||||
let metadata = fs::metadata(&path).unwrap();
|
||||
let mtime = FileTime::from_last_modification_time(&metadata).unix_seconds();
|
||||
if mtime!=ts {
|
||||
// ...was in failures table, but timestamp has changed
|
||||
db.remove_from_failures(&sname);
|
||||
track_paths.push(path);
|
||||
*file_count+=1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,92 +154,7 @@ fn check_dir_entry(db: &mut db::Db, mpath: &Path, entry: DirEntry, track_paths:
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analyse_new_files(db: &db::Db, mpath: &PathBuf, track_paths: Vec<String>) -> Result<()> {
|
||||
let total = track_paths.len();
|
||||
let progress = ProgressBar::new(total.try_into().unwrap()).with_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("[{elapsed_precise}] [{bar:25}] {percent:>3}% {pos:>6}/{len:6} {wide_msg}")
|
||||
.progress_chars("=> "),
|
||||
);
|
||||
|
||||
let mut analysed = 0;
|
||||
let mut failed: Vec<String> = Vec::new();
|
||||
let mut tag_error: Vec<String> = Vec::new();
|
||||
let mut reported_cue:HashSet<String> = HashSet::new();
|
||||
|
||||
log::info!("Analysing new files");
|
||||
for (path, result) in analyze_paths(track_paths) {
|
||||
let pbuff = PathBuf::from(&path);
|
||||
let stripped = pbuff.strip_prefix(mpath).unwrap();
|
||||
let spbuff = stripped.to_path_buf();
|
||||
let sname = String::from(spbuff.to_string_lossy());
|
||||
progress.set_message(format!("{}", sname));
|
||||
let mut inc_progress = true; // Only want to increment progress once for cue tracks
|
||||
match result {
|
||||
Ok(track) => {
|
||||
let cpath = String::from(path);
|
||||
match track.cue_info {
|
||||
Some(cue) => {
|
||||
match track.track_number {
|
||||
Some(track_num) => {
|
||||
let t_num = track_num.parse::<i32>().unwrap();
|
||||
if reported_cue.contains(&cpath) {
|
||||
inc_progress = false;
|
||||
} else {
|
||||
analysed += 1;
|
||||
reported_cue.insert(cpath);
|
||||
}
|
||||
let meta = db::Metadata {
|
||||
title: track.title.unwrap_or_default().to_string(),
|
||||
artist: track.artist.unwrap_or_default().to_string(),
|
||||
album: track.album.unwrap_or_default().to_string(),
|
||||
album_artist: track.album_artist.unwrap_or_default().to_string(),
|
||||
genre: track.genre.unwrap_or_default().to_string(),
|
||||
duration: track.duration.as_secs() as u32
|
||||
};
|
||||
|
||||
// Remove prefix from audio_file_path
|
||||
let pbuff = PathBuf::from(&cue.audio_file_path);
|
||||
let stripped = pbuff.strip_prefix(mpath).unwrap();
|
||||
let spbuff = stripped.to_path_buf();
|
||||
let sname = String::from(spbuff.to_string_lossy());
|
||||
|
||||
let db_path = format!("{}{}{}", sname, db::CUE_MARKER, t_num);
|
||||
db.add_track(&db_path, &meta, &track.analysis);
|
||||
}
|
||||
None => { failed.push(format!("{} - No track number?", sname)); }
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Use lofty to read tags here, and not bliss's, so that if update
|
||||
// tags is ever used they are from the same source.
|
||||
let mut meta = tags::read(&cpath);
|
||||
if meta.is_empty() {
|
||||
// Lofty failed? Try from bliss...
|
||||
meta.title = track.title.unwrap_or_default().to_string();
|
||||
meta.artist = track.artist.unwrap_or_default().to_string();
|
||||
meta.album = track.album.unwrap_or_default().to_string();
|
||||
meta.album_artist = track.album_artist.unwrap_or_default().to_string();
|
||||
meta.genre = track.genre.unwrap_or_default().to_string();
|
||||
meta.duration = track.duration.as_secs() as u32;
|
||||
}
|
||||
if meta.is_empty() {
|
||||
tag_error.push(sname.clone());
|
||||
}
|
||||
db.add_track(&sname, &meta, &track.analysis);
|
||||
analysed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => { failed.push(format!("{} - {}", sname, e)); }
|
||||
};
|
||||
|
||||
if inc_progress {
|
||||
progress.inc(1);
|
||||
}
|
||||
}
|
||||
|
||||
progress.finish_with_message(format!("{} Analysed. {} Failure(s).", analysed, failed.len()));
|
||||
fn show_errors(failed: &mut Vec<String>, tag_error: &mut Vec<String>) {
|
||||
if !failed.is_empty() {
|
||||
let total = failed.len();
|
||||
failed.truncate(MAX_ERRORS_TO_SHOW);
|
||||
@@ -189,88 +179,273 @@ pub fn analyse_new_files(db: &db::Db, mpath: &PathBuf, track_paths: Vec<String>)
|
||||
log::error!(" + {} other(s)", total - MAX_TAG_ERRORS_TO_SHOW);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, PartialEq)]
|
||||
pub struct Meta {
|
||||
pub file: String,
|
||||
pub meta: db::Metadata
|
||||
}
|
||||
|
||||
fn read_tags(tracks: Vec<String>, max_threads: usize) -> Receiver<Meta> {
|
||||
#[allow(clippy::type_complexity)]
|
||||
let (tx, rx): (
|
||||
Sender<Meta>,
|
||||
Receiver<Meta>,
|
||||
) = mpsc::channel();
|
||||
if tracks.is_empty() {
|
||||
return rx;
|
||||
}
|
||||
|
||||
let mut handles = Vec::new();
|
||||
let mut chunk_length = tracks.len() / max_threads;
|
||||
if chunk_length == 0 {
|
||||
chunk_length = tracks.len();
|
||||
} else if chunk_length == 1 && tracks.len() > max_threads {
|
||||
chunk_length = 2;
|
||||
}
|
||||
|
||||
for chunk in tracks.chunks(chunk_length) {
|
||||
let tx_thread = tx.clone();
|
||||
let owned_chunk = chunk.to_owned();
|
||||
let child = thread::spawn(move || {
|
||||
for track in owned_chunk {
|
||||
let _ = tx_thread.send(Meta {
|
||||
file:track.clone(),
|
||||
meta: tags::read(&track, true),
|
||||
});
|
||||
}
|
||||
});
|
||||
handles.push(child);
|
||||
}
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
fn check_for_tags(db: &db::Db, mpath: &PathBuf, track_paths: Vec<String>, max_threads: usize, notifs: &mut NotifInfo) -> Vec<String> {
|
||||
let mut untagged_paths:Vec<String> = Vec::new();
|
||||
let total = track_paths.len();
|
||||
let results = read_tags(track_paths, max_threads);
|
||||
let progress = ProgressBar::new(total.try_into().unwrap()).with_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("[{elapsed_precise}] [{bar:25}] {percent:>3}% {pos:>6}/{len:6} {wide_msg}")
|
||||
.progress_chars("=> "),
|
||||
);
|
||||
|
||||
log::info!("Reading any existing analysis tags");
|
||||
send_notif(notifs, "Reading any existing analysis tags");
|
||||
for res in results {
|
||||
let path = PathBuf::from(&res.file);
|
||||
let stripped = path.strip_prefix(mpath).unwrap();
|
||||
let spbuff = stripped.to_path_buf();
|
||||
let sname = String::from(spbuff.to_string_lossy());
|
||||
progress.set_message(format!("{}", sname));
|
||||
if !res.meta.is_empty() && !res.meta.analysis.is_none() {
|
||||
db.add_track(&sname, &res.meta.clone(), &res.meta.analysis.unwrap());
|
||||
} else {
|
||||
untagged_paths.push(res.file);
|
||||
}
|
||||
if terminate_analysis() {
|
||||
break
|
||||
}
|
||||
progress.inc(1);
|
||||
if notifs.enabled {
|
||||
let pc = (progress.position() as f64 * 100.0)/total as f64;
|
||||
send_notif(notifs, &format!("READ TAGS {:8.2}% {}", pc, sname));
|
||||
}
|
||||
}
|
||||
if terminate_analysis() {
|
||||
progress.abandon_with_message("Terminated!");
|
||||
} else {
|
||||
progress.finish_with_message("Finished!");
|
||||
}
|
||||
untagged_paths
|
||||
}
|
||||
|
||||
fn analyse_new_files(db: &db::Db, mpath: &PathBuf, track_paths: Vec<String>, max_threads: usize, write_tags: bool,
|
||||
preserve_mod_times: bool, notifs: &mut NotifInfo) -> Result<()> {
|
||||
let total = track_paths.len();
|
||||
let progress = ProgressBar::new(total.try_into().unwrap()).with_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("[{elapsed_precise}] [{bar:25}] {percent:>3}% {pos:>6}/{len:6} {wide_msg}")
|
||||
.progress_chars("=> "),
|
||||
);
|
||||
let mut analysed = 0;
|
||||
let mut failed: Vec<String> = Vec::new();
|
||||
let mut tag_error: Vec<String> = Vec::new();
|
||||
let mut reported_cue:HashSet<String> = HashSet::new();
|
||||
let mut options:AnalysisOptions = AnalysisOptions::default();
|
||||
options.number_cores = NonZero::new(max_threads).unwrap();
|
||||
|
||||
send_notif(notifs, "Analysing new files");
|
||||
log::info!("Analysing new files");
|
||||
|
||||
for (path, result) in SongDecoder::analyze_paths_with_options(track_paths, options) {
|
||||
let stripped = path.strip_prefix(mpath).unwrap();
|
||||
let spbuff = stripped.to_path_buf();
|
||||
let sname = String::from(spbuff.to_string_lossy());
|
||||
progress.set_message(format!("{}", sname));
|
||||
let mut inc_progress = true; // Only want to increment progress once for cue tracks
|
||||
match result {
|
||||
Ok(track) => {
|
||||
let cpath = String::from(path.to_string_lossy());
|
||||
match track.cue_info {
|
||||
Some(cue) => {
|
||||
match track.track_number {
|
||||
Some(track_num) => {
|
||||
if reported_cue.contains(&cpath) {
|
||||
inc_progress = false;
|
||||
} else {
|
||||
analysed += 1;
|
||||
reported_cue.insert(cpath);
|
||||
}
|
||||
let meta = db::Metadata {
|
||||
title: track.title.unwrap_or_default().to_string(),
|
||||
artist: track.artist.unwrap_or_default().to_string(),
|
||||
album: track.album.unwrap_or_default().to_string(),
|
||||
album_artist: track.album_artist.unwrap_or_default().to_string(),
|
||||
genre: track.genre.unwrap_or_default().to_string(),
|
||||
duration: track.duration.as_secs() as u32,
|
||||
analysis: None
|
||||
};
|
||||
|
||||
// Remove prefix from audio_file_path
|
||||
let pbuff = PathBuf::from(&cue.audio_file_path);
|
||||
let stripped = pbuff.strip_prefix(mpath).unwrap();
|
||||
let spbuff = stripped.to_path_buf();
|
||||
let sname = String::from(spbuff.to_string_lossy());
|
||||
|
||||
let db_path = format!("{}{}{}", sname, db::CUE_MARKER, track_num);
|
||||
db.add_track(&db_path, &meta, &track.analysis);
|
||||
}
|
||||
None => { failed.push(format!("{} - No track number?", sname)); }
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Use lofty to read tags here, and not bliss's, so that if update
|
||||
// tags is ever used they are from the same source.
|
||||
let mut meta = tags::read(&cpath, false);
|
||||
if meta.is_empty() {
|
||||
// Lofty failed? Try from bliss...
|
||||
meta.title = track.title.unwrap_or_default().to_string();
|
||||
meta.artist = track.artist.unwrap_or_default().to_string();
|
||||
meta.album = track.album.unwrap_or_default().to_string();
|
||||
meta.album_artist = track.album_artist.unwrap_or_default().to_string();
|
||||
meta.genre = track.genre.unwrap_or_default().to_string();
|
||||
meta.duration = track.duration.as_secs() as u32;
|
||||
}
|
||||
if meta.is_empty() {
|
||||
tag_error.push(sname.clone());
|
||||
}
|
||||
if write_tags {
|
||||
tags::write_analysis(&cpath, &track.analysis, preserve_mod_times);
|
||||
}
|
||||
db.add_track(&sname, &meta, &track.analysis);
|
||||
}
|
||||
}
|
||||
analysed += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
failed.push(format!("{} - {}", sname, e));
|
||||
let metadata = fs::metadata(path).unwrap();
|
||||
let mtime = FileTime::from_last_modification_time(&metadata);
|
||||
db.add_to_failures(&sname, mtime.unix_seconds(), &format!("{}", e));
|
||||
}
|
||||
};
|
||||
|
||||
if inc_progress {
|
||||
progress.inc(1);
|
||||
if notifs.enabled {
|
||||
let pc = (progress.position() as f64 * 100.0)/total as f64;
|
||||
send_notif(notifs, &format!("ANALYSE {:8.2}% {}", pc, sname));
|
||||
}
|
||||
}
|
||||
if terminate_analysis() {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if terminate_analysis() {
|
||||
progress.abandon_with_message("Terminated!");
|
||||
} else {
|
||||
progress.finish_with_message("Finished!");
|
||||
}
|
||||
log::info!("{} Analysed. {} Failed.", analysed, failed.len());
|
||||
show_errors(&mut failed, &mut tag_error);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn analyse_files(db_path: &str, mpaths: &Vec<PathBuf>, dry_run: bool, keep_old: bool, max_num_tracks: usize) {
|
||||
pub fn analyse_files(db_path: &str, mpaths: &Vec<PathBuf>, dry_run: bool, keep_old: bool, max_num_files: usize,
|
||||
max_threads: usize, ignore_path: &PathBuf, read_tags: bool, write_tags: bool, preserve_mod_times: bool,
|
||||
lms_host: &String, json_port: u16, send_notifs: bool) -> bool {
|
||||
let mut db = db::Db::new(&String::from(db_path));
|
||||
let mut track_count_left = max_num_tracks;
|
||||
let mut notifs = NotifInfo {
|
||||
enabled: send_notifs,
|
||||
address: format!("http://{}:{}/jsonrpc.js", lms_host, json_port),
|
||||
last_send: 0,
|
||||
start_time: SystemTime::now().duration_since(UNIX_EPOCH).expect("time should go forward").as_secs(),
|
||||
};
|
||||
|
||||
ctrlc::set_handler(move || {
|
||||
handle_ctrl_c();
|
||||
}).expect("Error setting Ctrl-C handler");
|
||||
|
||||
db.init();
|
||||
|
||||
if !keep_old {
|
||||
send_notif(&mut notifs, "Removing old files from DB");
|
||||
db.remove_old(mpaths, dry_run);
|
||||
}
|
||||
|
||||
let mut changes_made = false;
|
||||
for path in mpaths {
|
||||
let mpath = path.clone();
|
||||
let cur = path.clone();
|
||||
let mut track_paths: Vec<String> = Vec::new();
|
||||
let mut file_count:usize = 0;
|
||||
|
||||
if mpaths.len() > 1 {
|
||||
log::info!("Looking for new files in {}", mpath.to_string_lossy());
|
||||
} else {
|
||||
log::info!("Looking for new files");
|
||||
}
|
||||
get_file_list(&mut db, &mpath, &cur, &mut track_paths);
|
||||
log::info!("Looking for new files in {}", mpath.to_string_lossy());
|
||||
send_notif(&mut notifs, &format!("Looking for new files in {}", mpath.to_string_lossy()));
|
||||
|
||||
get_file_list(&mut db, &mpath, &cur, &mut track_paths, &mut file_count, max_num_files, dry_run, &mut notifs);
|
||||
track_paths.sort();
|
||||
log::info!("Num new files: {}", track_paths.len());
|
||||
log::info!("New files: {}", track_paths.len());
|
||||
|
||||
if dry_run {
|
||||
if !track_paths.is_empty() {
|
||||
log::info!("The following need to be analysed:");
|
||||
for track in track_paths {
|
||||
log::info!(" {}", track);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if max_num_tracks > 0 {
|
||||
if track_paths.len() > track_count_left {
|
||||
log::info!("Only analysing {} files", track_count_left);
|
||||
track_paths.truncate(track_count_left);
|
||||
}
|
||||
track_count_left -= track_paths.len();
|
||||
}
|
||||
|
||||
if !track_paths.is_empty() {
|
||||
match analyse_new_files(&db, &mpath, track_paths) {
|
||||
Ok(_) => { }
|
||||
Err(e) => { log::error!("Analysis returned error: {}", e); }
|
||||
if !terminate_analysis() {
|
||||
if dry_run {
|
||||
if !track_paths.is_empty() {
|
||||
log::info!("The following need to be analysed (or tags read):");
|
||||
for track in track_paths {
|
||||
log::info!(" {}", track);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::info!("No new files to analyse");
|
||||
}
|
||||
if !track_paths.is_empty() {
|
||||
let untagged_paths = if read_tags { check_for_tags(&db, &mpath, track_paths, max_threads, &mut notifs) } else { track_paths };
|
||||
|
||||
if max_num_tracks > 0 && track_count_left <= 0 {
|
||||
log::info!("File limit reached");
|
||||
break;
|
||||
if !untagged_paths.is_empty() {
|
||||
log::info!("New untagged files: {}", untagged_paths.len());
|
||||
match analyse_new_files(&db, &mpath, untagged_paths, max_threads, write_tags, preserve_mod_times, &mut notifs) {
|
||||
Ok(_) => { changes_made = true; }
|
||||
Err(e) => { log::error!("Analysis returned error: {}", e); }
|
||||
}
|
||||
} else {
|
||||
log::info!("No new untagged files to analyse");
|
||||
send_notif(&mut notifs, "No new files to analyse");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
pub fn read_tags(db_path: &str, mpaths: &Vec<PathBuf>) {
|
||||
let db = db::Db::new(&String::from(db_path));
|
||||
db.init();
|
||||
db.update_tags(&mpaths);
|
||||
db.close();
|
||||
}
|
||||
|
||||
pub fn update_ignore(db_path: &str, ignore_path: &PathBuf) {
|
||||
let file = File::open(ignore_path).unwrap();
|
||||
let reader = BufReader::new(file);
|
||||
let db = db::Db::new(&String::from(db_path));
|
||||
db.init();
|
||||
|
||||
db.clear_ignore();
|
||||
let mut lines = reader.lines();
|
||||
while let Some(Ok(line)) = lines.next() {
|
||||
if !line.is_empty() && !line.starts_with("#") {
|
||||
db.set_ignore(&line);
|
||||
}
|
||||
if changes_made && ignore_path.exists() && ignore_path.is_file() {
|
||||
log::info!("Updating 'ignore' flags");
|
||||
send_notif(&mut notifs, "Updating ignore");
|
||||
db::update_ignore(&db_path, &ignore_path);
|
||||
}
|
||||
|
||||
db.close();
|
||||
if send_notifs {
|
||||
send_notif_msg(&mut notifs, "FINISHED");
|
||||
}
|
||||
changes_made
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
/**
|
||||
* Analyse music with Bliss
|
||||
*
|
||||
* Copyright (c) 2022 Craig Drummond <craig.p.drummond@gmail.com>
|
||||
* Copyright (c) 2022-2025 Craig Drummond <craig.p.drummond@gmail.com>
|
||||
* GPLv3 license.
|
||||
*
|
||||
**/
|
||||
|
||||
use crate::tags;
|
||||
use bliss_audio::{Analysis, AnalysisIndex};
|
||||
use bliss_audio::{Analysis, AnalysisIndex, FeaturesVersion};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use rusqlite::{params, Connection};
|
||||
use std::convert::TryInto;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::thread;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
pub const CUE_MARKER: &str = ".CUE_TRACK.";
|
||||
|
||||
@@ -27,7 +31,13 @@ pub struct FileMetadata {
|
||||
pub duration: u32,
|
||||
}
|
||||
|
||||
#[derive(Default, PartialEq)]
|
||||
#[derive(Clone)]
|
||||
struct AnalysisResults {
|
||||
pub file: String,
|
||||
pub analysis: Analysis,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, PartialEq)]
|
||||
pub struct Metadata {
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
@@ -35,6 +45,7 @@ pub struct Metadata {
|
||||
pub album: String,
|
||||
pub genre: String,
|
||||
pub duration: u32,
|
||||
pub analysis: Option<Analysis>,
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
@@ -47,6 +58,20 @@ impl Metadata {
|
||||
}
|
||||
}
|
||||
|
||||
static mut TERMINATE_EXPORT_FLAG: bool = false;
|
||||
|
||||
fn terminate_export() -> bool {
|
||||
unsafe {
|
||||
return TERMINATE_EXPORT_FLAG
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_ctrl_c() {
|
||||
unsafe {
|
||||
TERMINATE_EXPORT_FLAG = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Db {
|
||||
pub conn: Connection,
|
||||
}
|
||||
@@ -67,8 +92,13 @@ impl Db {
|
||||
}
|
||||
|
||||
pub fn init(&self) {
|
||||
let _ = self.conn.execute("DROP TABLE IF EXISTS Tracks;", [],);
|
||||
let _ = self.conn.execute("DROP INDEX IF EXISTS Tracks_idx", []);
|
||||
|
||||
let _ = self.conn.execute("VACUUM", [],);
|
||||
|
||||
let cmd = self.conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS Tracks (
|
||||
"CREATE TABLE IF NOT EXISTS TracksV2 (
|
||||
File text primary key,
|
||||
Title text,
|
||||
Artist text,
|
||||
@@ -96,20 +126,44 @@ impl Db {
|
||||
Chroma7 real,
|
||||
Chroma8 real,
|
||||
Chroma9 real,
|
||||
Chroma10 real
|
||||
Chroma10 real,
|
||||
Chroma11 real,
|
||||
Chroma12 real,
|
||||
Chroma13 real
|
||||
);",
|
||||
[],
|
||||
);
|
||||
|
||||
if cmd.is_err() {
|
||||
log::error!("Failed to create DB table");
|
||||
log::error!("Failed to create Files table");
|
||||
process::exit(-1);
|
||||
}
|
||||
|
||||
let cmd = self.conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS Tracks_idx ON Tracks(File)", []);
|
||||
let cmd = self.conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS TracksV2_idx ON TracksV2(File)", []);
|
||||
|
||||
if cmd.is_err() {
|
||||
log::error!("Failed to create DB index");
|
||||
log::error!("Failed to create Files index");
|
||||
process::exit(-1);
|
||||
}
|
||||
|
||||
let cmd = self.conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS Failures (
|
||||
File text primary key,
|
||||
Timestamp integer,
|
||||
Reason text
|
||||
);",
|
||||
[],
|
||||
);
|
||||
|
||||
if cmd.is_err() {
|
||||
log::error!("Failed to create Failures table");
|
||||
process::exit(-1);
|
||||
}
|
||||
|
||||
let cmd = self.conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS Failures_idx ON Failures(File)", []);
|
||||
|
||||
if cmd.is_err() {
|
||||
log::error!("Failed to create Failures index");
|
||||
process::exit(-1);
|
||||
}
|
||||
}
|
||||
@@ -123,7 +177,7 @@ impl Db {
|
||||
if cfg!(windows) {
|
||||
db_path = db_path.replace("\\", "/");
|
||||
}
|
||||
let mut stmt = self.conn.prepare("SELECT rowid FROM Tracks WHERE File=:path;")?;
|
||||
let mut stmt = self.conn.prepare("SELECT rowid FROM TracksV2 WHERE File=:path;")?;
|
||||
let track_iter = stmt.query_map(&[(":path", &db_path)], |row| Ok(row.get(0)?)).unwrap();
|
||||
let mut rowid: usize = 0;
|
||||
for tr in track_iter {
|
||||
@@ -141,23 +195,24 @@ impl Db {
|
||||
match self.get_rowid(&path) {
|
||||
Ok(id) => {
|
||||
if id <= 0 {
|
||||
match self.conn.execute("INSERT INTO Tracks (File, Title, Artist, AlbumArtist, Album, Genre, Duration, Ignore, Tempo, Zcr, MeanSpectralCentroid, StdDevSpectralCentroid, MeanSpectralRolloff, StdDevSpectralRolloff, MeanSpectralFlatness, StdDevSpectralFlatness, MeanLoudness, StdDevLoudness, Chroma1, Chroma2, Chroma3, Chroma4, Chroma5, Chroma6, Chroma7, Chroma8, Chroma9, Chroma10) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
match self.conn.execute("INSERT INTO TracksV2 (File, Title, Artist, AlbumArtist, Album, Genre, Duration, Ignore, Tempo, Zcr, MeanSpectralCentroid, StdDevSpectralCentroid, MeanSpectralRolloff, StdDevSpectralRolloff, MeanSpectralFlatness, StdDevSpectralFlatness, MeanLoudness, StdDevLoudness, Chroma1, Chroma2, Chroma3, Chroma4, Chroma5, Chroma6, Chroma7, Chroma8, Chroma9, Chroma10, Chroma11, Chroma12, Chroma13) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
params![db_path, meta.title, meta.artist, meta.album_artist, meta.album, meta.genre, meta.duration, 0,
|
||||
analysis[AnalysisIndex::Tempo], analysis[AnalysisIndex::Zcr], analysis[AnalysisIndex::MeanSpectralCentroid], analysis[AnalysisIndex::StdDeviationSpectralCentroid], analysis[AnalysisIndex::MeanSpectralRolloff],
|
||||
analysis[AnalysisIndex::StdDeviationSpectralRolloff], analysis[AnalysisIndex::MeanSpectralFlatness], analysis[AnalysisIndex::StdDeviationSpectralFlatness], analysis[AnalysisIndex::MeanLoudness], analysis[AnalysisIndex::StdDeviationLoudness],
|
||||
analysis[AnalysisIndex::Chroma1], analysis[AnalysisIndex::Chroma2], analysis[AnalysisIndex::Chroma3], analysis[AnalysisIndex::Chroma4], analysis[AnalysisIndex::Chroma5],
|
||||
analysis[AnalysisIndex::Chroma6], analysis[AnalysisIndex::Chroma7], analysis[AnalysisIndex::Chroma8], analysis[AnalysisIndex::Chroma9], analysis[AnalysisIndex::Chroma10]]) {
|
||||
Ok(_) => { }
|
||||
analysis[AnalysisIndex::Chroma6], analysis[AnalysisIndex::Chroma7], analysis[AnalysisIndex::Chroma8], analysis[AnalysisIndex::Chroma9], analysis[AnalysisIndex::Chroma10],
|
||||
analysis[AnalysisIndex::Chroma11], analysis[AnalysisIndex::Chroma12], analysis[AnalysisIndex::Chroma13]]) {
|
||||
Ok(_) => { self.remove_from_failures(path); }
|
||||
Err(e) => { log::error!("Failed to insert '{}' into database. {}", path, e); }
|
||||
}
|
||||
} else {
|
||||
match self.conn.execute("UPDATE Tracks SET Title=?, Artist=?, AlbumArtist=?, Album=?, Genre=?, Duration=?, Tempo=?, Zcr=?, MeanSpectralCentroid=?, StdDevSpectralCentroid=?, MeanSpectralRolloff=?, StdDevSpectralRolloff=?, MeanSpectralFlatness=?, StdDevSpectralFlatness=?, MeanLoudness=?, StdDevLoudness=?, Chroma1=?, Chroma2=?, Chroma3=?, Chroma4=?, Chroma5=?, Chroma6=?, Chroma7=?, Chroma8=?, Chroma9=?, Chroma10=? WHERE rowid=?;",
|
||||
match self.conn.execute("UPDATE TracksV2 SET Title=?, Artist=?, AlbumArtist=?, Album=?, Genre=?, Duration=?, Tempo=?, Zcr=?, MeanSpectralCentroid=?, StdDevSpectralCentroid=?, MeanSpectralRolloff=?, StdDevSpectralRolloff=?, MeanSpectralFlatness=?, StdDevSpectralFlatness=?, MeanLoudness=?, StdDevLoudness=?, Chroma1=?, Chroma2=?, Chroma3=?, Chroma4=?, Chroma5=?, Chroma6=?, Chroma7=?, Chroma8=?, Chroma9=?, Chroma10=?, Chroma11=?, Chroma12=?, Chroma13=? WHERE rowid=?;",
|
||||
params![meta.title, meta.artist, meta.album_artist, meta.album, meta.genre, meta.duration,
|
||||
analysis[AnalysisIndex::Tempo], analysis[AnalysisIndex::Zcr], analysis[AnalysisIndex::MeanSpectralCentroid], analysis[AnalysisIndex::StdDeviationSpectralCentroid], analysis[AnalysisIndex::MeanSpectralRolloff],
|
||||
analysis[AnalysisIndex::StdDeviationSpectralRolloff], analysis[AnalysisIndex::MeanSpectralFlatness], analysis[AnalysisIndex::StdDeviationSpectralFlatness], analysis[AnalysisIndex::MeanLoudness], analysis[AnalysisIndex::StdDeviationLoudness],
|
||||
analysis[AnalysisIndex::Chroma1], analysis[AnalysisIndex::Chroma2], analysis[AnalysisIndex::Chroma3], analysis[AnalysisIndex::Chroma4], analysis[AnalysisIndex::Chroma5],
|
||||
analysis[AnalysisIndex::Chroma6], analysis[AnalysisIndex::Chroma7], analysis[AnalysisIndex::Chroma8], analysis[AnalysisIndex::Chroma9], analysis[AnalysisIndex::Chroma10], id]) {
|
||||
Ok(_) => { }
|
||||
Ok(_) => { self.remove_from_failures(path); }
|
||||
Err(e) => { log::error!("Failed to update '{}' in database. {}", path, e); }
|
||||
}
|
||||
}
|
||||
@@ -166,9 +221,42 @@ impl Db {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_to_failures(&self, path: &String, timestamp:i64, reason: &String) {
|
||||
self.remove_from_failures(path);
|
||||
let _ = self.conn.execute("INSERT INTO Failures (File, Timestamp, Reason) VALUES (?, ?, ?);", params![path, timestamp, reason]);
|
||||
}
|
||||
|
||||
pub fn remove_from_failures(&self, path: &String) {
|
||||
let _ = self.conn.execute("DELETE FROM Failures WHERE File = ?;", params![path]);
|
||||
}
|
||||
|
||||
pub fn remove_old(&self, mpaths: &Vec<PathBuf>, dry_run: bool) {
|
||||
self.remove_old_from_table(mpaths, dry_run, "TracksV2");
|
||||
self.remove_old_from_table(mpaths, dry_run, "Failures");
|
||||
}
|
||||
|
||||
fn clear_failures(&self) {
|
||||
let _ = self.conn.execute("DELETE FROM Failures;", []);
|
||||
}
|
||||
|
||||
pub fn get_failure_timestamp(&self, path: &String) -> i64 {
|
||||
let mut ts:i64 = 0;
|
||||
let mut db_path = path.to_string();
|
||||
if cfg!(windows) {
|
||||
db_path = db_path.replace("\\", "/");
|
||||
}
|
||||
let mut stmt = self.conn.prepare("SELECT Timestamp FROM Failures WHERE File=:path;").unwrap();
|
||||
let track_iter = stmt.query_map(&[(":path", &db_path)], |row| Ok(row.get(0)?)).unwrap();
|
||||
for tr in track_iter {
|
||||
ts = tr.unwrap();
|
||||
break;
|
||||
}
|
||||
ts
|
||||
}
|
||||
|
||||
fn remove_old_from_table(&self, mpaths: &Vec<PathBuf>, dry_run: bool, table: &str) {
|
||||
log::info!("Looking for non-existent tracks");
|
||||
let mut stmt = self.conn.prepare("SELECT File FROM Tracks;").unwrap();
|
||||
let mut stmt = self.conn.prepare(format!("SELECT File FROM {};", table).as_str()).unwrap();
|
||||
let track_iter = stmt.query_map([], |row| Ok((row.get(0)?,))).unwrap();
|
||||
let mut to_remove: Vec<String> = Vec::new();
|
||||
for tr in track_iter {
|
||||
@@ -212,7 +300,7 @@ impl Db {
|
||||
let count_before = self.get_track_count();
|
||||
for t in to_remove {
|
||||
//log::debug!("Remove '{}'", t);
|
||||
let cmd = self.conn.execute("DELETE FROM Tracks WHERE File = ?;", params![t]);
|
||||
let cmd = self.conn.execute(format!("DELETE FROM {} WHERE File = ?;", table).as_str(), params![t]);
|
||||
|
||||
if let Err(e) = cmd {
|
||||
log::error!("Failed to remove '{}' - {}", t, e)
|
||||
@@ -227,7 +315,7 @@ impl Db {
|
||||
}
|
||||
|
||||
pub fn get_track_count(&self) -> usize {
|
||||
let mut stmt = self.conn.prepare("SELECT COUNT(*) FROM Tracks;").unwrap();
|
||||
let mut stmt = self.conn.prepare("SELECT COUNT(*) FROM TracksV2;").unwrap();
|
||||
let track_iter = stmt.query_map([], |row| Ok(row.get(0)?)).unwrap();
|
||||
let mut count: usize = 0;
|
||||
for tr in track_iter {
|
||||
@@ -248,7 +336,7 @@ impl Db {
|
||||
.progress_chars("=> "),
|
||||
);
|
||||
|
||||
let mut stmt = self.conn.prepare("SELECT rowid, File, Title, Artist, AlbumArtist, Album, Genre, Duration FROM Tracks ORDER BY File ASC;").unwrap();
|
||||
let mut stmt = self.conn.prepare("SELECT rowid, File, Title, Artist, AlbumArtist, Album, Genre, Duration FROM TracksV2 ORDER BY File ASC;").unwrap();
|
||||
let track_iter = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(FileMetadata {
|
||||
@@ -275,6 +363,7 @@ impl Db {
|
||||
album: dbtags.album.unwrap_or_default(),
|
||||
genre: dbtags.genre.unwrap_or_default(),
|
||||
duration: dbtags.duration,
|
||||
analysis: None,
|
||||
};
|
||||
progress.set_message(format!("{}", dbtags.file));
|
||||
|
||||
@@ -282,11 +371,13 @@ impl Db {
|
||||
let track_path = mpath.join(&dbtags.file);
|
||||
if track_path.exists() {
|
||||
let path = String::from(track_path.to_string_lossy());
|
||||
let ftags = tags::read(&path);
|
||||
let ftags = tags::read(&path, false);
|
||||
|
||||
|
||||
if ftags.is_empty() {
|
||||
log::error!("Failed to read tags of '{}'", dbtags.file);
|
||||
} else if ftags != dtags {
|
||||
match self.conn.execute("UPDATE Tracks SET Title=?, Artist=?, AlbumArtist=?, Album=?, Genre=?, Duration=? WHERE rowid=?;",
|
||||
match self.conn.execute("UPDATE TracksV2 SET Title=?, Artist=?, AlbumArtist=?, Album=?, Genre=?, Duration=? WHERE rowid=?;",
|
||||
params![ftags.title, ftags.artist, ftags.album_artist, ftags.album, ftags.genre, ftags.duration, dbtags.rowid]) {
|
||||
Ok(_) => { updated += 1; }
|
||||
Err(e) => { log::error!("Failed to update tags of '{}'. {}", dbtags.file, e); }
|
||||
@@ -303,7 +394,7 @@ impl Db {
|
||||
}
|
||||
|
||||
pub fn clear_ignore(&self) {
|
||||
let cmd = self.conn.execute("UPDATE Tracks SET Ignore=0;", []);
|
||||
let cmd = self.conn.execute("UPDATE TracksV2 SET Ignore=0;", []);
|
||||
|
||||
if let Err(e) = cmd {
|
||||
log::error!("Failed clear Ignore column. {}", e);
|
||||
@@ -314,17 +405,159 @@ impl Db {
|
||||
log::info!("Ignore: {}", line);
|
||||
if line.starts_with("SQL:") {
|
||||
let sql = &line[4..];
|
||||
let cmd = self.conn.execute(&format!("UPDATE Tracks Set Ignore=1 WHERE {}", sql), []);
|
||||
let cmd = self.conn.execute(&format!("UPDATE TracksV2 Set Ignore=1 WHERE {}", sql), []);
|
||||
|
||||
if let Err(e) = cmd {
|
||||
log::error!("Failed set Ignore column for '{}'. {}", line, e);
|
||||
}
|
||||
} else {
|
||||
let cmd = self.conn.execute(&format!("UPDATE Tracks SET Ignore=1 WHERE File LIKE \"{}%\"", line), []);
|
||||
let cmd = self.conn.execute(&format!("UPDATE TracksV2 SET Ignore=1 WHERE File LIKE \"{}%\"", line), []);
|
||||
|
||||
if let Err(e) = cmd {
|
||||
log::error!("Failed set Ignore column for '{}'. {}", line, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export(&self, mpaths: &Vec<PathBuf>, max_threads: usize, preserve_mod_times: bool) {
|
||||
ctrlc::set_handler(move || {
|
||||
handle_ctrl_c();
|
||||
}).expect("Error setting Ctrl-C handler");
|
||||
|
||||
log::info!("Querying database");
|
||||
let mut tracks:Vec<AnalysisResults> = Vec::new();
|
||||
let mut stmt = self.conn.prepare("SELECT File, Tempo, Zcr, MeanSpectralCentroid, StdDevSpectralCentroid, MeanSpectralRolloff, StdDevSpectralRolloff, MeanSpectralFlatness, StdDevSpectralFlatness, MeanLoudness, StdDevLoudness, Chroma1, Chroma2, Chroma3, Chroma4, Chroma5, Chroma6, Chroma7, Chroma8, Chroma9, Chroma10, Chroma11, Chroma12, Chroma13 FROM TracksV2 ORDER BY File ASC;").unwrap();
|
||||
let track_iter = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(AnalysisResults {
|
||||
file: row.get(0)?,
|
||||
analysis: Analysis::new([row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?, row.get(8)?, row.get(9)?, row.get(10)?, row.get(11)?, row.get(12)?, row.get(13)?, row.get(14)?, row.get(15)?, row.get(16)?, row.get(17)?, row.get(18)?, row.get(19)?, row.get(20)?, row.get(21)?, row.get(22)?, row.get(23)?].to_vec(), FeaturesVersion::LATEST).expect("number of vals should be 23"),
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
for tr in track_iter {
|
||||
let dbtags = tr.unwrap();
|
||||
if !dbtags.file.contains(CUE_MARKER) {
|
||||
for mpath in mpaths {
|
||||
let track_path = mpath.join(dbtags.file.clone());
|
||||
if track_path.exists() {
|
||||
tracks.push(AnalysisResults{file:String::from(track_path.to_string_lossy()), analysis:dbtags.analysis.clone()});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total = tracks.len();
|
||||
if total <= 0 {
|
||||
log::info!("Nothing to export");
|
||||
return;
|
||||
}
|
||||
log::info!("Starting export");
|
||||
let chunk_size = total/max_threads;
|
||||
let mut threads: Vec<JoinHandle<()>> = vec![];
|
||||
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let reporting_thread = std::thread::spawn(move || {
|
||||
let mut processed = 0;
|
||||
let mut had_tags = 0;
|
||||
let mut failed_to_write = 0;
|
||||
let mut exported = 0;
|
||||
let progress = ProgressBar::new(total.try_into().unwrap()).with_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(
|
||||
"[{elapsed_precise}] [{bar:25}] {percent:>3}% {pos:>6}/{len:6} {wide_msg}",
|
||||
)
|
||||
.progress_chars("=> "),
|
||||
);
|
||||
for resp in receiver {
|
||||
progress.inc(1);
|
||||
processed+=1;
|
||||
if resp==0 {
|
||||
had_tags+=1;
|
||||
} else if resp==1 {
|
||||
failed_to_write+=1;
|
||||
} else {
|
||||
exported+=1;
|
||||
}
|
||||
if processed == total {
|
||||
break;
|
||||
}
|
||||
if terminate_export() {
|
||||
break
|
||||
}
|
||||
}
|
||||
if terminate_export() {
|
||||
progress.abandon_with_message("Terminated!");
|
||||
} else {
|
||||
progress.finish_with_message(format!("Finished!"));
|
||||
}
|
||||
log::info!("{} Exported. {} Existing. {} Failed.", exported, had_tags, failed_to_write);
|
||||
});
|
||||
threads.push(reporting_thread);
|
||||
for thread in 0..max_threads {
|
||||
let tid:usize = thread;
|
||||
let start = tid * chunk_size;
|
||||
let end = if tid+1 == max_threads { total } else { start + chunk_size };
|
||||
let sndr = sender.clone();
|
||||
let trks = Vec::from_iter(tracks[start..end].iter().cloned());
|
||||
threads.push(thread::spawn(move || {
|
||||
for track in trks {
|
||||
let mut updated = 0;
|
||||
let meta = tags::read(&track.file, true);
|
||||
if meta.is_empty() || meta.analysis.is_none() || meta.analysis.unwrap()!=track.analysis {
|
||||
updated = 1;
|
||||
if tags::write_analysis(&track.file, &track.analysis, preserve_mod_times) {
|
||||
updated = 2;
|
||||
}
|
||||
}
|
||||
sndr.send(updated).unwrap();
|
||||
if terminate_export() {
|
||||
break
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
for thread in threads {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_tags(db_path: &str, mpaths: &Vec<PathBuf>) {
|
||||
let db = Db::new(&String::from(db_path));
|
||||
db.init();
|
||||
db.update_tags(&mpaths);
|
||||
db.close();
|
||||
}
|
||||
|
||||
pub fn export(db_path: &str, mpaths: &Vec<PathBuf>, max_threads: usize, preserve_mod_times: bool) {
|
||||
let db = Db::new(&String::from(db_path));
|
||||
db.init();
|
||||
db.export(&mpaths, max_threads, preserve_mod_times);
|
||||
db.close();
|
||||
}
|
||||
|
||||
pub fn clear_failures(db_path: &str) {
|
||||
let db = Db::new(&String::from(db_path));
|
||||
db.init();
|
||||
db.clear_failures();
|
||||
db.close();
|
||||
}
|
||||
|
||||
pub fn update_ignore(db_path: &str, ignore_path: &PathBuf) {
|
||||
let file = File::open(ignore_path).unwrap();
|
||||
let reader = BufReader::new(file);
|
||||
let db = Db::new(&String::from(db_path));
|
||||
db.init();
|
||||
|
||||
db.clear_ignore();
|
||||
let mut lines = reader.lines();
|
||||
while let Some(Ok(line)) = lines.next() {
|
||||
if !line.is_empty() && !line.starts_with("#") {
|
||||
db.set_ignore(&line);
|
||||
}
|
||||
}
|
||||
|
||||
db.close();
|
||||
}
|
||||
+82
-10
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Analyse music with Bliss
|
||||
*
|
||||
* Copyright (c) 2022 Craig Drummond <craig.p.drummond@gmail.com>
|
||||
* Copyright (c) 2022-2025 Craig Drummond <craig.p.drummond@gmail.com>
|
||||
* GPLv3 license.
|
||||
*
|
||||
**/
|
||||
@@ -14,6 +14,7 @@ use log::LevelFilter;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use num_cpus;
|
||||
mod analyse;
|
||||
mod db;
|
||||
mod tags;
|
||||
@@ -27,13 +28,23 @@ fn main() {
|
||||
let mut db_path = "bliss.db".to_string();
|
||||
let mut logging = "info".to_string();
|
||||
let mut music_path = ".".to_string();
|
||||
let mut music_1_path = "".to_string();
|
||||
let mut music_2_path = "".to_string();
|
||||
let mut music_3_path = "".to_string();
|
||||
let mut music_4_path = "".to_string();
|
||||
let mut ignore_file = "ignore.txt".to_string();
|
||||
let mut keep_old: bool = false;
|
||||
let mut dry_run: bool = false;
|
||||
let mut task = "".to_string();
|
||||
let mut lms_host = "127.0.0.1".to_string();
|
||||
let mut lms_json_port:u16 = 9000;
|
||||
let mut max_num_files: usize = 0;
|
||||
let mut music_paths: Vec<PathBuf> = Vec::new();
|
||||
let mut max_threads: i16 = 0;
|
||||
let mut write_tags = false;
|
||||
let mut read_tags = false;
|
||||
let mut preserve_mod_times = false;
|
||||
let mut send_notifs = false;
|
||||
|
||||
match dirs::home_dir() {
|
||||
Some(path) => {
|
||||
@@ -45,10 +56,12 @@ fn main() {
|
||||
{
|
||||
let config_file_help = format!("config file (default: {})", &config_file);
|
||||
let music_path_help = format!("Music folder (default: {})", &music_path);
|
||||
let alt_music_path_help = format!("Other music folder");
|
||||
let db_path_help = format!("Database location (default: {})", &db_path);
|
||||
let logging_help = format!("Log level; trace, debug, info, warn, error. (default: {})", logging);
|
||||
let ignore_file_help = format!("File contains items to mark as ignored. (default: {})", ignore_file);
|
||||
let lms_host_help = format!("LMS hostname or IP address (default: {})", &lms_host);
|
||||
let lms_json_port_help = format!("LMS JSONRPC port (default: {})", &lms_json_port);
|
||||
let description = format!("Bliss Analyser v{}", VERSION);
|
||||
|
||||
// arg_parse.refer 'borrows' db_path, etc, and can only have one
|
||||
@@ -57,14 +70,24 @@ fn main() {
|
||||
arg_parse.set_description(&description);
|
||||
arg_parse.refer(&mut config_file).add_option(&["-c", "--config"], Store, &config_file_help);
|
||||
arg_parse.refer(&mut music_path).add_option(&["-m", "--music"], Store, &music_path_help);
|
||||
arg_parse.refer(&mut music_1_path).add_option(&["--music_1"], Store, &alt_music_path_help);
|
||||
arg_parse.refer(&mut music_2_path).add_option(&["--music_2"], Store, &alt_music_path_help);
|
||||
arg_parse.refer(&mut music_3_path).add_option(&["--music_3"], Store, &alt_music_path_help);
|
||||
arg_parse.refer(&mut music_4_path).add_option(&["--music_4"], Store, &alt_music_path_help);
|
||||
arg_parse.refer(&mut db_path).add_option(&["-d", "--db"], Store, &db_path_help);
|
||||
arg_parse.refer(&mut logging).add_option(&["-l", "--logging"], Store, &logging_help);
|
||||
arg_parse.refer(&mut keep_old).add_option(&["-k", "--keep-old"], StoreTrue, "Don't remove files from DB if they don't exist (used with analyse task)");
|
||||
arg_parse.refer(&mut dry_run).add_option(&["-r", "--dry-run"], StoreTrue, "Dry run, only show what needs to be done (used with analyse task)");
|
||||
arg_parse.refer(&mut ignore_file).add_option(&["-i", "--ignore"], Store, &ignore_file_help);
|
||||
arg_parse.refer(&mut lms_host).add_option(&["-L", "--lms"], Store, &lms_host_help);
|
||||
arg_parse.refer(&mut lms_json_port).add_option(&["-J", "--json"], Store, &lms_json_port_help);
|
||||
arg_parse.refer(&mut max_num_files).add_option(&["-n", "--numfiles"], Store, "Maximum number of files to analyse");
|
||||
arg_parse.refer(&mut task).add_argument("task", Store, "Task to perform; analyse, tags, ignore, upload, stopmixer.");
|
||||
arg_parse.refer(&mut max_threads).add_option(&["-t", "--threads"], Store, "Maximum number of threads to use for analysis");
|
||||
arg_parse.refer(&mut write_tags).add_option(&["-W", "--writetags"], StoreTrue, "When analysing files, also store results within files themselves");
|
||||
arg_parse.refer(&mut read_tags).add_option(&["-R", "--readtags"], StoreTrue, "When analysing files, attempt to read results from tags");
|
||||
arg_parse.refer(&mut preserve_mod_times).add_option(&["-p", "--preserve"], StoreTrue, "Preserve modification time when writing results to files");
|
||||
arg_parse.refer(&mut send_notifs).add_option(&["-N", "--notifs"], StoreTrue, "Periodically send notification messages to LMS");
|
||||
arg_parse.refer(&mut task).add_argument("task", Store, "Task to perform; analyse, tags, ignore, upload, export, clearfail, stopmixer.");
|
||||
arg_parse.parse_args_or_exit();
|
||||
}
|
||||
|
||||
@@ -72,24 +95,28 @@ fn main() {
|
||||
|| logging.eq_ignore_ascii_case("warn") || logging.eq_ignore_ascii_case("error")) {
|
||||
logging = String::from("info");
|
||||
}
|
||||
let other_level = if logging.eq_ignore_ascii_case("trace") { LevelFilter::Trace } else { LevelFilter::Error };
|
||||
let mut builder = env_logger::Builder::from_env(env_logger::Env::default().filter_or("XXXXXXXX", logging));
|
||||
builder.filter(Some("bliss_audio"), LevelFilter::Error);
|
||||
builder.filter(Some("bliss_audio"), other_level);
|
||||
builder.filter(Some("symphonia"), other_level);
|
||||
builder.filter(Some("lofty"), other_level);
|
||||
builder.format(|buf, record| {
|
||||
writeln!(buf, "[{} {:.1}] {}", Local::now().format("%Y-%m-%d %H:%M:%S"), record.level(), record.args())
|
||||
});
|
||||
builder.init();
|
||||
|
||||
if task.is_empty() {
|
||||
log::error!("No task specified, please choose from; analyse, tags, ignore, upload");
|
||||
log::error!("No task specified, please choose from; analyse, tags, ignore, upload, export, clearfail, stopmixer");
|
||||
process::exit(-1);
|
||||
}
|
||||
|
||||
if !task.eq_ignore_ascii_case("analyse") && !task.eq_ignore_ascii_case("tags") && !task.eq_ignore_ascii_case("ignore")
|
||||
&& !task.eq_ignore_ascii_case("upload") && !task.eq_ignore_ascii_case("stopmixer") {
|
||||
&& !task.eq_ignore_ascii_case("upload") && !task.eq_ignore_ascii_case("export") && !task.eq_ignore_ascii_case("stopmixer") && !task.eq_ignore_ascii_case("analyse-lms") {
|
||||
log::error!("Invalid task ({}) supplied", task);
|
||||
process::exit(-1);
|
||||
}
|
||||
|
||||
|
||||
if !config_file.is_empty() {
|
||||
let path = PathBuf::from(&config_file);
|
||||
if path.exists() && path.is_file() {
|
||||
@@ -111,10 +138,26 @@ fn main() {
|
||||
Some(val) => { lms_host = val; }
|
||||
None => { }
|
||||
}
|
||||
match config.get(TOP_LEVEL_INI_TAG, "json") {
|
||||
Some(val) => { lms_json_port = val.parse::<u16>().unwrap(); }
|
||||
None => { }
|
||||
}
|
||||
match config.get(TOP_LEVEL_INI_TAG, "ignore") {
|
||||
Some(val) => { ignore_file = val; }
|
||||
None => { }
|
||||
}
|
||||
match config.get(TOP_LEVEL_INI_TAG, "writetags") {
|
||||
Some(val) => { write_tags = val.eq("true"); }
|
||||
None => { }
|
||||
}
|
||||
match config.get(TOP_LEVEL_INI_TAG, "readtags") {
|
||||
Some(val) => { read_tags = val.eq("true"); }
|
||||
None => { }
|
||||
}
|
||||
match config.get(TOP_LEVEL_INI_TAG, "preserve") {
|
||||
Some(val) => { preserve_mod_times = val.eq("true"); }
|
||||
None => { }
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to load config file. {}", e);
|
||||
@@ -126,10 +169,29 @@ fn main() {
|
||||
|
||||
if music_paths.is_empty() {
|
||||
music_paths.push(PathBuf::from(&music_path));
|
||||
if !music_1_path.is_empty() {
|
||||
music_paths.push(PathBuf::from(&music_1_path));
|
||||
}
|
||||
if !music_2_path.is_empty() {
|
||||
music_paths.push(PathBuf::from(&music_2_path));
|
||||
}
|
||||
if !music_3_path.is_empty() {
|
||||
music_paths.push(PathBuf::from(&music_3_path));
|
||||
}
|
||||
if !music_4_path.is_empty() {
|
||||
music_paths.push(PathBuf::from(&music_4_path));
|
||||
}
|
||||
}
|
||||
|
||||
let mut thread_limit:usize = num_cpus::get() as usize;
|
||||
if max_threads<0 && (max_threads.abs() as usize) < thread_limit-1 {
|
||||
thread_limit = thread_limit - (max_threads.abs() as usize)
|
||||
} else if max_threads>0 && (max_threads as usize) < thread_limit {
|
||||
thread_limit = max_threads as usize;
|
||||
}
|
||||
|
||||
if task.eq_ignore_ascii_case("stopmixer") {
|
||||
upload::stop_mixer(&lms_host);
|
||||
upload::stop_mixer(&lms_host, lms_json_port);
|
||||
} else {
|
||||
if db_path.len() < 3 {
|
||||
log::error!("Invalid DB path ({}) supplied", db_path);
|
||||
@@ -144,7 +206,7 @@ fn main() {
|
||||
|
||||
if task.eq_ignore_ascii_case("upload") {
|
||||
if path.exists() {
|
||||
upload::upload_db(&db_path, &lms_host);
|
||||
upload::upload_db(&db_path, &lms_host, lms_json_port);
|
||||
} else {
|
||||
log::error!("DB ({}) does not exist", db_path);
|
||||
process::exit(-1);
|
||||
@@ -162,7 +224,7 @@ fn main() {
|
||||
}
|
||||
|
||||
if task.eq_ignore_ascii_case("tags") {
|
||||
analyse::read_tags(&db_path, &music_paths);
|
||||
db::read_tags(&db_path, &music_paths);
|
||||
} else if task.eq_ignore_ascii_case("ignore") {
|
||||
let ignore_path = PathBuf::from(&ignore_file);
|
||||
if !ignore_path.exists() {
|
||||
@@ -173,9 +235,19 @@ fn main() {
|
||||
log::error!("Ignore file ({}) is not a file", ignore_file);
|
||||
process::exit(-1);
|
||||
}
|
||||
analyse::update_ignore(&db_path, &ignore_path);
|
||||
db::update_ignore(&db_path, &ignore_path);
|
||||
} else if task.eq_ignore_ascii_case("export") {
|
||||
db::export(&db_path, &music_paths, thread_limit, preserve_mod_times);
|
||||
} else if task.eq_ignore_ascii_case("clearfail") {
|
||||
db::clear_failures(&db_path);
|
||||
} else {
|
||||
analyse::analyse_files(&db_path, &music_paths, dry_run, keep_old, max_num_files);
|
||||
let ignore_path = PathBuf::from(&ignore_file);
|
||||
let modified = analyse::analyse_files(&db_path, &music_paths, dry_run, keep_old, max_num_files, thread_limit,
|
||||
&ignore_path, read_tags, write_tags, preserve_mod_times, &lms_host, lms_json_port,
|
||||
send_notifs);
|
||||
if modified && task.eq_ignore_ascii_case("analyse-lms") && path.exists() {
|
||||
upload::stop_mixer(&lms_host, lms_json_port);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+201
-10
@@ -1,39 +1,186 @@
|
||||
/**
|
||||
* Analyse music with Bliss
|
||||
*
|
||||
* Copyright (c) 2022 Craig Drummond <craig.p.drummond@gmail.com>
|
||||
* Copyright (c) 2022-2025 Craig Drummond <craig.p.drummond@gmail.com>
|
||||
* GPLv3 license.
|
||||
*
|
||||
**/
|
||||
|
||||
use crate::db;
|
||||
use lofty::{Accessor, AudioFile, ItemKey};
|
||||
use lofty::config::WriteOptions;
|
||||
use lofty::file::FileType;
|
||||
use lofty::prelude::{Accessor, AudioFile, ItemKey, TagExt, TaggedFileExt};
|
||||
use lofty::tag::{ItemValue, Tag, TagItem};
|
||||
use regex::Regex;
|
||||
use std::fs::File;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use substring::Substring;
|
||||
use std::time::SystemTime;
|
||||
use bliss_audio::{Analysis, AnalysisIndex, FeaturesVersion};
|
||||
|
||||
const MAX_GENRE_VAL: usize = 192;
|
||||
const NUM_ANALYSIS_VALS: usize = 23;
|
||||
const ANALYSIS_TAG: &str = "BLISS_ANALYSIS";
|
||||
const ANALYSIS_TAG_FORMAT_VER: u16 = 2;
|
||||
|
||||
pub fn read(track: &String) -> db::Metadata {
|
||||
fn fmt(val: f32) -> String {
|
||||
format!("{:.16}", val).trim_end_matches("0").to_string()
|
||||
}
|
||||
|
||||
pub fn write_analysis(track: &String, analysis: &Analysis, preserve_mod_times: bool) -> bool {
|
||||
let value = format!("{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}", ANALYSIS_TAG_FORMAT_VER,
|
||||
fmt(analysis[AnalysisIndex::Tempo]), fmt(analysis[AnalysisIndex::Zcr]), fmt(analysis[AnalysisIndex::MeanSpectralCentroid]), fmt(analysis[AnalysisIndex::StdDeviationSpectralCentroid]), fmt(analysis[AnalysisIndex::MeanSpectralRolloff]),
|
||||
fmt(analysis[AnalysisIndex::StdDeviationSpectralRolloff]), fmt(analysis[AnalysisIndex::MeanSpectralFlatness]), fmt(analysis[AnalysisIndex::StdDeviationSpectralFlatness]), fmt(analysis[AnalysisIndex::MeanLoudness]), fmt(analysis[AnalysisIndex::StdDeviationLoudness]),
|
||||
fmt(analysis[AnalysisIndex::Chroma1]), fmt(analysis[AnalysisIndex::Chroma2]), fmt(analysis[AnalysisIndex::Chroma3]), fmt(analysis[AnalysisIndex::Chroma4]), fmt(analysis[AnalysisIndex::Chroma5]),
|
||||
fmt(analysis[AnalysisIndex::Chroma6]), fmt(analysis[AnalysisIndex::Chroma7]), fmt(analysis[AnalysisIndex::Chroma8]), fmt(analysis[AnalysisIndex::Chroma9]), fmt(analysis[AnalysisIndex::Chroma10]),
|
||||
fmt(analysis[AnalysisIndex::Chroma11]), fmt(analysis[AnalysisIndex::Chroma12]), fmt(analysis[AnalysisIndex::Chroma13]));
|
||||
|
||||
let mut written = false;
|
||||
if let Ok(mut file) = lofty::read_from_path(Path::new(track)) {
|
||||
let tag = match file.primary_tag_mut() {
|
||||
Some(primary_tag) => primary_tag,
|
||||
None => {
|
||||
if let Some(first_tag) = file.first_tag_mut() {
|
||||
first_tag
|
||||
} else {
|
||||
let tag_type = file.primary_tag_type();
|
||||
file.insert_tag(Tag::new(tag_type));
|
||||
file.primary_tag_mut().unwrap()
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Store analysis results
|
||||
let tag_key = ItemKey::Unknown(ANALYSIS_TAG.to_string());
|
||||
tag.remove_key(&tag_key);
|
||||
let lower_tag_key = ItemKey::Unknown(ANALYSIS_TAG.to_lowercase().to_string());
|
||||
tag.remove_key(&lower_tag_key);
|
||||
tag.insert_unchecked(TagItem::new(tag_key, ItemValue::Text(value)));
|
||||
|
||||
// If we have any of the older analysis-in-comment tags, then remove these
|
||||
let entries = tag.get_strings(&ItemKey::Comment);
|
||||
let mut keep: Vec<ItemValue> = Vec::new();
|
||||
let mut have_old = false;
|
||||
for entry in entries {
|
||||
if entry.starts_with(ANALYSIS_TAG) {
|
||||
have_old = true;
|
||||
} else {
|
||||
keep.push(ItemValue::Text(entry.to_string()));
|
||||
}
|
||||
}
|
||||
if have_old {
|
||||
tag.remove_key(&ItemKey::Comment);
|
||||
for k in keep {
|
||||
tag.push(TagItem::new(ItemKey::Comment, k));
|
||||
}
|
||||
}
|
||||
|
||||
let now = SystemTime::now();
|
||||
let mut mod_time = now;
|
||||
|
||||
if preserve_mod_times {
|
||||
if let Ok(fmeta) = fs::metadata(track) {
|
||||
if let Ok(time) = fmeta.modified() {
|
||||
mod_time = time;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(_) = tag.save_to_path(Path::new(track), WriteOptions::default()) {
|
||||
if preserve_mod_times {
|
||||
if mod_time<now {
|
||||
if let Ok(f) = File::open(track) {
|
||||
let _ = f.set_modified(mod_time);
|
||||
}
|
||||
}
|
||||
}
|
||||
written = true;
|
||||
}
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
fn read_analysis_string(tag_str: &str, start_tag_pos:usize, version_pos:usize) -> Option<Analysis> {
|
||||
let parts = tag_str.split(",");
|
||||
let mut index = 0;
|
||||
let mut num_read_vals = 0;
|
||||
let mut vals = [0.; NUM_ANALYSIS_VALS];
|
||||
let val_start_pos = version_pos+1;
|
||||
for part in parts {
|
||||
if index==start_tag_pos && start_tag_pos<version_pos {
|
||||
if part!=ANALYSIS_TAG {
|
||||
break;
|
||||
}
|
||||
} else if index==version_pos {
|
||||
match part.parse::<u16>() {
|
||||
Ok(ver) => {
|
||||
if ver!=ANALYSIS_TAG_FORMAT_VER {
|
||||
break;
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (index - val_start_pos) < NUM_ANALYSIS_VALS {
|
||||
match part.parse::<f32>() {
|
||||
Ok(val) => {
|
||||
num_read_vals += 1;
|
||||
vals[index - val_start_pos] = val;
|
||||
},
|
||||
Err(_) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
if num_read_vals == NUM_ANALYSIS_VALS {
|
||||
return Some(Analysis::new(vals.to_vec(), FeaturesVersion::LATEST).expect("number of vals should be 23"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn read(track: &String, read_analysis: bool) -> db::Metadata {
|
||||
let mut meta = db::Metadata {
|
||||
duration: 180,
|
||||
..db::Metadata::default()
|
||||
};
|
||||
|
||||
if let Ok(file) = lofty::read_from_path(Path::new(track), true) {
|
||||
if let Ok(file) = lofty::read_from_path(Path::new(track)) {
|
||||
let tag = match file.primary_tag() {
|
||||
Some(primary_tag) => primary_tag,
|
||||
None => file.first_tag().expect("Error: No tags found!"),
|
||||
None => {
|
||||
if let Some(first_tag) = file.first_tag() {
|
||||
first_tag
|
||||
} else {
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
meta.title = tag.title().unwrap_or_default().to_string();
|
||||
meta.artist = tag.artist().unwrap_or_default().to_string();
|
||||
meta.album = tag.album().unwrap_or_default().to_string();
|
||||
meta.album_artist = tag.get_string(&ItemKey::AlbumArtist).unwrap_or_default().to_string();
|
||||
meta.genre = tag.genre().unwrap_or_default().to_string();
|
||||
|
||||
// Check whether MP3 as numeric genre, and if so covert to text
|
||||
if file.file_type().eq(&lofty::FileType::MP3) {
|
||||
// If file has multiple genre tags then read all.
|
||||
let genres = tag.get_strings(&ItemKey::Genre);
|
||||
let mut genre_list:Vec<String> = Vec::new();
|
||||
|
||||
for genre in genres {
|
||||
genre_list.push(genre.to_string());
|
||||
}
|
||||
if genre_list.len()>1 {
|
||||
meta.genre = genre_list.join(";");
|
||||
} else {
|
||||
meta.genre = tag.genre().unwrap_or_default().to_string();
|
||||
}
|
||||
|
||||
// Check whether MP3 has numeric genre, and if so covert to text
|
||||
if file.file_type().eq(&FileType::Mpeg) {
|
||||
match tag.genre() {
|
||||
Some(genre) => {
|
||||
let test = genre.parse::<u8>();
|
||||
@@ -55,8 +202,7 @@ pub fn read(track: &String) -> db::Metadata {
|
||||
if let Ok(val) = test {
|
||||
let idx: usize = val as usize;
|
||||
if idx < MAX_GENRE_VAL {
|
||||
meta.genre =
|
||||
lofty::id3::v1::GENRES[idx].to_string();
|
||||
meta.genre = lofty::id3::v1::GENRES[idx].to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +217,51 @@ pub fn read(track: &String) -> db::Metadata {
|
||||
}
|
||||
|
||||
meta.duration = file.properties().duration().as_secs() as u32;
|
||||
|
||||
if read_analysis {
|
||||
match tag.get_string(&ItemKey::Unknown(ANALYSIS_TAG.to_string())) {
|
||||
Some(tag_str) => {
|
||||
match read_analysis_string(tag_str, 100, 0) {
|
||||
Some(analysis) => {
|
||||
meta.analysis = Some(analysis);
|
||||
}
|
||||
None => { }
|
||||
}
|
||||
}
|
||||
None => { }
|
||||
}
|
||||
|
||||
if meta.analysis.is_none() {
|
||||
// Try lowercase
|
||||
match tag.get_string(&ItemKey::Unknown(ANALYSIS_TAG.to_lowercase().to_string())) {
|
||||
Some(tag_str) => {
|
||||
match read_analysis_string(tag_str, 100, 0) {
|
||||
Some(analysis) => {
|
||||
meta.analysis = Some(analysis);
|
||||
}
|
||||
None => { }
|
||||
}
|
||||
}
|
||||
None => { }
|
||||
}
|
||||
}
|
||||
|
||||
if meta.analysis.is_none() {
|
||||
// Try old, stored in comment
|
||||
let entries = tag.get_strings(&ItemKey::Comment);
|
||||
for entry in entries {
|
||||
if entry.len()>(ANALYSIS_TAG.len()+(NUM_ANALYSIS_VALS*8)) && entry.starts_with(ANALYSIS_TAG) {
|
||||
match read_analysis_string(entry, 0, 1) {
|
||||
Some(analysis) => {
|
||||
meta.analysis = Some(analysis);
|
||||
break;
|
||||
}
|
||||
None => { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
meta
|
||||
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Analyse music with Bliss
|
||||
*
|
||||
* Copyright (c) 2022 Craig Drummond <craig.p.drummond@gmail.com>
|
||||
* Copyright (c) 2022-2025 Craig Drummond <craig.p.drummond@gmail.com>
|
||||
* GPLv3 license.
|
||||
*
|
||||
**/
|
||||
@@ -17,24 +17,24 @@ fn fail(msg: &str) {
|
||||
process::exit(-1);
|
||||
}
|
||||
|
||||
pub fn stop_mixer(lms: &String) {
|
||||
pub fn stop_mixer(lms_host: &String, json_port: u16) {
|
||||
let stop_req = "{\"id\":1, \"method\":\"slim.request\",\"params\":[\"\",[\"blissmixer\",\"stop\"]]}";
|
||||
|
||||
log::info!("Asking plugin to stop mixer");
|
||||
let req = ureq::post(&format!("http://{}:9000/jsonrpc.js", lms)).send_string(&stop_req);
|
||||
let req = ureq::post(&format!("http://{}:{}/jsonrpc.js", lms_host, json_port)).send_string(&stop_req);
|
||||
if let Err(e) = req {
|
||||
log::error!("Failed to ask plugin to stop mixer. {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upload_db(db_path: &String, lms: &String) {
|
||||
pub fn upload_db(db_path: &String, lms_host: &String, json_port: u16) {
|
||||
// First tell LMS to restart the mixer in upload mode
|
||||
let start_req = "{\"id\":1, \"method\":\"slim.request\",\"params\":[\"\",[\"blissmixer\",\"start-upload\"]]}";
|
||||
let mut port: u16 = 0;
|
||||
|
||||
log::info!("Requesting LMS plugin to allow uploads");
|
||||
|
||||
match ureq::post(&format!("http://{}:9000/jsonrpc.js", lms)).send_string(&start_req) {
|
||||
match ureq::post(&format!("http://{}:{}/jsonrpc.js", lms_host, json_port)).send_string(&start_req) {
|
||||
Ok(resp) => match resp.into_string() {
|
||||
Ok(text) => match text.find("\"port\":") {
|
||||
Some(s) => {
|
||||
@@ -69,13 +69,13 @@ pub fn upload_db(db_path: &String, lms: &String) {
|
||||
Ok(meta) => {
|
||||
let buffered_reader = BufReader::new(file);
|
||||
log::info!("Length: {}", meta.len());
|
||||
match ureq::put(&format!("http://{}:{}/upload", lms, port))
|
||||
match ureq::put(&format!("http://{}:{}/upload", lms_host, port))
|
||||
.set("Content-Length", &meta.len().to_string())
|
||||
.set("Content-Type", "application/octet-stream")
|
||||
.send(buffered_reader) {
|
||||
Ok(_) => {
|
||||
log::info!("Database uploaded");
|
||||
stop_mixer(lms);
|
||||
stop_mixer(lms_host, json_port);
|
||||
}
|
||||
Err(e) => { fail(&format!("Failed to upload database. {}", e)); }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user