238 Commits
Author SHA1 Message Date
CDrummond d42f28d1f2 Update for reduced set of builds. 2025-09-23 17:07:46 +01:00
CDrummond 0cf766cfeb Tidy up 2025-09-23 16:47:52 +01:00
CDrummond 3f997b979b Drop support for using ffmpeg via commandline. 2025-09-22 21:12:48 +01:00
CDrummond 39229efd99 Refactor max threads 2025-09-22 20:33:57 +01:00
CDrummond 50c564cde0 Add option to remove failures 2025-09-22 12:26:04 +01:00
CDrummond 0202c68579 Seprate out read and write tags options 2025-09-21 17:06:29 +01:00
CDrummond f27804a2bb Only read tags if also set to write? 2025-09-21 16:27:53 +01:00
CDrummond 9a7f316419 Change notif text 2025-09-21 16:22:21 +01:00
CDrummond bc68f73de9 Store file failures in DB so don't re-analyse unless timestamp of file changes. 2025-09-21 12:02:26 +01:00
CDrummond 5a11b8b3e5 Oops! 2025-09-21 09:44:38 +01:00
CDrummond b1508d27c5 Strip windows builds? 2025-09-21 09:33:56 +01:00
CDrummond 172bcac11a Update 2025-09-20 12:44:46 +01:00
CDrummond f6d99afd9e Just set URL 2025-09-20 12:26:30 +01:00
CDrummond 9d4db09660 Windows ffmpeg version 2025-09-20 12:10:57 +01:00
CDrummond f6d15c41c5 Re-enable more builds 2025-09-20 09:24:06 +01:00
CDrummond 30d5b15f08 Fix staticlibav 2025-09-20 08:45:03 +01:00
CDrummond 23d491db55 Multi-threaded read of existing analysis tags. 2025-09-19 18:19:51 +01:00
CDrummond 50331e518a Send FINISHED notif when complete. 2025-09-19 12:03:02 +01:00
CDrummond d046add1ea Better notifications 2025-09-18 12:22:07 +01:00
CDrummond 8a26eea76a Send notifications every 2(?) seconds 2025-09-17 21:16:45 +01:00
CDrummond cb4e7e97f9 Fix updates 2025-09-17 17:53:06 +01:00
CDrummond bb4c268f82 Add sending of notifications 2025-09-17 13:16:42 +01:00
CDrummond e7a9156289 Use latest ubuntu 2025-09-16 18:16:37 +01:00
CDrummond 8a54a38673 Use Windows 2022 2025-09-16 16:08:19 +01:00
CDrummond 6af1865691 Reason strings - not sure if correct!!! 2025-09-16 12:38:43 +01:00
CDrummond 75955c15cb Updates to support invoking from LMS plugin. 2025-09-16 12:23:07 +01:00
CDrummond 4f74a6ea58 Update deps 2025-09-16 12:22:35 +01:00
CDrummond 9709909378 Fix ffmpeg build 2025-09-16 12:22:17 +01:00
CDrummond b8a2f248af Update to newer bliss-rs, and default to Symphonia 2025-09-15 19:37:29 +01:00
CDrummond 7bcc176ac2 Set log level for tag reader library. 2025-09-12 19:37:31 +01:00
CraigD e488903d7f Merge pull request #32 from chincheta0815/docker_updates
change docker commands to get more images updates
2025-06-15 12:25:22 +00:00
CDrummond 2d2ddc7b17 Don't crash if file has no tags.
Closes #33
2025-06-15 13:21:49 +01:00
chincheta0815@nowhere.local 9e107c5fc2 change docker commands to get more images updates 2025-06-08 08:20:50 +02:00
CDrummond 4714f8f4ef Formatting 2025-05-26 17:57:25 +01:00
CDrummond fd38256dc0 Handle both BLISS_ANALYSIS and bliss_analysis
Issue #31
2025-05-26 17:38:00 +01:00
CDrummond b4eb7562cf Gracefully handle Ctrl-C. 2025-05-22 12:07:24 +01:00
CDrummond c7d436c13b Spelling 2025-05-22 12:07:04 +01:00
CDrummond e04b22ebcc Strip debug info from release binaries. 2025-05-20 12:10:00 +01:00
CDrummond 6055fa2af7 Consistent status messages 2025-05-20 12:09:40 +01:00
CDrummond 0a1b9d8790 Formatting 2025-05-20 12:09:25 +01:00
CDrummond e395ff515b Consistency 2025-05-20 12:09:11 +01:00
CDrummond 4794a9b747 Remove --read-tags and --write-tags. Always read tags, so --tags just
controls writing.
Issue #29
2025-05-20 12:08:20 +01:00
CDrummond b623c1939b No need to mention multi threaded export, as export itself is new to
this release.
2025-05-19 12:08:00 +01:00
CDrummond de232d2a33 Use max of 16 significant digits (traling zeros stripped) when writing analysis results to file tags.
Issue #26
2025-05-19 12:07:28 +01:00
CDrummond 4830ef92b3 Make constant name more meaningful 2025-05-19 12:06:36 +01:00
CDrummond 520190c52e When writing new BLISS_ANALYSIS tag remove any old comment tags.
Issue #27
2025-05-19 12:06:01 +01:00
CDrummond d4f32d8b4d Next version is 0.4.0, not 0.5.0!!! 2025-05-15 13:53:21 +01:00
CDrummond 0250117b15 Add --read-tags and --write-tags commandline options.
Closes #25
2025-05-15 12:04:49 +01:00
CDrummond d2b3db43f7 Move functions to where they belong. 2025-05-15 12:04:04 +01:00
CDrummond 2759f7f47c Multi-thread DB export.
Closes #24
2025-05-15 12:03:47 +01:00
CDrummond a79712cb24 Check for multiple genre tags, and if found add as semi-colon separated list to DB. 2025-05-14 12:23:45 +01:00
CDrummond 5722b04c85 - Store analysis in BLISS_ANALYSIS tag, not in a COMMENT tag.
- Fix read of tags during analysis phase.
Closes #23
2025-05-13 12:25:50 +01:00
CDrummond 50a55ee61a ...and again... 2025-05-01 07:16:40 +01:00
CDrummond b5c9356ef2 Fix formatting. 2025-05-01 07:15:39 +01:00
CDrummond 9d9117e8f7 Allow "export" !!! 2025-03-24 17:07:26 +00:00
CDrummond 417ac5f652 Add option to preserve file modification time when writing tags.
Issue #21
2025-03-24 07:17:51 +00:00
CDrummond 342440f04b Add action to export analysis results from DB to files.
Issue #20
2025-03-24 06:47:35 +00:00
CraigD 85a2e985bf Merge pull request #19 from Polochon-street/add-rpi-feature
Add rpi feature
2025-03-20 17:46:51 +00:00
Polochon_street c999297430 Add rpi feature 2025-03-20 18:13:03 +01:00
CDrummond db9b12be3f Update 2025-03-17 18:16:39 +00:00
CDrummond 2468a83d4b Reduce code duplication 2025-03-17 17:55:24 +00:00
CDrummond 22149c2349 Document build steps for Pi 2025-03-16 17:04:07 +00:00
CDrummond 0dda8a17e7 bliss-rs 0.10.0 2025-03-16 08:17:48 +00:00
CDrummond a2ff1aade1 Specify git rev until release made 2025-03-15 10:59:40 +00:00
CDrummond 471063b53d Copy docs and example config for mac builds! 2025-03-15 10:22:34 +00:00
CDrummond 94011d12ea _Slightly_ slower 2025-03-15 09:33:32 +00:00
CDrummond 2210c44614 Quiten symphonia log messages 2025-03-15 09:27:35 +00:00
CDrummond 00be6015f7 Symphonia is not actually that slow... 2025-03-15 09:24:52 +00:00
CDrummond e4522cc683 Add symphonia builds 2025-03-14 12:17:52 +00:00
CDrummond 5c7b33a5d3 Use correct name 2025-03-09 15:22:34 +00:00
CDrummond 6fd78df3ef Fix build 2025-03-09 13:26:39 +00:00
CDrummond f23202e956 Document tags option 2025-03-09 13:26:21 +00:00
CDrummond 7ea6c3befd Add excplcit 'ffmpeg' feature flag. 2025-03-09 08:02:37 +00:00
CDrummond 302609ed1b Update lofty version 2025-03-05 19:10:47 +00:00
CDrummond 5e5bd9841a ...and use ffprobe when updating tags. 2025-03-05 18:43:05 +00:00
CDrummond 32610f22b7 Use ffprobe to read meta-data if not compiled against libav
Closes #18
2025-03-05 18:39:54 +00:00
CDrummond 751dca7091 Enable DSF support for libav builds 2025-03-05 17:58:19 +00:00
CDrummond ac87ebb66c When saving anlysis results to a comment tag, remove any previous
results.
2025-03-05 17:40:25 +00:00
CDrummond 98b9cd61e0 Only stop when found correct BLISS_ANALYSIS comment 2025-03-05 17:37:51 +00:00
CDrummond 786b7d2c2d If log level set to 'trace' then set this level for the Bliss library too.
Issue #17
2025-03-05 17:09:52 +00:00
CDrummond 1e00e9593a Spelling 2025-03-05 17:01:48 +00:00
CDrummond 0d877fe79e Save analysis in a new COMMENT tag.
Closes #4
2025-03-04 21:57:06 +00:00
CDrummond 6cc26c399e Add option to write analysis results to files, and use for future scans.
Issue #4
2025-03-04 20:04:37 +00:00
CDrummond bff4ba18b4 Update (C) year 2025-03-04 19:17:24 +00:00
CDrummond 6fc9ac7f2e Fix output format 2025-03-04 19:15:12 +00:00
CDrummond 3a387be3bf If new files analysed and 'ignore' file exists then update DB's 'ignore' flags.
Closes #9
2025-03-04 19:06:59 +00:00
CDrummond 2cb7dc0fa0 Add ability to specify LMS JSONRPC port.
Closes #16
2025-03-04 19:01:20 +00:00
CDrummond b91c2edafd Spelling 2025-03-04 18:06:33 +00:00
CDrummond f559abb395 Mention statically linked variant. 2025-03-04 17:59:15 +00:00
CDrummond cca929a13e Need to use libav with staticlibav 2025-03-04 17:40:02 +00:00
CDrummond 6dcd61d9bb Remove bliss-analyser-mac-libav 2025-03-04 13:51:08 +00:00
CDrummond f8ea9947ac Nope, cant build ffmpeg for macOS :( 2025-03-04 13:46:13 +00:00
CDrummond b275568000 Static libav failed on macOS, try dynamic? 2025-03-04 13:25:45 +00:00
CDrummond 0249bf8edb nasm for macOS 2025-03-04 13:10:11 +00:00
CDrummond 6d5b7c80ef Fix syntax 2025-03-04 12:48:44 +00:00
CDrummond b078736224 staticlibav 2025-03-04 12:47:21 +00:00
CDrummond a610dab7c0 ...and for other binary! 2025-03-03 22:09:42 +00:00
CDrummond 05fb19e2f9 update-aubio-bindings 3rd try... 2025-03-03 22:06:00 +00:00
CDrummond 375abe91d0 bliss-audio-aubio-sys/bindgen? 2025-03-03 22:01:39 +00:00
CDrummond f9984e8ca0 bindgen? 2025-03-03 21:57:16 +00:00
CDrummond 73d4ae72cd Fix job IDs 2025-03-03 21:48:00 +00:00
CDrummond 92c2a90d25 Fix indentation 2025-03-03 21:45:09 +00:00
CDrummond dbe36c35db Fat binaries for macOS 2025-03-02 22:56:15 +00:00
CDrummond decbbd18e5 Update 2025-03-02 22:55:55 +00:00
CDrummond 36f44d6550 Mention libav/ffmpeg variants 2025-03-02 22:54:57 +00:00
CDrummond 01be5d107a Put libav/ffmpeg last 2025-03-02 22:44:48 +00:00
CDrummond 388e571571 Rename jobs, and artifacts, to match OS 2025-03-02 22:35:17 +00:00
CDrummond 5f247ef10f Fix build, as it was copied from ARM scripts. 2025-03-02 22:14:51 +00:00
CDrummond eb7214644a Oops! Should not have been added! 2025-03-02 21:50:00 +00:00
CDrummond 25362b9635 Rename jobs 2025-03-03 21:38:21 +00:00
CDrummond 4a98282ce2 Fix names, again! 2025-03-03 21:37:01 +00:00
CDrummond 331d770d46 Fix target 2025-03-03 21:28:43 +00:00
CDrummond 0d1c33b131 Fix command 2025-03-03 21:23:01 +00:00
CDrummond c9d72b3f71 Fix artifact name 2025-03-03 21:22:50 +00:00
CDrummond bfd73ea0bc actions/upload-artifact@v4 2025-03-03 21:15:34 +00:00
CDrummond b643272d1f Update versions 2025-03-03 21:13:51 +00:00
CDrummond 9321be0ad5 Build libav and ffmpeg variants 2025-03-03 18:29:06 +00:00
CDrummond 55f944fbdd Remove macOS deps 2025-03-03 18:09:30 +00:00
CDrummond d7d7c87582 Re-enable mac builds, but use ffmpeg from commandline 2025-03-03 17:34:42 +00:00
CDrummond 4e6d522829 Update github workflow scripts 2025-03-03 17:33:12 +00:00
CDrummond 25f399f932 Document libav feature 2025-03-03 17:26:38 +00:00
CDrummond a5db8f48ce Reduce duplication 2025-03-03 17:14:31 +00:00
CDrummond 136651ada7 Add cargo features flag to enable libav usage. 2025-03-03 17:06:09 +00:00
CDrummond e5ef67f8c6 Sort directory items, and do limiting when collecting files to analyse. 2025-03-02 22:10:55 +00:00
CDrummond 6b9cb960a9 Add CUE support for commandline ffmpeg usage. 2025-03-02 20:57:59 +00:00
CDrummond c6f9a7faf5 Only invoke "stty sane" if not Windows. 2025-03-02 20:56:47 +00:00
CDrummond 2ac4b9d17f Avoid pushing and reallocating all the time. 2025-03-02 17:29:17 +00:00
CDrummond 7071ea14e7 Reset terminal 2025-03-02 09:40:57 +00:00
CDrummond e23aba88e3 Have ffmpeg output to a pipe, and read from that - saves creating temp
files.
2025-03-02 08:57:08 +00:00
CDrummond 5fa745975c Check ffmpeg is in PATH 2025-03-01 16:06:07 +00:00
CDrummond 22133436b9 Use 'ffmpeg' commandline to decode files, and not ffmpeg libraries. 2025-03-01 15:45:47 +00:00
CDrummond 68ed1fc3b0 Update version of bliss library. 2024-11-02 08:58:50 +00:00
CDrummond 3b465543d7 Update 2024-04-28 13:32:20 +01:00
CraigD 56ed0b9045 Merge pull request #13 from terual/wavpack
Add support for (DSD) WavPack
2024-04-28 12:30:47 +00:00
Bart Lauret 62c956bc78 Add support for (DSD) WavPack
- Added wv as valid extension
- lofty had incorrect duration scanning for DSD Wavpack in version 0.15.0, so version bumped to 0.16.0
2024-04-28 11:55:34 +02:00
CraigD a880fff993 Merge pull request #12 from chincheta0815/docker_ubuntu
make ubuntu a docker build for ffmpeg5
2024-02-22 17:37:47 +00:00
chincheta0815 777d8e3c6c make ubuntu a docker build for ffmpeg5 2024-02-22 07:00:03 +01:00
CraigD 319f72d2a4 Merge pull request #11 from chincheta0815/bookworm_and_bullseye
add builds for bullseye which requires newer ffmpeg
2024-02-16 17:34:06 +00:00
chincheta0815 297ed3304e add builds for bullseye which requires newer ffmpeg 2024-02-16 15:13:06 +01:00
CDrummond 04baedbfc2 Don't try to download ffmpeg5 or macOS builds - currently broken 2023-08-11 17:16:41 +01:00
CDrummond 609b6faff9 So, ffmpeg5 ppa is now paywalled so cannot use. 2023-08-10 21:25:21 +01:00
CDrummond fe65d35066 Revert ffmpeg apt steps, and disable macOS builds (failing!) 2023-08-10 21:07:23 +01:00
CDrummond 75aeee128f Use 22.04 for ffmpeg5? 2023-08-10 20:50:46 +01:00
CDrummond 65e40acce4 Update bliss and lofty libraries 2023-08-10 20:08:17 +01:00
CDrummond a9f819f39b Update version of bliss library. 2023-02-28 15:48:16 +00:00
CDrummond 4704624b2f Add option to limit number of concurrent threads. 2023-02-28 15:41:01 +00:00
CDrummond c3083d9a1a Update (C) year 2023-01-02 09:28:26 +00:00
CDrummond 90d4dfec23 Nope, revert back to bullseye as build fails on stretch 2022-12-20 21:50:30 +00:00
CDrummond e89aee1f4e Build on stretch? 2022-12-20 21:40:58 +00:00
CDrummond 703e39508c Update lofty to 0.9.0 2022-12-20 09:25:45 +00:00
CDrummond eb16d8379d Update bliss-rs 2022-10-22 11:49:39 +01:00
CDrummond a220b4d0c5 Update bliss-rs and lofty-rs 2022-10-04 17:06:52 +01:00
CDrummond 6d61616dda Update lofty version 2022-08-21 08:55:23 +01:00
CDrummond d85424d27c Update lofty to 0.7.3 2022-08-04 22:26:32 +01:00
CDrummond 144c8a7ffe Update version of tag reader library. 2022-07-03 20:17:36 +01:00
CDrummond ab0adc96ca Show analysis results on a new line after progress. Just show
"Finished!" as last progress message.
Issue #5
2022-06-19 17:53:24 +01:00
CDrummond 7cf62c3ad0 Fix chmod call 2022-06-18 10:18:46 +01:00
CDrummond d9841b3527 Copy files within docker 2022-06-18 10:00:10 +01:00
CDrummond 3b6872b2a0 Place ARM binaries in bin sub-folder 2022-06-18 09:05:00 +01:00
CDrummond 45b20902d6 Merge branch 'master' into linux-arm 2022-06-18 08:44:47 +01:00
CDrummond 9feb80bbd1 Download arm builds 2022-06-18 08:44:17 +01:00
CDrummond b9b8fdabfc Add script to launch arm for correct arch 2022-06-18 08:40:17 +01:00
CDrummond a818628706 Add other deps 2022-06-18 08:05:44 +01:00
CDrummond 370c533a49 Revery to using docker, and add changes from chincheta0815 - #3 2022-06-18 07:55:54 +01:00
CDrummond 9aae7825d0 No sudo on bullseye either... 2022-06-16 20:18:25 +01:00
CDrummond ebddf8febe Use sudo for mount 2022-06-16 19:35:43 +01:00
CDrummond 143fb2bf62 bullseye? 2022-06-16 19:34:42 +01:00
CDrummond 323978cbeb tmpfs? 2022-06-16 19:26:22 +01:00
CDrummond a749c03195 source $HOME/.cargo/env 2022-06-16 19:16:42 +01:00
CDrummond bfd2a44e2e Download rust? #3 2022-06-16 19:05:46 +01:00
CDrummond 943227c16b Update run-on-arch-action version. #3 2022-06-16 18:32:39 +01:00
CDrummond b7259cd0ae Install cargo? #3 2022-06-16 18:01:19 +01:00
CDrummond f023faa336 Alternate method 2022-06-13 13:21:25 +01:00
CDrummond 074be07d18 Rust 2021? 2022-06-13 13:08:18 +01:00
CDrummond a12971df17 Split docker runs? 2022-06-13 12:53:32 +01:00
CDrummond 8ba10a0f8e nightly? 2022-06-13 12:51:09 +01:00
CDrummond cf0e6cf5fb edition2021 requires nightly 2022-06-13 12:47:57 +01:00
CDrummond de2c2dfaa3 Add "cargo-features = ["edition2021"]" at top 2022-06-13 12:39:44 +01:00
CDrummond 1a45791e58 cargo-features = ["edition2021"] ???? 2022-06-13 12:24:23 +01:00
CDrummond b5f35dcf36 Build for Linux ARM 2022-06-13 12:02:25 +01:00
CDrummond cd12e805c3 More compact 2022-05-28 17:50:19 +01:00
CDrummond fa87a7e135 Fix checking if CUE already analysed.
Issue #2
2022-05-28 17:24:09 +01:00
CDrummond df17032377 Fix Job names 2022-05-26 12:08:52 +01:00
CDrummond e47c2b477e Single run command 2022-05-26 12:07:18 +01:00
CDrummond 133e3e2204 Produce ffmpeg 4 and 5 builds for Linux 2022-05-26 12:04:15 +01:00
CraigD 3dec74d328 Mention password protected LMS access 2022-05-21 10:11:17 +01:00
CDrummond cb323f3efe Increment version 2022-05-21 10:04:10 +01:00
CDrummond 69303d62c6 Update lofty 2022-05-21 09:30:09 +01:00
CDrummond f05e11da2e Add Fedora deps instructions 2022-05-21 09:24:00 +01:00
Craig Drummond 37947564fc Remove mention of temporary files, as this is no longer relevant. 2022-04-24 08:51:07 +01:00
Craig Drummond 8a5d9bc276 Don't need ffmpeg.exe for Windows builds, as bliss-rs does cue splitting 2022-04-24 08:35:27 +01:00
Craig Drummond 467e341cfb Update version of bliss-rs, this now handles CUE processing internally. 2022-04-18 20:20:32 +01:00
Craig Drummond 2788759829 More 'cargo fmt' reverts 2022-04-15 09:03:47 +01:00
Craig Drummond 0653e365b8 Undo some 'cargo fmt' changes. 2022-04-13 21:32:44 +01:00
Craig Drummond 985902325e Minor formatting mods 2022-04-13 21:07:09 +01:00
Craig Drummond f3903383c9 Update version of bliss-rs 2022-04-12 21:17:29 +01:00
Craig Drummond f13601f1c2 loft-rs 0.6.1 2022-04-10 20:27:29 +01:00
CDrummond dfb87881c9 Add colon 2022-04-06 13:23:41 +01:00
CDrummond b1f89050e2 Mention that must run from terminal window 2022-04-06 13:22:47 +01:00
CDrummond 94ef2e2c1f Show error if can't open/create database 2022-04-06 13:22:30 +01:00
CDrummond 7fb2e66c58 Update version of tag reader library, should now support ID3v2 in FLAC. 2022-04-06 13:10:05 +01:00
CDrummond 9f8ea6e490 Fix indentation 2022-03-23 20:16:27 +00:00
CDrummond 0aa10918ae Tidy up code, thanks to Serial-ATA 2022-03-23 20:15:54 +00:00
CraigD caa8f124a7 Merge pull request #1 from Serial-ATA/master
Cleanup
2022-03-23 07:34:44 +00:00
Serial dabbfdb206 More typos
Signed-off-by: Serial <69764315+Serial-ATA@users.noreply.github.com>
2022-03-22 19:08:50 -04:00
Serial 182d980395 Typos
Signed-off-by: Serial <69764315+Serial-ATA@users.noreply.github.com>
2022-03-22 19:07:59 -04:00
Serial c51de47696 Missing derive
Signed-off-by: Serial <69764315+Serial-ATA@users.noreply.github.com>
2022-03-22 18:05:39 -04:00
Serial f6edb983d3 Cleanup upload module
Signed-off-by: Serial <69764315+Serial-ATA@users.noreply.github.com>
2022-03-22 17:49:27 -04:00
Serial 625248fbc6 Cleanup tags module
Signed-off-by: Serial <69764315+Serial-ATA@users.noreply.github.com>
2022-03-22 17:44:40 -04:00
Serial 37d9f627ad Cleanup db module
Signed-off-by: Serial <69764315+Serial-ATA@users.noreply.github.com>
2022-03-22 17:41:02 -04:00
Serial b61aee0a6a Cleanup cue module
Signed-off-by: Serial <69764315+Serial-ATA@users.noreply.github.com>
2022-03-22 17:32:31 -04:00
Serial 51ce6cadc7 Cleanup analyse module
Signed-off-by: Serial <69764315+Serial-ATA@users.noreply.github.com>
2022-03-22 17:25:03 -04:00
Serial b097f06b5f Add Metadata::is_empty
Signed-off-by: Serial <69764315+Serial-ATA@users.noreply.github.com>
2022-03-22 17:05:33 -04:00
Serial e0a6759af5 fmt
Signed-off-by: Serial <69764315+Serial-ATA@users.noreply.github.com>
2022-03-22 17:02:08 -04:00
Craig Drummond 0153b85b65 Track limit should span normal & cue and over folders 2022-03-16 15:34:23 +00:00
Craig Drummond 311c5e4242 Only output music path if more than 1 configured 2022-03-16 10:40:30 +00:00
Craig Drummond 4f7850bc40 Merge branch 'master' into multi-folder 2022-03-16 10:37:28 +00:00
Craig Drummond e88c4446d2 Update 2022-03-16 10:30:06 +00:00
Craig Drummond 4addd27329 Revert back to x86 only mac binaries 2022-03-16 09:54:15 +00:00
Craig Drummond 0db7d882c2 Build fat binary for macOS. 2022-03-16 08:55:29 +00:00
Craig Drummond 7a6317b28c Use git version of tag reader library. 2022-03-15 20:17:54 +00:00
Craig Drummond 7998278c9b Sort paths 2022-03-15 16:37:40 +00:00
CDrummond 08e43bf609 When performing a dry-run analysis (--dry-run) print paths of all tracks to be analysed and to be removed. 2022-03-15 12:08:15 +00:00
CDrummond 302b0f9483 Output list of (up to 100) tracks that failed to analyse. 2022-03-15 12:07:24 +00:00
CDrummond a6284c3761 Add support for multiple music folders 2022-03-14 11:24:16 +00:00
Craig Drummond cf729aaef3 Simplify 2022-03-13 11:47:01 +00:00
Craig Drummond 2ff95c27c5 OK, ffmpeg needs a file extension 2022-03-13 11:39:52 +00:00
Craig Drummond 4c40a0b41b Dont transcode CUE tracks to MP3, just copy in original format - *much*
faster
2022-03-13 11:32:35 +00:00
Craig Drummond 649ce31907 Add some info on CUE file handling 2022-03-13 11:13:01 +00:00
Craig Drummond 6d53ac16a9 Make bliss-analyser in linux and mac zips executable 2022-03-13 11:06:46 +00:00
Craig Drummond a859f7ac0f If album missing, then set to audio filename (without extension) 2022-03-13 10:41:36 +00:00
Craig Drummond f3a86414dc If track artist is empty then set this to album_artist (if it is not
also empty)
2022-03-13 10:28:46 +00:00
Craig Drummond e8614340f0 Another attempt to fix terminal echo 2022-03-13 08:26:15 +00:00
Craig Drummond b361948396 Copy ffmpeg.exe into windows zip, as will be needed for cue files 2022-03-13 07:55:39 +00:00
Craig Drummond 25c01f4434 Number cue tracks from 1, should match LMS track number 2022-03-13 06:54:09 +00:00
Craig Drummond 5fef00c24a No tag error for cue 2022-03-12 23:44:36 +00:00
Craig Drummond ef772ad30f Fix ffmpeg beaking terminal echo 2022-03-12 23:43:05 +00:00
Craig Drummond caa77bd847 Add support for analysing cue tracks 2022-03-12 23:07:56 +00:00
22 changed files with 3131 additions and 995 deletions

No files matched your search

+140 -22
View File
@@ -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,13 +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
@@ -47,26 +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
@@ -76,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/
@@ -92,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/
@@ -102,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
+1153 -466
View File
File diff suppressed because it is too large. Load diff
+30 -7
View File
@@ -1,26 +1,49 @@
[package]
name = "bliss-analyser"
version = "0.0.2"
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 = { git = "https://github.com/Polochon-street/bliss-rs", rev = "5f366b0" }
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.5.3"
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"
+73
View File
@@ -1,3 +1,76 @@
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, DB file.
4. Update version of bliss-rs, this now handles CUE processing internally.
0.1.0
-----
1. Add support for analysing CUE files.
2. Output list of (up to 100) tracks that failed to analyse.
3. When performing a dry-run analysis (--dry-run) print paths of all tracks to
be analysed and to be removed.
4. Use git version of tag reader library.
5. Support up to 5 music folders (music, music_1, music_2, music_3, and
music_4).
0.0.2
-----
1. Package vcruntime140.dll with Windows ZIP.
+76 -2
View File
@@ -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
+110 -61
View File
@@ -6,6 +6,9 @@ upload its database of music analysis to LMS. The `Bliss Mixer` LMS plugin can
then use this information to provide music mixes for LMS's `Don't Stop the Music`
feature.
**NOTE:** You must run this application from a terminal window (e.g. cmd.com or
PowerShell for Windows), as there is no graphical user interface.
Quick guide
@@ -13,9 +16,7 @@ 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]
@@ -23,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
@@ -98,18 +53,29 @@ music=/home/user/Music
db=bliss.db
lms=127.0.0.1
ignore=ignore.txt
readtags=true
writetags=true
```
The following items are supported:
* `music` specifies the location of your music collection - e.g. `c:\Users\user\Music`
for windows. This default to `Music` within the user's home folder.
for windows. This default to `Music` within the user's home folder. Up to 4 other
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
@@ -130,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
@@ -173,6 +149,31 @@ 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 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.
Exclude folders
---------------
@@ -244,6 +245,10 @@ entries):
.\bliss-analyser.exe tags
```
*NOTE* Tag re-reading is not implemented for CUE tracks. Therefore if you update
your CUE files you will need to remove these from the database and re-analyse,
or manually edit the meta-data in `bliss.db` using an SQLite GUI.
Ignoring tracks in mixes
@@ -286,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:
@@ -303,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
=======
+38
View File
@@ -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"]
+36
View File
@@ -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"]
+36
View File
@@ -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"]
+30
View File
@@ -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"]
+25
View File
@@ -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"]
+29
View File
@@ -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
+29
View File
@@ -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
+29
View File
@@ -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
+18
View File
@@ -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
+28 -5
View File
@@ -3,15 +3,21 @@
#
# 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.
#
import datetime, os, requests, sys, time
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,7 +70,24 @@ 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 UNIX_ARTIFACTS:
archive = "%s-%s.zip" % (a, version)
info("Making analyser executable in %s" % archive)
with tempfile.TemporaryDirectory() as td:
subprocess.call(["unzip", archive, "-d", td], shell=False)
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)
def checkVersion(version):
@@ -85,4 +108,4 @@ if version!="test":
checkVersion(version)
download_artifacts(version)
make_executable(version)
+10
View File
@@ -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
+406 -136
View File
@@ -1,181 +1,451 @@
/**
* 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 anyhow::{Result};
use bliss_audio::{library::analyze_paths_streaming};
use indicatif::{ProgressBar, ProgressStyle};
use std::convert::TryInto;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use crate::db;
use crate::tags;
use anyhow::Result;
use if_chain::if_chain;
use indicatif::{ProgressBar, ProgressStyle};
use std::collections::HashSet;
use std::convert::TryInto;
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_TAG_ERRORS_TO_SHOW:usize = 25;
const DONT_ANALYSE: &str = ".notmusic";
const MAX_ERRORS_TO_SHOW: usize = 100;
const MAX_TAG_ERRORS_TO_SHOW: usize = 50;
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:&PathBuf, path:&PathBuf, track_paths:&mut Vec<String>) {
if path.is_dir() {
match path.read_dir() {
Ok(items) => {
for item in items {
match item {
Ok(entry) => {
let pb = entry.path().to_path_buf();
if entry.path().is_dir() {
let mut check = pb.clone();
check.push(PathBuf::from(DONT_ANALYSE));
if check.exists() {
log::info!("Skipping '{}', found '{}'", pb.to_string_lossy(), DONT_ANALYSE);
} else {
get_file_list(db, mpath, &entry.path(), track_paths);
}
} else if entry.path().is_file() {
let e = pb.extension();
if e.is_some() {
let ext = e.unwrap().to_string_lossy();
if ext=="m4a" || ext=="mp3" || ext=="ogg" || ext=="flac" || ext=="opus" {
match pb.strip_prefix(mpath) {
Ok(stripped) => {
let mut cue = pb.clone();
cue.set_extension("cue");
if cue.exists() {
log::warn!("Found CUE album '{}' - not currently handled!", pb.to_string_lossy());
} else {
let spb = stripped.to_path_buf();
let sname = String::from(spb.to_string_lossy());
match db.get_rowid(&sname) {
Ok(id) => {
if id<=0 {
track_paths.push(String::from(pb.to_string_lossy()));
}
},
Err(_) => { }
}
}
},
Err(_) => { }
}
}
}
}
},
Err(_) => { }
}
}
},
Err(_) => { }
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
}
}
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(&notifs.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;
}
}
}
pub fn analyse_new_files(db:&db::Db, mpath: &PathBuf, track_paths:Vec<String>) -> Result<()> {
let total = track_paths.len();
let pb = ProgressBar::new(total.try_into().unwrap());
let style = ProgressStyle::default_bar()
.template("[{elapsed_precise}] [{bar:25}] {percent:>3}% {pos:>6}/{len:6} {wide_msg}")
.progress_chars("=> ");
pb.set_style(style);
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;
}
let results = analyze_paths_streaming(track_paths)?;
let mut analysed = 0;
let mut failed = 0;
let mut tag_error:Vec<String> = Vec::new();
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());
log::info!("Analysing new tracks");
for (path, result) in results {
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());
pb.set_message(format!("{}", sname));
match result {
Ok(track) => {
let cpath = String::from(path);
let meta = tags::read(&cpath);
if meta.title.is_empty() && meta.artist.is_empty() && meta.album.is_empty() && meta.genre.is_empty() {
tag_error.push(sname.clone());
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 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() && (max_num_files<=0 || *file_count<max_num_files) {
if_chain! {
if let Some(ext) = pb.extension();
let ext = ext.to_string_lossy();
if VALID_EXTENSIONS.contains(&&*ext);
if let Ok(stripped) = pb.strip_prefix(mpath);
then {
let sname = String::from(stripped.to_string_lossy());
let mut cue_file = pb.clone();
cue_file.set_extension("cue");
if cue_file.exists() {
// For cue files, check if first track is in DB
let mut cue_track_path = pb.clone();
let ext = pb.extension().unwrap().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 {
// 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;
}
}
}
}
}
}
}
}
}
db.add_track(&sname, &meta, &track.analysis);
analysed += 1;
},
Err(_) => {
failed += 1;
}
};
pb.inc(1);
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);
log::error!("Failed to analyse the following file(s):");
for err in failed {
log::error!(" {}", err);
}
if total > MAX_ERRORS_TO_SHOW {
log::error!(" + {} other(s)", total - MAX_ERRORS_TO_SHOW);
}
}
pb.finish_with_message(format!("{} Analysed. {} Failure(s).", analysed, failed));
if !tag_error.is_empty() {
let total = tag_error.len();
tag_error.truncate(MAX_TAG_ERRORS_TO_SHOW);
log::error!("Failed to read tags of the folling track(s):");
log::error!("Failed to read tags of the following file(s):");
for err in tag_error {
log::error!(" {}", err);
}
if total>MAX_TAG_ERRORS_TO_SHOW {
if total > MAX_TAG_ERRORS_TO_SHOW {
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, mpath: &PathBuf, dry_run:bool, keep_old:bool, max_num_tracks:usize) {
let mut track_paths:Vec<String> = Vec::new();
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 cur = PathBuf::from(mpath);
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();
log::info!("Looking for new tracks");
get_file_list(&mut db, mpath, &cur, &mut track_paths);
log::info!("Num new tracks: {}", track_paths.len());
if !dry_run && max_num_tracks>0 && track_paths.len()>max_num_tracks {
log::info!("Only analysing {} tracks", max_num_tracks);
track_paths.truncate(max_num_tracks);
}
if !keep_old {
db.remove_old(mpath, dry_run);
send_notif(&mut notifs, "Removing old files from DB");
db.remove_old(mpaths, dry_run);
}
if !dry_run {
if track_paths.len()>0 {
match analyse_new_files(&db, mpath, track_paths) {
Ok(_) => { },
Err(_) => { }
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;
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!("New files: {}", track_paths.len());
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 {
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 !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");
}
}
}
} else {
log::info!("No new tracks to analyse");
}
}
db.close();
}
pub fn read_tags(db_path: &str, mpath: &PathBuf) {
let db = db::Db::new(&String::from(db_path));
db.init();
db.update_tags(&mpath);
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();
for (_index, line) in reader.lines().enumerate() {
let line = line.unwrap();
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
}
+423 -135
View File
@@ -1,53 +1,104 @@
/**
* 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 bliss_audio::{Analysis, AnalysisIndex};
use indicatif::{ProgressBar, ProgressStyle};
use rusqlite::{Connection, params};
use std::convert::TryInto;
use std::path::{Path, PathBuf};
use std::process;
use crate::tags;
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.";
pub struct FileMetadata {
pub rowid:usize,
pub file:String,
pub title:Option<String>,
pub artist:Option<String>,
pub album_artist:Option<String>,
pub album:Option<String>,
pub genre:Option<String>,
pub duration:u32
pub rowid: usize,
pub file: String,
pub title: Option<String>,
pub artist: Option<String>,
pub album_artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
pub duration: u32,
}
#[derive(Clone)]
struct AnalysisResults {
pub file: String,
pub analysis: Analysis,
}
#[derive(Clone, Default, PartialEq)]
pub struct Metadata {
pub title:String,
pub artist:String,
pub album_artist:String,
pub album:String,
pub genre:String,
pub duration:u32
pub title: String,
pub artist: String,
pub album_artist: String,
pub album: String,
pub genre: String,
pub duration: u32,
pub analysis: Option<Analysis>,
}
impl Metadata {
pub fn is_empty(&self) -> bool {
self.title.is_empty()
&& self.artist.is_empty()
&& self.album_artist.is_empty()
&& self.album.is_empty()
&& self.genre.is_empty()
}
}
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
pub conn: Connection,
}
impl Db {
pub fn new(path: &String) -> Self {
Self {
conn: Connection::open(path).unwrap(),
match Connection::open(path) {
Ok(conn) => {
Self {
conn: conn,
}
}
Err(e) => {
log::error!("Failed top open/create database. {}", e);
process::exit(-1);
}
}
}
pub fn init(&self) {
match self.conn.execute(
"CREATE TABLE IF NOT EXISTS Tracks (
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 TracksV2 (
File text primary key,
Title text,
Artist text,
@@ -75,40 +126,60 @@ impl Db {
Chroma7 real,
Chroma8 real,
Chroma9 real,
Chroma10 real
);",[]) {
Ok(_) => { },
Err(_) => {
log::error!("Failed to create DB table");
process::exit(-1);
}
Chroma10 real,
Chroma11 real,
Chroma12 real,
Chroma13 real
);",
[],
);
if cmd.is_err() {
log::error!("Failed to create Files table");
process::exit(-1);
}
match self.conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS Tracks_idx ON Tracks(File)", []) {
Ok(_) => { },
Err(_) => {
log::error!("Failed to create DB index");
process::exit(-1);
}
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 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);
}
}
pub fn close(self) {
match self.conn.close() {
Ok(_) => { },
Err(_) => { }
}
let _ = self.conn.close();
}
pub fn get_rowid(&self, path: &String) -> Result<usize, rusqlite::Error> {
let mut db_path = path.clone();
pub fn get_rowid(&self, path: &str) -> Result<usize, rusqlite::Error> {
let mut db_path = path.to_string();
if cfg!(windows) {
db_path = db_path.replace("\\", "/");
}
let mut stmt = self.conn.prepare("SELECT rowid FROM Tracks WHERE File=:path;")?;
let track_iter = stmt.query_map(&[(":path", &db_path)], |row| {
Ok(row.get(0)?)
}).unwrap();
let mut rowid:usize = 0;
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 {
rowid = tr.unwrap();
break;
@@ -116,82 +187,137 @@ impl Db {
Ok(rowid)
}
pub fn add_track(&self, path: &String, meta: &Metadata, analysis:&Analysis) {
pub fn add_track(&self, path: &String, meta: &Metadata, analysis: &Analysis) {
let mut db_path = path.clone();
if cfg!(windows) {
db_path = db_path.replace("\\", "/");
}
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
if id <= 0 {
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); }
}
}
},
}
Err(_) => { }
}
}
pub fn remove_old(&self, mpath:&Path, dry_run:bool) {
log::info!("Looking for non-existant tracks");
let mut stmt = self.conn.prepare("SELECT File FROM Tracks;").unwrap();
let track_iter = stmt.query_map([], |row| {
Ok((row.get(0)?,))
}).unwrap();
let mut to_remove:Vec<String> = Vec::new();
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 {
let mut db_path:String = tr.unwrap().0;
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(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 {
let mut db_path: String = tr.unwrap().0;
let orig_path = db_path.clone();
match orig_path.find(CUE_MARKER) {
Some(s) => {
db_path.truncate(s);
}
None => {}
}
if cfg!(windows) {
db_path = db_path.replace("/", "\\");
}
let path = mpath.join(PathBuf::from(db_path.clone()));
let mut exists = false;
if !path.exists() {
for mpath in mpaths {
let path = mpath.join(PathBuf::from(db_path.clone()));
//log::debug!("Check if '{}' exists.", path.to_string_lossy());
if path.exists() {
exists = true;
break;
}
}
if !exists {
to_remove.push(orig_path);
}
}
let num_to_remove = to_remove.len();
log::info!("Num non-existant tracks: {}", num_to_remove);
if !dry_run && num_to_remove>0 {
let count_before = self.get_track_count();
for t in to_remove {
log::debug!("Remove '{}'", t);
match self.conn.execute("DELETE FROM Tracks WHERE File = ?;", params![t]) {
Ok(_) => { },
Err(e) => { log::error!("Failed to remove '{}' - {}", t, e) }
log::info!("Num non-existent tracks: {}", num_to_remove);
if num_to_remove > 0 {
if dry_run {
log::info!("The following need to be removed from database:");
for t in to_remove {
log::info!(" {}", t);
}
} else {
let count_before = self.get_track_count();
for t in to_remove {
//log::debug!("Remove '{}'", 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)
}
}
let count_now = self.get_track_count();
if (count_now + num_to_remove) != count_before {
log::error!("Failed to remove all tracks. Count before: {}, wanted to remove: {}, count now: {}", count_before, num_to_remove, count_now);
}
}
let count_now = self.get_track_count();
if (count_now + num_to_remove) != count_before {
log::error!("Failed to remove all tracks. Count before: {}, wanted to remove: {}, count now: {}", count_before, num_to_remove, count_now);
}
}
}
pub fn get_track_count(&self) -> usize {
let mut stmt = self.conn.prepare("SELECT COUNT(*) FROM Tracks;").unwrap();
let track_iter = stmt.query_map([], |row| {
Ok(row.get(0)?)
}).unwrap();
let mut count:usize = 0;
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 {
count = tr.unwrap();
break;
@@ -199,77 +325,239 @@ impl Db {
count
}
pub fn update_tags(&self, mpath:&PathBuf) {
pub fn update_tags(&self, mpaths: &Vec<PathBuf>) {
let total = self.get_track_count();
if total>0 {
let pb = ProgressBar::new(total.try_into().unwrap());
let style = ProgressStyle::default_bar()
.template("[{elapsed_precise}] [{bar:25}] {percent:>3}% {pos:>6}/{len:6} {wide_msg}")
.progress_chars("=> ");
pb.set_style(style);
let mut stmt = self.conn.prepare("SELECT rowid, File, Title, Artist, AlbumArtist, Album, Genre, Duration FROM Tracks ORDER BY File ASC;").unwrap();
let track_iter = stmt.query_map([], |row| {
Ok(FileMetadata {
rowid: row.get(0)?,
file: row.get(1)?,
title: row.get(2)?,
artist: row.get(3)?,
album_artist: row.get(4)?,
album: row.get(5)?,
genre: row.get(6)?,
duration: row.get(7)?,
if total > 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("=> "),
);
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 {
rowid: row.get(0)?,
file: row.get(1)?,
title: row.get(2)?,
artist: row.get(3)?,
album_artist: row.get(4)?,
album: row.get(5)?,
genre: row.get(6)?,
duration: row.get(7)?,
})
})
}).unwrap();
.unwrap();
let mut updated = 0;
for tr in track_iter {
let dbtags = tr.unwrap();
let dtags = Metadata{
title:dbtags.title.unwrap_or(String::new()),
artist:dbtags.artist.unwrap_or(String::new()),
album_artist:dbtags.album_artist.unwrap_or(String::new()),
album:dbtags.album.unwrap_or(String::new()),
genre:dbtags.genre.unwrap_or(String::new()),
duration:dbtags.duration
};
pb.set_message(format!("{}", dbtags.file));
let path = String::from(mpath.join(&dbtags.file).to_string_lossy());
let ftags = tags::read(&path);
if ftags.title.is_empty() && ftags.artist.is_empty() && ftags.album_artist.is_empty() && ftags.album.is_empty() && ftags.genre.is_empty() {
log::error!("Failed to read tags of '{}'", dbtags.file);
} else if ftags.duration!=dtags.duration || ftags.title!=dtags.title || ftags.artist!=dtags.artist || ftags.album_artist!=dtags.album_artist || ftags.album!=dtags.album || ftags.genre!=dtags.genre {
match self.conn.execute("UPDATE Tracks 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); }
if !dbtags.file.contains(CUE_MARKER) {
let dtags = Metadata {
title: dbtags.title.unwrap_or_default(),
artist: dbtags.artist.unwrap_or_default(),
album_artist: dbtags.album_artist.unwrap_or_default(),
album: dbtags.album.unwrap_or_default(),
genre: dbtags.genre.unwrap_or_default(),
duration: dbtags.duration,
analysis: None,
};
progress.set_message(format!("{}", dbtags.file));
for mpath in mpaths {
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, false);
if ftags.is_empty() {
log::error!("Failed to read tags of '{}'", dbtags.file);
} else if ftags != dtags {
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); }
}
}
break;
}
}
}
pb.inc(1);
progress.inc(1);
}
pb.finish_with_message(format!("{} Updated.", updated))
progress.finish_with_message(format!("{} Updated.", updated))
}
}
pub fn clear_ignore(&self) {
match self.conn.execute("UPDATE Tracks SET Ignore=0;", []) {
Ok(_) => { },
Err(e) => { log::error!("Failed clear Ignore column. {}", e); }
let cmd = self.conn.execute("UPDATE TracksV2 SET Ignore=0;", []);
if let Err(e) = cmd {
log::error!("Failed clear Ignore column. {}", e);
}
}
pub fn set_ignore(&self, line:&str) {
pub fn set_ignore(&self, line: &str) {
log::info!("Ignore: {}", line);
if line.starts_with("SQL:") {
let sql = &line[4..];
match self.conn.execute(&format!("UPDATE Tracks Set Ignore=1 WHERE {}", sql), []) {
Ok(_) => { },
Err(e) => { log::error!("Failed set Ignore column for '{}'. {}", line, e); }
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 {
match self.conn.execute(&format!("UPDATE Tracks SET Ignore=1 WHERE File LIKE \"{}%\"", line), []) {
Ok(_) => { },
Err(e) => { log::error!("Failed set Ignore column for '{}'. {}", line, e); }
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();
}
+125 -38
View File
@@ -1,10 +1,11 @@
/**
* 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 argparse::{ArgumentParser, Store, StoreTrue};
use chrono::Local;
use configparser::ini::Ini;
@@ -13,39 +14,54 @@ use log::LevelFilter;
use std::io::Write;
use std::path::PathBuf;
use std::process;
use num_cpus;
mod analyse;
mod db;
mod tags;
mod upload;
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
const TOP_LEVEL_INI_TAG:&str = "Bliss";
const TOP_LEVEL_INI_TAG: &str = "Bliss";
fn main() {
let mut config_file = "config.ini".to_string();
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 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 max_num_tracks:usize = 0;
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) => { music_path = String::from(path.join("Music").to_string_lossy()); }
None => { }
Some(path) => {
music_path = String::from(path.join("Music").to_string_lossy());
}
None => {}
}
{
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 containg items to mark as ignored. (default: {})", ignore_file);
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
@@ -54,58 +70,95 @@ 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 tracks from DB if they don't exist (used with analyse task)");
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 max_num_tracks).add_option(&["-n", "--numtracks"], Store, "Maximum number of tracks to analyse");
arg_parse.refer(&mut task).add_argument("task", Store, "Task to perform; analyse, tags, ignore, upload, stopmixer.");
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 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();
}
if !(logging.eq_ignore_ascii_case("trace") || logging.eq_ignore_ascii_case("debug") || logging.eq_ignore_ascii_case("info") || logging.eq_ignore_ascii_case("warn") || logging.eq_ignore_ascii_case("error")) {
if !(logging.eq_ignore_ascii_case("trace") || logging.eq_ignore_ascii_case("debug") || logging.eq_ignore_ascii_case("info")
|| 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.format(|buf, record| writeln!(buf, "[{} {:.1}] {}", Local::now().format("%Y-%m-%d %H:%M:%S"), record.level(), record.args()));
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");
if task.is_empty() {
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") {
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("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() {
let mut config = Ini::new();
match config.load(&config_file) {
Ok(_) => {
match config.get(TOP_LEVEL_INI_TAG, "music") {
Some(val) => { music_path = val; },
None => { }
let path_keys: [&str; 5] = ["music", "music_1", "music_2", "music_3", "music_4"];
for key in &path_keys {
match config.get(TOP_LEVEL_INI_TAG, key) {
Some(val) => { music_paths.push(PathBuf::from(&val)); }
None => { }
}
}
match config.get(TOP_LEVEL_INI_TAG, "db") {
Some(val) => { db_path = val; },
Some(val) => { db_path = val; }
None => { }
}
match config.get(TOP_LEVEL_INI_TAG, "lms") {
Some(val) => { lms_host = val; },
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; },
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);
process::exit(-1);
@@ -114,8 +167,31 @@ 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);
@@ -130,24 +206,25 @@ 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);
}
} else {
let mpath = PathBuf::from(&music_path);
if !mpath.exists() {
log::error!("Music path ({}) does not exist", music_path);
process::exit(-1);
}
if !mpath.is_dir() {
log::error!("Music path ({}) is not a directory", music_path);
process::exit(-1);
for mpath in &music_paths {
if !mpath.exists() {
log::error!("Music path ({}) does not exist", mpath.to_string_lossy());
process::exit(-1);
}
if !mpath.is_dir() {
log::error!("Music path ({}) is not a directory", mpath.to_string_lossy());
process::exit(-1);
}
}
if task.eq_ignore_ascii_case("tags") {
analyse::read_tags(&db_path, &mpath);
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() {
@@ -158,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, &mpath, dry_run, keep_old, max_num_tracks);
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);
}
}
}
}
+245 -64
View File
@@ -1,87 +1,268 @@
/**
* 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 lofty::{Accessor, ItemKey, Probe};
use crate::db;
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 crate::db;
use std::time::SystemTime;
use bliss_audio::{Analysis, AnalysisIndex, FeaturesVersion};
const MAX_GENRE_VAL:usize = 192;
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 {
let mut meta = db::Metadata{
title:String::new(),
artist:String::new(),
album:String::new(),
album_artist:String::new(),
genre:String::new(),
duration:180
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()
};
let path = Path::new(track);
match Probe::open(path) {
Ok(probe) => {
match probe.read(true) {
Ok(file) => {
let tag = match file.primary_tag() {
Some(primary_tag) => primary_tag,
None => file.first_tag().expect("Error: No tags found!"),
};
meta.title=tag.title().unwrap_or("").to_string();
meta.artist=tag.artist().unwrap_or("").to_string();
meta.album=tag.album().unwrap_or("").to_string();
meta.album_artist=tag.get_string(&ItemKey::AlbumArtist).unwrap_or("").to_string();
meta.genre=tag.genre().unwrap_or("").to_string();
// Check whether MP3 as numeric genre, and if so covert to text
if file.file_type().eq(&lofty::FileType::MP3) {
match tag.genre() {
Some(genre) => {
let test = &genre.parse::<u8>();
match test {
Ok(val) => {
let idx:usize = *val as usize;
if idx<MAX_GENRE_VAL {
meta.genre=lofty::id3::v1::GENRES[idx].to_string();
}
},
Err(_) => {
// Check for "(number)text"
let re = Regex::new(r"^\([0-9]+\)").unwrap();
if re.is_match(&genre) {
match genre.find(")") {
Some(end) => {
let test = &genre.to_string().substring(1, end).parse::<u8>();
match test {
Ok(val) => {
let idx:usize = *val as usize;
if idx<MAX_GENRE_VAL {
meta.genre=lofty::id3::v1::GENRES[idx].to_string();
}
},
Err(_) => { }
}
},
None => { }
if let Ok(file) = lofty::read_from_path(Path::new(track)) {
let tag = match file.primary_tag() {
Some(primary_tag) => primary_tag,
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();
// 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>();
match test {
Ok(val) => {
let idx: usize = val as usize;
if idx < MAX_GENRE_VAL {
meta.genre = lofty::id3::v1::GENRES[idx].to_string();
}
}
Err(_) => {
// Check for "(number)text"
let re = Regex::new(r"^\([0-9]+\)").unwrap();
if re.is_match(&genre) {
match genre.find(")") {
Some(end) => {
let test = genre.to_string().substring(1, end).parse::<u8>();
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();
}
}
}
None => { }
}
},
None => {}
}
}
}
meta.duration=file.properties().duration().as_secs() as u32;
},
Err(_) => { }
}
None => { }
}
},
Err(_) => { }
}
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
}
}
+42 -59
View File
@@ -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.
*
**/
@@ -12,93 +12,76 @@ use std::process;
use substring::Substring;
use ureq;
fn fail(msg:&str) {
fn fail(msg: &str) {
log::error!("{}", msg);
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");
match ureq::post(&format!("http://{}:9000/jsonrpc.js", lms)).send_string(&stop_req) {
Ok(_) => { },
Err(e) => { log::error!("Failed to ask plugin to stop mixer. {}", e); }
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;
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) {
Ok(resp) => {
match resp.into_string() {
Ok(text) => {
match text.find("\"port\":") {
Some(s) => {
let txt = text.to_string().substring(s+7, text.len()).to_string();
match txt.find("}") {
Some(e) => {
let p = txt.substring(0, e);
let test = p.parse::<u16>();
match test {
Ok(val) => {
port = val;
},
Err(_) => { fail("Could not parse resp (cast)"); }
}
},
None => { fail("Could not parse resp (closing)"); }
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) => {
let txt = text.to_string().substring(s + 7, text.len()).to_string();
match txt.find("}") {
Some(e) => {
let p = txt.substring(0, e);
let test = p.parse::<u16>();
match test {
Ok(val) => { port = val; }
Err(_) => { fail("Could not parse resp (cast)"); }
}
},
None => { fail("Could not parse resp (no port)"); }
}
None => { fail("Could not parse resp (closing)"); }
}
},
Err(_) => { fail("No text?")}
}
None => { fail("Could not parse resp (no port)"); }
}
},
Err(e) => {
fail(&format!("Failed to ask LMS plugin to allow upload. {}", e));
Err(_) => fail("No text?"),
}
Err(e) => { fail(&format!("Failed to ask LMS plugin to allow upload. {}", e)); }
}
if port<=0 {
if port == 0 {
fail("Invalid port");
}
// Now we have port number, do the actual upload...
log::info!("Uploading {}", db_path);
match File::open(db_path) {
Ok(file) => {
match file.metadata() {
Ok(meta) => {
let buffered_reader = BufReader::new(file);
log::info!("Length: {}", meta.len());
match ureq::put(&format!("http://{}:{}/upload", lms, 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);
},
Err(e) => {
fail(&format!("Failed to upload database. {}", e));
}
Ok(file) => match file.metadata() {
Ok(meta) => {
let buffered_reader = BufReader::new(file);
log::info!("Length: {}", meta.len());
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_host, json_port);
}
},
Err(e) => {
fail(&format!("Failed to open database. {}", e));
Err(e) => { fail(&format!("Failed to upload database. {}", e)); }
}
}
},
Err(e) => {
fail(&format!("Failed to open database. {}", e));
Err(e) => { fail(&format!("Failed to open database. {}", e)); }
}
Err(e) => { fail(&format!("Failed to open database. {}", e)); }
}
}
}