A tool to sync music with your favorite devices
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

*: a bunch of stuff, including now transcoding on copy

Gee Sawra de3db3cf 4f2d2e82

+765 -246
+12
.sqlx/query-28c3bb4879501fcd23c3fa0c92fcbad6c939e4e51368d0958e0ae9518ad2c847.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "\n INSERT OR REPLACE INTO transcoding_settings (\n id, enabled\n ) VALUES (\n ?1, ?2\n )\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 2 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "28c3bb4879501fcd23c3fa0c92fcbad6c939e4e51368d0958e0ae9518ad2c847" 12 + }
+20
.sqlx/query-648469621f60d28033b484b02c52905b4527595fe16dcd4f5b5f08134e188769.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "\n INSERT OR REPLACE INTO transcoding_settings (\n enabled\n ) VALUES (\n ?1\n ) RETURNING id\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "id", 8 + "ordinal": 0, 9 + "type_info": "Int64" 10 + } 11 + ], 12 + "parameters": { 13 + "Right": 1 14 + }, 15 + "nullable": [ 16 + false 17 + ] 18 + }, 19 + "hash": "648469621f60d28033b484b02c52905b4527595fe16dcd4f5b5f08134e188769" 20 + }
+20
.sqlx/query-752e23d58600452e5ae23e8dd765d940381f375f355519cfa7befdb9ac774ad6.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT id FROM transcoding_settings LIMIT 1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "id", 8 + "ordinal": 0, 9 + "type_info": "Int64" 10 + } 11 + ], 12 + "parameters": { 13 + "Right": 0 14 + }, 15 + "nullable": [ 16 + false 17 + ] 18 + }, 19 + "hash": "752e23d58600452e5ae23e8dd765d940381f375f355519cfa7befdb9ac774ad6" 20 + }
+12
.sqlx/query-dcb4b2cd8f9475f268b91668c0446ac57edd5ae91ea482ce48b51a1085ce7a77.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "\n INSERT OR REPLACE INTO transcoding_settings (\n id, enabled, codec, bitrate, sampling_rate\n ) VALUES (\n ?1, 1, ?2, ?3, ?4\n )\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 4 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "dcb4b2cd8f9475f268b91668c0446ac57edd5ae91ea482ce48b51a1085ce7a77" 12 + }
+78 -35
Cargo.lock
··· 1 1 # This file is automatically @generated by Cargo. 2 2 # It is not intended for manual editing. 3 - version = 3 3 + version = 4 4 4 5 5 [[package]] 6 6 name = "addr2line" ··· 262 262 dependencies = [ 263 263 "proc-macro2", 264 264 "quote", 265 - "syn 2.0.65", 265 + "syn 2.0.87", 266 266 ] 267 267 268 268 [[package]] ··· 341 341 342 342 [[package]] 343 343 name = "bitflags" 344 - version = "2.5.0" 344 + version = "2.10.0" 345 345 source = "registry+https://github.com/rust-lang/crates.io-index" 346 - checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" 346 + checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 347 347 dependencies = [ 348 - "serde", 348 + "serde_core", 349 349 ] 350 350 351 351 [[package]] ··· 390 390 391 391 [[package]] 392 392 name = "cc" 393 - version = "1.0.98" 393 + version = "1.2.52" 394 394 source = "registry+https://github.com/rust-lang/crates.io-index" 395 - checksum = "41c270e7540d725e65ac7f1b212ac8ce349719624d7bcff99f8e2e488e8cf03f" 395 + checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" 396 + dependencies = [ 397 + "find-msvc-tools", 398 + "shlex", 399 + ] 396 400 397 401 [[package]] 398 402 name = "cfg-if" ··· 431 435 "heck 0.5.0", 432 436 "proc-macro2", 433 437 "quote", 434 - "syn 2.0.65", 438 + "syn 2.0.87", 435 439 ] 436 440 437 441 [[package]] ··· 747 751 checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" 748 752 749 753 [[package]] 754 + name = "find-msvc-tools" 755 + version = "0.1.7" 756 + source = "registry+https://github.com/rust-lang/crates.io-index" 757 + checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" 758 + 759 + [[package]] 750 760 name = "flate2" 751 761 version = "1.0.30" 752 762 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 877 887 dependencies = [ 878 888 "proc-macro2", 879 889 "quote", 880 - "syn 2.0.65", 890 + "syn 2.0.87", 881 891 ] 882 892 883 893 [[package]] ··· 990 1000 checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" 991 1001 992 1002 [[package]] 1003 + name = "hermit-abi" 1004 + version = "0.5.2" 1005 + source = "registry+https://github.com/rust-lang/crates.io-index" 1006 + checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 1007 + 1008 + [[package]] 993 1009 name = "hex" 994 1010 version = "0.4.3" 995 1011 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1034 1050 source = "registry+https://github.com/rust-lang/crates.io-index" 1035 1051 checksum = "79f41f7e5ad125c63d55b112a98afb753742fa7f97692bfbbc52544b89e1ff1f" 1036 1052 dependencies = [ 1037 - "bitflags 2.5.0", 1053 + "bitflags 2.10.0", 1038 1054 "byteorder", 1039 1055 "flate2", 1040 1056 ] ··· 1087 1103 source = "registry+https://github.com/rust-lang/crates.io-index" 1088 1104 checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" 1089 1105 dependencies = [ 1090 - "hermit-abi", 1106 + "hermit-abi 0.3.9", 1091 1107 "libc", 1092 1108 "windows-sys 0.48.0", 1093 1109 ] ··· 1142 1158 1143 1159 [[package]] 1144 1160 name = "libc" 1145 - version = "0.2.155" 1161 + version = "0.2.180" 1146 1162 source = "registry+https://github.com/rust-lang/crates.io-index" 1147 - checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" 1163 + checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" 1148 1164 1149 1165 [[package]] 1150 1166 name = "libm" ··· 1158 1174 source = "registry+https://github.com/rust-lang/crates.io-index" 1159 1175 checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 1160 1176 dependencies = [ 1161 - "bitflags 2.5.0", 1177 + "bitflags 2.10.0", 1162 1178 "libc", 1163 1179 ] 1164 1180 ··· 1320 1336 ] 1321 1337 1322 1338 [[package]] 1339 + name = "num_cpus" 1340 + version = "1.17.0" 1341 + source = "registry+https://github.com/rust-lang/crates.io-index" 1342 + checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" 1343 + dependencies = [ 1344 + "hermit-abi 0.5.2", 1345 + "libc", 1346 + ] 1347 + 1348 + [[package]] 1323 1349 name = "number_prefix" 1324 1350 version = "0.4.0" 1325 1351 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1470 1496 dependencies = [ 1471 1497 "cfg-if", 1472 1498 "concurrent-queue", 1473 - "hermit-abi", 1499 + "hermit-abi 0.3.9", 1474 1500 "pin-project-lite", 1475 1501 "rustix 0.38.34", 1476 1502 "tracing", ··· 1558 1584 source = "registry+https://github.com/rust-lang/crates.io-index" 1559 1585 checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" 1560 1586 dependencies = [ 1561 - "bitflags 2.5.0", 1587 + "bitflags 2.10.0", 1562 1588 ] 1563 1589 1564 1590 [[package]] ··· 1608 1634 checksum = "61797318be89b1a268a018a92a7657096d83f3ecb31418b9e9c16dcbb043b702" 1609 1635 dependencies = [ 1610 1636 "ahash", 1611 - "bitflags 2.5.0", 1637 + "bitflags 2.10.0", 1612 1638 "instant", 1613 1639 "num-traits", 1614 1640 "once_cell", ··· 1626 1652 dependencies = [ 1627 1653 "proc-macro2", 1628 1654 "quote", 1629 - "syn 2.0.65", 1655 + "syn 2.0.87", 1630 1656 ] 1631 1657 1632 1658 [[package]] ··· 1675 1701 source = "registry+https://github.com/rust-lang/crates.io-index" 1676 1702 checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" 1677 1703 dependencies = [ 1678 - "bitflags 2.5.0", 1704 + "bitflags 2.10.0", 1679 1705 "errno", 1680 1706 "libc", 1681 1707 "linux-raw-sys 0.4.14", ··· 1705 1731 1706 1732 [[package]] 1707 1733 name = "serde" 1708 - version = "1.0.203" 1734 + version = "1.0.228" 1709 1735 source = "registry+https://github.com/rust-lang/crates.io-index" 1710 - checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" 1736 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 1737 + dependencies = [ 1738 + "serde_core", 1739 + "serde_derive", 1740 + ] 1741 + 1742 + [[package]] 1743 + name = "serde_core" 1744 + version = "1.0.228" 1745 + source = "registry+https://github.com/rust-lang/crates.io-index" 1746 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 1711 1747 dependencies = [ 1712 1748 "serde_derive", 1713 1749 ] 1714 1750 1715 1751 [[package]] 1716 1752 name = "serde_derive" 1717 - version = "1.0.203" 1753 + version = "1.0.228" 1718 1754 source = "registry+https://github.com/rust-lang/crates.io-index" 1719 - checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" 1755 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 1720 1756 dependencies = [ 1721 1757 "proc-macro2", 1722 1758 "quote", 1723 - "syn 2.0.65", 1759 + "syn 2.0.87", 1724 1760 ] 1725 1761 1726 1762 [[package]] ··· 1770 1806 ] 1771 1807 1772 1808 [[package]] 1809 + name = "shlex" 1810 + version = "1.3.0" 1811 + source = "registry+https://github.com/rust-lang/crates.io-index" 1812 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1813 + 1814 + [[package]] 1773 1815 name = "signature" 1774 1816 version = "2.2.0" 1775 1817 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1955 1997 dependencies = [ 1956 1998 "atoi", 1957 1999 "base64", 1958 - "bitflags 2.5.0", 2000 + "bitflags 2.10.0", 1959 2001 "byteorder", 1960 2002 "bytes", 1961 2003 "crc", ··· 1997 2039 dependencies = [ 1998 2040 "atoi", 1999 2041 "base64", 2000 - "bitflags 2.5.0", 2042 + "bitflags 2.10.0", 2001 2043 "byteorder", 2002 2044 "crc", 2003 2045 "dotenvy", ··· 2098 2140 2099 2141 [[package]] 2100 2142 name = "syn" 2101 - version = "2.0.65" 2143 + version = "2.0.87" 2102 2144 source = "registry+https://github.com/rust-lang/crates.io-index" 2103 - checksum = "d2863d96a84c6439701d7a38f9de935ec562c8832cc55d1dde0f513b52fad106" 2145 + checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" 2104 2146 dependencies = [ 2105 2147 "proc-macro2", 2106 2148 "quote", ··· 2142 2184 dependencies = [ 2143 2185 "proc-macro2", 2144 2186 "quote", 2145 - "syn 2.0.65", 2187 + "syn 2.0.87", 2146 2188 ] 2147 2189 2148 2190 [[package]] ··· 2200 2242 dependencies = [ 2201 2243 "proc-macro2", 2202 2244 "quote", 2203 - "syn 2.0.65", 2245 + "syn 2.0.87", 2204 2246 ] 2205 2247 2206 2248 [[package]] ··· 2213 2255 ] 2214 2256 2215 2257 [[package]] 2216 - name = "tracksync" 2217 - version = "0.1.0" 2258 + name = "tunz" 2259 + version = "0.1.1" 2218 2260 dependencies = [ 2219 2261 "anyhow", 2220 2262 "async-std", ··· 2227 2269 "futures", 2228 2270 "indicatif", 2229 2271 "log", 2272 + "num_cpus", 2230 2273 "once_cell", 2231 2274 "regex", 2232 2275 "rhai", ··· 2378 2421 "once_cell", 2379 2422 "proc-macro2", 2380 2423 "quote", 2381 - "syn 2.0.65", 2424 + "syn 2.0.87", 2382 2425 "wasm-bindgen-shared", 2383 2426 ] 2384 2427 ··· 2412 2455 dependencies = [ 2413 2456 "proc-macro2", 2414 2457 "quote", 2415 - "syn 2.0.65", 2458 + "syn 2.0.87", 2416 2459 "wasm-bindgen-backend", 2417 2460 "wasm-bindgen-shared", 2418 2461 ] ··· 2642 2685 dependencies = [ 2643 2686 "proc-macro2", 2644 2687 "quote", 2645 - "syn 2.0.65", 2688 + "syn 2.0.87", 2646 2689 ] 2647 2690 2648 2691 [[package]]
+4 -6
Cargo.toml
··· 1 1 [package] 2 - name = "tracksync" 2 + name = "tunz" 3 3 version = "0.1.1" 4 - edition = "2021" 5 - description = "A command-line tool to manage music library syncing to neatly ordered directories." 4 + edition = "2024" 5 + description = "A tool to synchronize a music library to someplace else." 6 6 license-file = "LICENSE" 7 7 8 8 [profile.release] 9 9 strip = true # Automatically strip symbols from the binary. 10 10 opt-level = "z" # Optimize for size. 11 11 lto = true 12 - 13 - [profile.dev.package.sqlx-macros] 14 - opt-level = 3 15 12 16 13 [dependencies] 17 14 async-std = { version = "1.12.0", features = [ ··· 41 38 regex = "1.10.5" 42 39 rhai = "1.19.0" 43 40 edit = "0.1.5" 41 + num_cpus = "1.17.0"
+10
Justfile
··· 1 + # Pre-compiles prepared statement for sqlx. 2 + prepare: 3 + rm -rf .temp.db 4 + touch .temp.db 5 + DATABASE_URL=sqlite://.temp.db cargo sqlx migrate run --source src/db/migrations 6 + DATABASE_URL=sqlite://.temp.db cargo sqlx prepare 7 + rm .temp.db 8 + 9 + new_migration name: 10 + cargo sqlx migrate add --source src/db/migrations {{ name }}
+15 -7
README.md
··· 1 - # `tracksync`: if `rsync` was ID3-aware 1 + # `tunz`: if `rsync` was ID3-aware 2 2 3 - `tracksync` synchronizes music files from a source to a destination, keeping a database for both. 3 + `tunz` synchronizes music files from a source to a destination, keeping a database for both. 4 4 5 5 Tracks need to be added to the source database first, and then can be synced on the destination. 6 6 ··· 11 11 12 12 Pass `-h` to each subcommand to understand how to use it! 13 13 14 - `tracksync` can also create hardlinks instead of copies of your files: pass the `--link` flag to `sync` to do so. 14 + `tunz` can also create hardlinks instead of copies of your files: pass the `--link` flag to `sync` to do so. 15 15 16 16 ## Installing 17 17 18 18 ```sh 19 - cargo install tracksync 19 + cargo install tunz 20 20 ``` 21 21 22 22 ### From sources ··· 26 26 Once you have that setup: 27 27 28 28 ```sh 29 - git clone https://github.com/gsora/tracksync 30 - cd tracksync 29 + git clone https://github.com/gsora/tunz 30 + cd tunz 31 31 cargo build --release 32 - ./target/release/tracksync -h 32 + ./target/release/tunz -h 33 33 ``` 34 34 35 35 ## Filtering ··· 104 104 105 105 The database schema might break suddenly, making your source and destination(s) libraries unusable: a `rescan` command 106 106 is in the works -- I will make sure to keep those at a minimum. 107 + 108 + ## conversion 109 + 110 + Debian deps: 111 + - libavutil-dev 112 + - libavformat-dev 113 + - libavfilter-dev 114 + - libavdevice-dev
database/migrations/local/0_base.sql src/db/migrations/0_base.sql
database/migrations/local/1_added_dirs.sql src/db/migrations/1_added_dirs.sql
database/migrations/local/2_albums_view.sql src/db/migrations/2_albums_view.sql
database/migrations/local/3_fts.sql src/db/migrations/3_fts.sql
database/migrations/local/4_filter.sql src/db/migrations/4_filter.sql
+13 -1
src/cli.rs
··· 1 - use clap::{command, Parser, Subcommand}; 1 + use clap::{Parser, Subcommand}; 2 2 3 3 use crate::cmd; 4 4 ··· 27 27 /// Cleans destination of uncleanly-copied files. 28 28 Clean(cmd::clean::Args), 29 29 30 + /// Settings allows modifying transcoding and filtering settings. 31 + Settings { 32 + #[command(subcommand)] 33 + command: Settings, 34 + }, 35 + } 36 + 37 + #[derive(Subcommand)] 38 + pub enum Settings { 30 39 /// Filter tracks to copy over to a destination. 31 40 Filter(cmd::filter::Args), 41 + 42 + /// Convert files from one format to another during sync. 43 + Transcode(cmd::transcode::Args), 32 44 }
+16 -53
src/cmd/add.rs
··· 5 5 use anyhow::{Context, Result}; 6 6 use clap::Args as ClapArgs; 7 7 use futures::{executor::block_on, future::try_join_all}; 8 - use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; 9 8 use model::FileState; 10 9 11 10 #[derive(ClapArgs, Debug)] 12 11 pub struct Args { 13 - /// Directory in which tracksync will store its local database. 12 + /// Directory in which tunz will store its local database. 14 13 #[arg(short, long, default_value_t = db::default_database_dir().to_str().unwrap().to_owned())] 15 14 pub database_path: String, 16 15 17 - /// A path in which tracksync will look for music files. 16 + /// A path in which tunz will look for music files. 18 17 /// Specify more than one for multiple sources. 19 18 #[arg(short, long = "source", value_name = "SOURCE", action = clap::ArgAction::Append)] 20 19 pub sources: Option<Vec<String>>, 21 20 21 + // TODO: we could understand if the database path is destination or not by detecting 22 + // if it's a mount point and not the local disk. 22 23 /// Specifies if the database is to be written is a destination one. 23 24 #[arg( 24 25 long = "destination", ··· 41 42 } 42 43 43 44 pub async fn run(args: Args, update: bool) -> Result<()> { 44 - let val_res = args.validate(); 45 - 46 45 match update { 47 46 true => {} 48 - false => val_res?, 47 + false => args.validate()?, 49 48 }; 50 49 51 50 log::debug!("CLI args: {:?}", args); ··· 63 62 .with_context(|| "Cannot fetch track directories from database")?, 64 63 }; 65 64 66 - let mp = MultiProgress::new(); 67 65 let mut tracks = vec![]; 68 66 69 67 for source in &sources { ··· 82 80 sources 83 81 .into_iter() 84 82 .map(|source| { 85 - traverse_and_add_param(&db, &mp, source, { 83 + traverse_and_add_param(&db, source, { 86 84 let tracks_set = tracks_set.clone(); 87 85 88 - move |path, db, pb| match update { 89 - false => add_dupe_checker(path, db, pb), 86 + move |path, db| match update { 87 + false => add_dupe_checker(path, db), 90 88 true => Ok(!tracks_set.contains(path)), 91 89 } 92 90 }) ··· 101 99 0 => log::info!("Imported {} tracks", totals.0), 102 100 _ => match update { 103 101 false => log::info!( 104 - "Imported {} new tracks, but found {} duplicates", 102 + "Imported {} new tracks, found {} duplicates", 105 103 totals.0, 106 104 totals.1 107 105 ), ··· 110 108 }; 111 109 112 110 if update { 113 - let prog = mp.add( 114 - ProgressBar::new_spinner() 115 - .with_message("Looking for tracks not on disk anymore...") 116 - .with_style(ProgressStyle::default_spinner()), 117 - ); 118 - 119 - prog.enable_steady_tick(std::time::Duration::from_millis(50)); 120 - 121 111 // look for files in db that are not on the filesystem anymore 122 112 let track_iter = db 123 113 .tracks_iter() ··· 132 122 match tp.exists() { 133 123 true => {} 134 124 false => { 135 - prog.set_message(format!( 136 - "Found track in database not existing on filesystem, deleting: {}", 137 - track.file_path, 138 - )); 139 - 140 125 db.delete(track.id) 141 126 .await 142 127 .with_context(|| "Cannot delete track from database.")?; 143 128 } 144 129 } 145 130 } 146 - 147 - prog.finish(); 148 - mp.remove(&prog); 149 131 } 150 132 151 133 Ok(()) 152 134 } 153 135 154 - fn add_dupe_checker(path: &String, db: &db::Instance, pb: &indicatif::ProgressBar) -> Result<bool> { 136 + fn add_dupe_checker(path: &String, db: &db::Instance) -> Result<bool> { 155 137 block_on(async { 156 138 if db.exists(path.clone()).await? { 157 - pb.set_message(format!("Found duplicate at {}", path.clone())); 158 139 return Ok(true); 159 140 } 160 141 ··· 164 145 165 146 pub(crate) async fn traverse_and_add_param<F>( 166 147 db: &db::Instance, 167 - mp: &MultiProgress, 168 148 path: String, 169 149 dupe_checker: F, 170 150 ) -> Result<(u64, u64)> 171 151 where 172 - F: FnOnce(&String, &db::Instance, &indicatif::ProgressBar) -> Result<bool> + Clone, 152 + F: FnOnce(&String, &db::Instance) -> Result<bool> + Clone, 173 153 { 174 - let paths = fs::traverse(&path).await; 175 - 176 - let base_msg = format!("Reading {}...", path.clone()); 177 - 178 - let prog = mp.add( 179 - ProgressBar::new_spinner() 180 - .with_message(base_msg.clone()) 181 - .with_style(ProgressStyle::default_spinner()), 182 - ); 154 + log::info!("scanning {}", path); 183 155 184 - prog.enable_steady_tick(std::time::Duration::from_millis(50)); 156 + let paths = fs::traverse(&path).await; 185 157 186 158 let mut new_tracks = 0; 187 159 let mut duplicate = 0; ··· 190 162 let p = p?.clone(); 191 163 192 164 let dc = dupe_checker.clone(); 193 - if dc(&p, db, &prog)? { 165 + if dc(&p, db)? { 194 166 duplicate += 1; 195 167 continue; 196 168 } ··· 202 174 let mut track: model::Track = model::RawTrack { tags, path: p }.into(); 203 175 track.file_state = FileState::Copied; 204 176 177 + log::info!("new track: {}", track); 178 + 205 179 db.insert_track(&track) 206 180 .await 207 181 .with_context(|| format!("Cannot write track data to database"))?; 208 182 209 - prog.set_message(format!( 210 - "{}\nFound track: {} - {}, from {}", 211 - base_msg.clone(), 212 - track.title, 213 - track.artist, 214 - track.album 215 - )); 216 - 217 183 new_tracks += 1; 218 184 } 219 - 220 - prog.finish(); 221 - mp.remove(&prog); 222 185 223 186 db.insert_directory(path).await?; 224 187
+1 -1
src/cmd/dupes.rs
··· 5 5 6 6 #[derive(ClapArgs, Debug)] 7 7 pub struct Args { 8 - /// Directory in which tracksync will store its local database. 8 + /// Directory in which tunz will store its local database. 9 9 #[arg(short, long, default_value_t = db::default_database_dir().to_str().unwrap().to_owned())] 10 10 pub database_path: String, 11 11 }
+1 -1
src/cmd/filter.rs
··· 6 6 7 7 #[derive(ClapArgs)] 8 8 pub struct Args { 9 - /// Path where tracksync database is stored. 9 + /// Path where tunz database is stored. 10 10 #[arg(long)] 11 11 pub destination: Option<String>, 12 12
+1
src/cmd/mod.rs
··· 4 4 pub mod error; 5 5 pub mod filter; 6 6 pub mod sync; 7 + pub mod transcode;
+92 -122
src/cmd/sync.rs
··· 1 1 use crate::cmd::*; 2 2 use crate::db; 3 + use crate::ffmpeg; 3 4 use crate::model; 4 - use anyhow::anyhow; 5 5 use anyhow::Ok; 6 + use anyhow::anyhow; 6 7 use anyhow::{Context, Result}; 7 8 use clap::Args as ClapArgs; 8 - use fs_extra::file::{copy_with_progress, CopyOptions}; 9 - use indicatif::MultiProgress; 9 + use fs_extra::file::{CopyOptions, copy_with_progress}; 10 + use futures::StreamExt; 11 + use futures::TryStreamExt; 12 + use futures::stream; 10 13 use std::collections::hash_set; 11 - use std::os::unix::fs::MetadataExt; 14 + use std::str::FromStr; 12 15 13 16 #[derive(ClapArgs)] 14 17 pub struct Args { 15 - /// Path where to look for tracksync source data. 18 + /// Path where to look for tunz source data. 16 19 #[arg(short, long, default_value_t = db::default_database_dir().to_str().unwrap().to_owned())] 17 20 pub database_path: String, 18 21 19 - /// Path where to store tracksync's database, as well as music files. 22 + /// Path where to store tunz's database, as well as music files. 20 23 #[arg(long)] 21 24 pub destination: Option<String>, 22 25 ··· 32 35 /// Instead of copying the files over to the specified destination, create an hardlink. 33 36 #[arg(long, default_value_t = false)] 34 37 pub link: bool, 38 + 39 + /// Amount of threads to use for copy and transcoding, defaults to number of CPUs 40 + /// available. 41 + #[arg(long)] 42 + pub threads: Option<usize>, 35 43 } 36 44 37 45 impl Args { ··· 48 56 49 57 pub async fn run(args: Args) -> Result<()> { 50 58 args.validate()?; 59 + 60 + let threads = match args.threads { 61 + Some(t) => t, 62 + None => num_cpus::get(), 63 + }; 51 64 52 65 let dest_dir = args.destination.unwrap(); 53 66 ··· 95 108 if !args.no_delete { 96 109 run_delete(&dest_db, &dest_dir, reverse_diff, filters.as_ref()).await? 97 110 } 111 + 112 + log::info!("using {} threads", threads); 98 113 99 114 run_copy( 100 115 &local_db, ··· 103 118 diff, 104 119 filters.as_ref(), 105 120 args.link, 121 + threads, 106 122 ) 107 123 .await?; 108 124 109 125 Ok(()) 110 126 } 111 127 112 - fn progress_bar(size: u64, style: indicatif::ProgressStyle) -> indicatif::ProgressBar { 113 - let bar = indicatif::ProgressBar::new(size); 114 - bar.set_style(style); 115 - 116 - bar 117 - } 118 - 119 - fn total_style() -> indicatif::ProgressStyle { 120 - indicatif::ProgressStyle::with_template( 121 - "Total progress:\n[{percent}% {wide_bar:.green}] {human_pos}/{human_len} {elapsed}\n\n", 122 - ) 123 - .unwrap() 124 - .progress_chars("##-") 125 - } 126 - 127 - fn delete_style() -> indicatif::ProgressStyle { 128 - indicatif::ProgressStyle::with_template( 129 - "Deleting old tracks:\n[{percent}% {wide_bar:.green}] {human_pos}/{human_len} {elapsed}\n\n", 130 - ) 131 - .unwrap() 132 - .progress_chars("##-") 133 - } 134 - 135 - fn track_style() -> indicatif::ProgressStyle { 136 - indicatif::ProgressStyle::with_template( 137 - "{msg}\n[{percent}% {wide_bar:.green}] {decimal_bytes:>7}/{decimal_total_bytes:7} {elapsed}\n\n", 138 - ) 139 - .unwrap() 140 - .progress_chars("##-") 141 - } 142 - 143 128 async fn diff_databases( 144 129 source: &db::Instance, 145 130 destination: &db::Instance, ··· 228 213 diff: Vec<String>, 229 214 filters: Option<&Vec<crate::filter::ScriptRuntime>>, 230 215 link: bool, 216 + threads: usize, 231 217 ) -> Result<()> { 232 - let diff_len = diff.len(); 218 + let tr_settings = dest_db.get_transcoding_settings().await?; 233 219 234 220 let tracks = filter_tracks( 235 221 local_db ··· 240 226 false, 241 227 )?; 242 228 243 - // Copy tracks 244 - let mp = MultiProgress::new(); 229 + stream::iter(tracks) 230 + .map(|track| { 231 + let trs = tr_settings.clone(); 232 + async move { do_copy(track, dest_db, dest_dir, trs, link).await } 233 + }) 234 + .buffer_unordered(threads) 235 + .try_for_each(|_| async move { Ok(()) }) 236 + .await 237 + } 245 238 246 - let total_bar = mp.add(progress_bar(diff_len as u64, total_style())); 247 - 248 - total_bar.tick(); 249 - 250 - for track in tracks { 251 - copy(track, &dest_db, &dest_dir, &mp, link).await?; 252 - total_bar.inc(1); 239 + async fn do_copy( 240 + track: model::Track, 241 + dest_db: &db::Instance, 242 + dest_dir: &String, 243 + transcoding_settings: Option<ffmpeg::Quality>, 244 + link: bool, 245 + ) -> Result<()> { 246 + if let Some(ts) = transcoding_settings.clone() { 247 + log::info!("copying and transcoding {} ({:?})", track, ts); 248 + } else { 249 + log::info!("copying {}", track); 253 250 } 254 - 255 - total_bar.finish(); 256 - 257 - Ok(()) 251 + copy(track, &dest_db, &dest_dir, transcoding_settings, link).await 258 252 } 259 253 260 254 async fn dry_run_copy( ··· 326 320 true, 327 321 )?; 328 322 329 - let mp = MultiProgress::new(); 330 - 331 - let total_bar = mp.add(progress_bar(diff_len as u64, delete_style())); 332 - 333 - total_bar.tick(); 334 - 335 323 for track in tracks { 336 - delete(track, &dest_db, &dest_dir, &mp).await?; 337 - total_bar.inc(1); 324 + log::info!("deleting {}", track); 325 + delete(track, &dest_db, &dest_dir).await?; 338 326 } 339 327 340 - total_bar.finish(); 341 - 342 328 Ok(()) 343 329 } 344 330 345 - async fn delete( 346 - track: model::Track, 347 - dest_db: &db::Instance, 348 - dest_dir: &String, 349 - mp: &indicatif::MultiProgress, 350 - ) -> Result<()> { 331 + async fn delete(track: model::Track, dest_db: &db::Instance, dest_dir: &String) -> Result<()> { 351 332 let track_storage_path = track.storage_path(&dest_dir); 352 333 353 - let bar = mp.add( 354 - progress_bar(1, track_style()).with_message(format!("Deleting: {}", track_storage_path)), 355 - ); 356 - 357 334 dest_db.delete(track.id).await?; 358 335 std::fs::remove_file(track_storage_path.clone()) 359 336 .with_context(|| format!("Cannot delete file {}", track_storage_path.clone()))?; 360 - 361 - bar.inc(1); 362 - 363 - mp.remove(&bar); 364 - 365 337 Ok(()) 366 338 } 367 339 ··· 369 341 track: model::Track, 370 342 dest_db: &db::Instance, 371 343 dest_dir: &String, 372 - mp: &indicatif::MultiProgress, 344 + transcoding_settings: Option<ffmpeg::Quality>, 373 345 link: bool, 374 346 ) -> Result<()> { 375 347 let track_storage_path = track.storage_path(&dest_dir); ··· 395 367 ) 396 368 })?; 397 369 398 - let orig_file_path = std::path::Path::new(&track.file_path); 399 - let orig_file = std::fs::File::open(orig_file_path).with_context(|| { 400 - format!( 401 - "Cannot open origin file {}", 402 - orig_file_path.to_str().unwrap() 403 - ) 404 - })?; 370 + let opts = CopyOptions::new().overwrite(true); 405 371 406 - let orig_file_meta = orig_file.metadata().with_context(|| { 407 - format!( 408 - "Cannot obtain metadata information of {}", 409 - orig_file_path.to_str().unwrap() 410 - ) 411 - })?; 372 + let track_for_thread = track.clone(); 373 + let dest_dir_for_thread = dest_dir.clone(); 374 + let ts_for_thread = transcoding_settings.clone(); 412 375 413 - let bar = mp.add( 414 - progress_bar(orig_file_meta.size(), track_style()).with_message(format!( 415 - "Copying: {}\nTo: {}", 416 - track.file_path, track_storage_path 417 - )), 418 - ); 376 + async_std::task::spawn_blocking(move || { 377 + if let Some(ts) = ts_for_thread { 378 + ffmpeg::convert( 379 + &track_for_thread, 380 + std::path::PathBuf::from_str( 381 + &track_for_thread 382 + .storage_path_with_extension(&dest_dir_for_thread, &ts.codec.extension()), 383 + ) 384 + .unwrap(), 385 + &ts, 386 + )?; 387 + } else { 388 + if link { 389 + std::fs::hard_link( 390 + track_for_thread.file_path.clone(), 391 + track_storage_path.clone(), 392 + )?; 393 + } else { 394 + match copy_with_progress( 395 + track_for_thread.file_path.clone(), 396 + track_storage_path.clone(), 397 + &opts, 398 + |_| {}, 399 + ) { 400 + std::result::Result::Ok(_) => {} 401 + Err(err) => { 402 + return Err(error::Error::CopyError(err)).with_context(|| { 403 + format!( 404 + "Cannot copy {} to {}", 405 + track_for_thread.file_path, track_storage_path 406 + ) 407 + }); 408 + } 409 + }; 410 + } 411 + } 419 412 420 - let opts = CopyOptions::new().overwrite(true); 421 - 422 - if link { 423 - std::fs::hard_link(track.file_path.clone(), track_storage_path.clone())?; 424 - } else { 425 - match copy_with_progress( 426 - track.file_path.clone(), 427 - track_storage_path.clone(), 428 - &opts, 429 - |ph| { 430 - bar.set_position(ph.copied_bytes); 431 - }, 432 - ) { 433 - std::result::Result::Ok(_) => {} 434 - Err(err) => { 435 - return Err(error::Error::CopyError(err)).with_context(|| { 436 - format!("Cannot copy {} to {}", track.file_path, track_storage_path) 437 - }); 438 - } 439 - }; 440 - // .with_context(|| format!("Cannot copy {} to {}", track.file_path, track_storage_path))?; 441 - } 413 + Ok(()) 414 + }) 415 + .await?; 442 416 443 417 // step 3: update the destination track with the new state 444 418 dest_track.file_state = crate::model::FileState::Copied; ··· 446 420 .insert_track(&dest_track) 447 421 .await 448 422 .with_context(|| "Cannot insert copy finished track in destination database")?; 449 - 450 - bar.finish(); 451 - 452 - mp.remove(&bar); 453 423 454 424 Ok(()) 455 425 }
+76
src/cmd/transcode.rs
··· 1 + use crate::{ 2 + db, 3 + ffmpeg::{Bitrate, BitrateParser, Codec, SamplingRate, SamplingRateParser}, 4 + }; 5 + use anyhow::{Result, anyhow}; 6 + use clap::Args as ClapArgs; 7 + 8 + #[derive(ClapArgs, Debug)] 9 + pub struct Args { 10 + /// Path where tunz database is stored. 11 + #[arg(long)] 12 + pub destination: String, 13 + 14 + /// Enable or disable transcoding on copy. 15 + #[arg(long)] 16 + pub enabled: Option<bool>, 17 + 18 + /// Codec to transcode files to. 19 + #[arg(long)] 20 + pub codec: Option<Codec>, 21 + 22 + /// Bitrate to transcode at. 23 + #[arg(long, value_parser = BitrateParser{})] 24 + pub bitrate: Option<Bitrate>, 25 + 26 + /// Sample rate to transcode at. 27 + #[arg(long, value_parser = SamplingRateParser{})] 28 + pub sampling_rate: Option<SamplingRate>, 29 + } 30 + 31 + impl Args { 32 + pub fn validate(&self) -> Result<()> { 33 + match self.enabled { 34 + Some(s) => match s { 35 + true => { 36 + if !(self.bitrate.is_some() 37 + || self.codec.is_some() 38 + || self.sampling_rate.is_some()) 39 + { 40 + return Err(anyhow!("can't enable transcoding with no settings!")); 41 + } 42 + 43 + Ok(()) 44 + } 45 + false => Ok(()), 46 + }, 47 + None => Ok(()), 48 + } 49 + } 50 + } 51 + 52 + pub async fn run(args: Args) -> Result<()> { 53 + args.validate()?; 54 + 55 + let dest_db = db::Instance::new(&args.destination, true).await?; 56 + 57 + if let Some(enabled) = args.enabled { 58 + dest_db.set_transcoding(enabled).await?; 59 + 60 + if !enabled { 61 + return Ok(()); // user is disabling transcoding, we can return early 62 + } 63 + } 64 + 65 + dest_db 66 + .set_transcoding_settings( 67 + args.codec.map(|e| e.to_string()), 68 + args.bitrate.map(|e| e.to_string()), 69 + args.sampling_rate.map(|e| e.to_string()), 70 + ) 71 + .await?; 72 + 73 + println!("{:?}", dest_db.get_transcoding_settings().await?); 74 + 75 + Ok(()) 76 + }
+111 -4
src/db/instance.rs
··· 1 + use std::str::FromStr; 2 + 1 3 use async_std::channel::{Receiver, Sender}; 4 + use clap::ValueEnum; 2 5 use futures::StreamExt; 3 - use sqlx::{migrate::Migrator, sqlite::SqliteConnectOptions, Error, Row, SqlitePool}; 6 + use sqlx::{Error, Row, SqlitePool, migrate::Migrator, sqlite::SqliteConnectOptions}; 4 7 5 - use crate::model; 8 + use crate::{ffmpeg, model}; 6 9 7 - static MIGRATOR: Migrator = sqlx::migrate!("database/migrations/local"); 10 + static MIGRATOR: Migrator = sqlx::migrate!("src/db/migrations"); 8 11 9 - static DATABASE_DEFAULT_NAME: &str = "tracksync.db"; 12 + static DATABASE_DEFAULT_NAME: &str = "tunz.db"; 10 13 11 14 pub struct Instance { 12 15 pool: SqlitePool, ··· 79 82 ) 80 83 .execute(&mut *conn) 81 84 .await?; 85 + 86 + Ok(()) 87 + } 88 + 89 + pub async fn set_transcoding_settings( 90 + &self, 91 + codec: Option<String>, 92 + bitrate: Option<String>, 93 + sampling_rate: Option<String>, 94 + ) -> Result<(), Error> { 95 + let mut conn = self.pool.begin().await?; 96 + 97 + let id = sqlx::query!(r#"SELECT id FROM transcoding_settings LIMIT 1"#) 98 + .fetch_one(&mut *conn) 99 + .await? 100 + .id; 101 + 102 + sqlx::query!( 103 + r#" 104 + INSERT OR REPLACE INTO transcoding_settings ( 105 + id, enabled, codec, bitrate, sampling_rate 106 + ) VALUES ( 107 + ?1, 1, ?2, ?3, ?4 108 + ) 109 + "#, 110 + id, 111 + codec, 112 + bitrate, 113 + sampling_rate 114 + ) 115 + .execute(&mut *conn) 116 + .await?; 117 + 118 + conn.commit().await?; 119 + 120 + Ok(()) 121 + } 122 + 123 + pub async fn get_transcoding_settings(&self) -> Result<Option<ffmpeg::Quality>, Error> { 124 + let mut conn = self.pool.acquire().await?; 125 + 126 + let ts = sqlx::query( 127 + r#" 128 + SELECT bitrate, sampling_rate, codec FROM transcoding_settings LIMIT 1; 129 + "#, 130 + ) 131 + .fetch_one(&mut *conn) 132 + .await?; 133 + 134 + if ts.len() == 0 { 135 + return Ok(None); 136 + } 137 + 138 + Ok(Some(ffmpeg::Quality { 139 + bitrate: ffmpeg::Bitrate::from_str(ts.get("bitrate")).unwrap(), 140 + sampling_rate: ts 141 + .try_get("sampling_rate") 142 + .map(|e| Some(ffmpeg::SamplingRate::from_str(e).unwrap()))?, 143 + codec: ffmpeg::Codec::from_str(ts.get("codec"), false).unwrap(), 144 + })) 145 + } 146 + 147 + pub async fn set_transcoding(&self, enabled: bool) -> Result<(), Error> { 148 + let mut conn = self.pool.begin().await?; 149 + 150 + let id = match sqlx::query!(r#"SELECT id FROM transcoding_settings LIMIT 1"#) 151 + .fetch_one(&mut *conn) 152 + .await 153 + { 154 + Ok(i) => i.id, 155 + Err(e) => match e { 156 + Error::RowNotFound => { 157 + sqlx::query_scalar!( 158 + r#" 159 + INSERT OR REPLACE INTO transcoding_settings ( 160 + enabled 161 + ) VALUES ( 162 + ?1 163 + ) RETURNING id 164 + "#, 165 + enabled 166 + ) 167 + .fetch_one(&mut *conn) 168 + .await? 169 + } 170 + _ => return Err(e), 171 + }, 172 + }; 173 + 174 + sqlx::query!( 175 + r#" 176 + INSERT OR REPLACE INTO transcoding_settings ( 177 + id, enabled 178 + ) VALUES ( 179 + ?1, ?2 180 + ) 181 + "#, 182 + id, 183 + enabled 184 + ) 185 + .execute(&mut *conn) 186 + .await?; 187 + 188 + conn.commit().await?; 82 189 83 190 Ok(()) 84 191 }
+8
src/db/migrations/0005_transcoding_settings.sql
··· 1 + CREATE TABLE IF NOT EXISTS transcoding_settings 2 + ( 3 + id INTEGER PRIMARY KEY NOT NULL, 4 + enabled BOOL DEFAULT FALSE, 5 + codec TEXT, 6 + bitrate TEXT, 7 + sampling_rate TEXT 8 + );
+253
src/ffmpeg.rs
··· 1 + use anyhow::{Result, anyhow}; 2 + use clap::{builder::TypedValueParser, error::ErrorKind}; 3 + use std::{path::PathBuf, str::FromStr}; 4 + 5 + use crate::model::Track; 6 + 7 + #[derive(Clone, Debug)] 8 + pub enum Bitrate { 9 + CBR(String), 10 + VBR(String), 11 + } 12 + 13 + impl FromStr for Bitrate { 14 + type Err = String; 15 + 16 + fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { 17 + let br_regex = regex::Regex::new("([0-9]+)k").unwrap(); 18 + let spl = s.split(":").collect::<Vec<&str>>(); 19 + 20 + if spl.len() != 2 { 21 + return Err("malformed bitrate format".to_owned()); 22 + } 23 + 24 + let br_type = spl[0]; 25 + let br_amt = spl[1]; 26 + if !br_regex.is_match(br_amt) { 27 + return Err("malformed bitrate format".to_owned()); 28 + } 29 + 30 + match br_type { 31 + "cbr" => Ok(Self::CBR(br_amt.to_owned())), 32 + "vbr" => Ok(Self::VBR(br_amt.to_owned())), 33 + _ => Err("malformed bitrate format".to_owned()), 34 + } 35 + } 36 + } 37 + 38 + #[derive(Clone)] 39 + pub struct BitrateParser {} 40 + 41 + impl TypedValueParser for BitrateParser { 42 + type Value = Bitrate; 43 + 44 + fn parse_ref( 45 + &self, 46 + cmd: &clap::Command, 47 + _: Option<&clap::Arg>, 48 + value: &std::ffi::OsStr, 49 + ) -> std::result::Result<Self::Value, clap::Error> { 50 + let mut err = clap::Error::new(ErrorKind::InvalidValue).with_cmd(cmd); 51 + 52 + match Self::Value::from_str(&value.to_string_lossy().to_string()) { 53 + Ok(b) => Ok(b), 54 + Err(e) => { 55 + err.insert( 56 + clap::error::ContextKind::InvalidArg, 57 + clap::error::ContextValue::String(e), 58 + ); 59 + 60 + Err(err) 61 + } 62 + } 63 + } 64 + 65 + fn possible_values( 66 + &self, 67 + ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> { 68 + Some(Box::new( 69 + ["cbr:{bitrate_integer}k", "vbr:{bitrate_integer}k"] 70 + .iter() 71 + .map(|s| clap::builder::PossibleValue::new(s)), 72 + )) 73 + } 74 + } 75 + 76 + impl Bitrate { 77 + fn cli_argument(&self) -> String { 78 + match self { 79 + Bitrate::CBR(b) => format!("-b:a {b}"), 80 + Bitrate::VBR(b) => format!("-q:a {b}"), 81 + } 82 + } 83 + } 84 + 85 + impl std::fmt::Display for Bitrate { 86 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 87 + match self { 88 + Bitrate::CBR(b) => write!(f, "cbr:{b}"), 89 + Bitrate::VBR(b) => write!(f, "vbr:{b}"), 90 + } 91 + } 92 + } 93 + 94 + #[derive(Clone, Copy, Debug, clap::ValueEnum)] 95 + pub enum Codec { 96 + AAC, 97 + MP3, 98 + OGG, 99 + Opus, 100 + } 101 + 102 + impl Codec { 103 + pub fn extension(&self) -> String { 104 + match self { 105 + Codec::AAC => "m4a", 106 + Codec::MP3 => "mp3", 107 + Codec::OGG | Codec::Opus => "ogg", 108 + } 109 + .to_owned() 110 + } 111 + } 112 + 113 + impl std::fmt::Display for Codec { 114 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 115 + write!( 116 + f, 117 + "{}", 118 + match self { 119 + Codec::AAC => "aac", 120 + Codec::MP3 => "mp3", 121 + Codec::OGG => "libvorbis", 122 + Codec::Opus => "opus", 123 + } 124 + ) 125 + } 126 + } 127 + 128 + #[derive(Clone, Copy, Debug)] 129 + pub enum SamplingRate { 130 + Rate441K, 131 + Rate48K, 132 + Rate96K, 133 + } 134 + 135 + #[derive(Clone)] 136 + pub struct SamplingRateParser {} 137 + 138 + impl TypedValueParser for SamplingRateParser { 139 + type Value = SamplingRate; 140 + 141 + fn parse_ref( 142 + &self, 143 + cmd: &clap::Command, 144 + arg: Option<&clap::Arg>, 145 + value: &std::ffi::OsStr, 146 + ) -> std::result::Result<Self::Value, clap::Error> { 147 + match Self::Value::from_str(&value.to_string_lossy().to_string()) { 148 + Ok(v) => Ok(v), 149 + Err(e) => { 150 + let mut err = clap::Error::new(ErrorKind::InvalidValue).with_cmd(cmd); 151 + err.insert( 152 + clap::error::ContextKind::InvalidArg, 153 + clap::error::ContextValue::String(arg.unwrap().to_string()), 154 + ); 155 + 156 + err.insert( 157 + clap::error::ContextKind::InvalidArg, 158 + clap::error::ContextValue::String(format!("{}", e)), 159 + ); 160 + 161 + Err(err) 162 + } 163 + } 164 + } 165 + 166 + fn possible_values( 167 + &self, 168 + ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> { 169 + Some(Box::new( 170 + ["44100", "48000", "96000"] 171 + .iter() 172 + .map(|s| clap::builder::PossibleValue::new(s)), 173 + )) 174 + } 175 + } 176 + 177 + impl FromStr for SamplingRate { 178 + type Err = String; 179 + 180 + fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { 181 + match s { 182 + "44100" => Ok(Self::Rate441K), 183 + "48000" => Ok(Self::Rate48K), 184 + "96000" => Ok(Self::Rate96K), 185 + _ => Err(format!("unsupported sampling rate: {s}")), 186 + } 187 + } 188 + } 189 + 190 + impl std::fmt::Display for SamplingRate { 191 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 192 + write!( 193 + f, 194 + "{}", 195 + match self { 196 + SamplingRate::Rate441K => "44100", 197 + SamplingRate::Rate48K => "48000", 198 + SamplingRate::Rate96K => "96000", 199 + } 200 + ) 201 + } 202 + } 203 + 204 + #[derive(Debug, Clone)] 205 + pub struct Quality { 206 + pub bitrate: Bitrate, 207 + pub sampling_rate: Option<SamplingRate>, 208 + pub codec: Codec, 209 + } 210 + 211 + pub fn convert(track: &Track, dest: PathBuf, quality: &Quality) -> Result<()> { 212 + let res = std::process::Command::new("ffmpeg") 213 + .arg("-y") 214 + .arg("-i") 215 + .arg(track.file_path.clone()) 216 + .args(["-c:v", "copy"]) 217 + .args(["-map_metadata", "0"]) 218 + .arg("-ar") 219 + .arg({ 220 + match quality.sampling_rate { 221 + Some(b) => b.to_string(), 222 + None => match quality.codec { 223 + Codec::Opus => SamplingRate::Rate48K.to_string(), 224 + _ => SamplingRate::Rate441K.to_string(), 225 + }, 226 + } 227 + }) 228 + .arg("-c:a") 229 + .arg(quality.codec.to_string()) 230 + .args(["-strict", "-2"]) 231 + .args( 232 + quality 233 + .bitrate 234 + .cli_argument() 235 + .split(" ") 236 + .collect::<Vec<&str>>(), 237 + ) 238 + .arg(format!( 239 + "{}.{}", 240 + dest.display().to_string(), 241 + quality.codec.extension() 242 + )) 243 + .output()?; 244 + 245 + match res.status.success() { 246 + true => Ok(()), 247 + false => Err(anyhow!( 248 + "exit status {}: {}", 249 + res.status, 250 + String::from_utf8(res.stderr).unwrap() 251 + )), 252 + } 253 + }
+1
src/fs.rs
··· 51 51 52 52 /// Returns true if name has one of the supported music file extension. 53 53 fn is_music(name: &String) -> bool { 54 + // TODO: look at the mimetype instead of trusting the extension 54 55 let formats = ["flac", "mp3", "ogg", "mp4", "m4a"]; 55 56 56 57 formats
+6 -2
src/main.rs
··· 2 2 mod cli; 3 3 mod cmd; 4 4 mod db; 5 + mod ffmpeg; 5 6 mod filter; 6 7 mod fs; 7 8 mod model; ··· 18 19 cli::Commands::Dupes(dupes_args) => Ok(cmd::dupes::run(dupes_args).await?), 19 20 cli::Commands::Clean(clean_args) => Ok(cmd::clean::run(clean_args).await?), 20 21 cli::Commands::Update(update_args) => Ok(cmd::add::run(update_args, true).await?), 21 - cli::Commands::Filter(filter_args) => Ok(cmd::filter::run(filter_args).await?), 22 + cli::Commands::Settings { command } => match command { 23 + cli::Settings::Filter(args) => Ok(cmd::filter::run(args).await?), 24 + cli::Settings::Transcode(args) => Ok(cmd::transcode::run(args).await?), 25 + }, 22 26 } 23 27 } 24 28 25 29 fn log_setup() { 26 30 if std::env::var("RUST_LOG").is_err() { 27 - std::env::set_var("RUST_LOG", "info") 31 + unsafe { std::env::set_var("RUST_LOG", "info") } 28 32 } 29 33 30 34 env_logger::init();
+15 -14
src/model.rs
··· 78 78 79 79 impl Track { 80 80 pub fn storage_path(&self, base: &str) -> String { 81 + self.storage_path_with_extension( 82 + base, 83 + std::path::Path::new(&self.file_path) 84 + .extension() 85 + .unwrap_or_default() 86 + .to_str() 87 + .unwrap(), 88 + ) 89 + } 90 + 91 + pub fn storage_path_with_extension(&self, base: &str, ext: &str) -> String { 81 92 let mut p = std::path::PathBuf::new(); 82 93 83 - let extension = std::path::Path::new(&self.file_path) 84 - .extension() 85 - .unwrap_or_default() 86 - .to_str() 87 - .unwrap(); 88 - 89 - let filename = format!("{}.{}", self.title, extension); 94 + let filename = format!("{}.{}", self.title, ext); 90 95 91 96 p.push(base); 92 97 p.push(clean(self.artist.clone(), false)); ··· 129 134 130 135 t.track_id = track_hash(&t); 131 136 137 + // TODO: use extracted mimetype extension. 132 138 let extension = std::path::Path::new(&t.file_path) 133 139 .extension() 134 140 .unwrap_or(std::ffi::OsStr::new("NONE")) ··· 155 161 156 162 // Incredibly ugly way to remove all characters sqlite3's FTS5 hates. 157 163 // I am ashamed of my self, but as they say, if it works it isn't stupid. 164 + // TODO: check that it's really FTS5 that hates this characters? 158 165 fn clean(s: String, is_file: bool) -> String { 159 166 let mut s = s.clone(); 160 167 ··· 171 178 "|", 172 179 "+", 173 180 ",", 174 - { 175 - if !is_file { 176 - "." 177 - } else { 178 - "" 179 - } 180 - }, 181 + { if !is_file { "." } else { "" } }, 181 182 ";", 182 183 "=", 183 184 "[",