diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml
index 6c63bae5..2d0f771c 100644
--- a/.github/workflows/spotube-release-binary.yml
+++ b/.github/workflows/spotube-release-binary.yml
@@ -112,6 +112,9 @@ jobs:
distribution: temurin
java-version: 21
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
@@ -170,6 +173,9 @@ jobs:
distribution: temurin
java-version: 21
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
@@ -231,6 +237,9 @@ jobs:
distribution: temurin
java-version: 21
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
@@ -268,6 +277,9 @@ jobs:
distribution: temurin
java-version: 21
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
diff --git a/.gitignore b/.gitignore
index cfc7da21..68c4954a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -112,4 +112,6 @@ android/app/.cxx
/node_modules
/package.json
/package-lock.json
-/bun.lock
\ No newline at end of file
+/bun.lock
+
+**/target/
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index eb5958c7..bd0d4631 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -30,4 +30,6 @@ plugins {
alias(libs.plugins.zipline.gradle.plugin) apply false
alias(libs.plugins.spotubeGradle) apply false
alias(libs.plugins.vlcjBundler) apply false
+ alias(libs.plugins.uniffi) apply false
+ alias(libs.plugins.cargo) apply false
}
\ No newline at end of file
diff --git a/composeApp/Cargo.lock b/composeApp/Cargo.lock
new file mode 100644
index 00000000..1e6bd60d
--- /dev/null
+++ b/composeApp/Cargo.lock
@@ -0,0 +1,723 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "anyhow"
+version = "1.0.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
+
+[[package]]
+name = "askama"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7"
+dependencies = [
+ "askama_derive",
+ "itoa",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "askama_derive"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac"
+dependencies = [
+ "askama_parser",
+ "basic-toml",
+ "memchr",
+ "proc-macro2",
+ "quote",
+ "rustc-hash",
+ "serde",
+ "serde_derive",
+ "syn",
+]
+
+[[package]]
+name = "askama_parser"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f"
+dependencies = [
+ "memchr",
+ "serde",
+ "serde_derive",
+ "winnow",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "basic-toml"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "camino"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "compose-app"
+version = "0.1.0"
+dependencies = [
+ "lofty",
+ "uniffi",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fs-err"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "goblin"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47"
+dependencies = [
+ "log",
+ "plain",
+ "scroll",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "lofty"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec4feeff6c7d75093278133a06e827d7af6d2bfe20b0f331f9d10338a5ec7ca"
+dependencies = [
+ "byteorder",
+ "data-encoding",
+ "flate2",
+ "lofty_attr",
+ "log",
+ "ogg_pager",
+ "paste",
+]
+
+[[package]]
+name = "lofty_attr"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "ogg_pager"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "scroll"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6"
+dependencies = [
+ "scroll_derive",
+]
+
+[[package]]
+name = "scroll_derive"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "siphasher"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
+
+[[package]]
+name = "smawk"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
+dependencies = [
+ "smawk",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "toml"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "uniffi"
+version = "0.29.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3291800a6b06569f7d3e15bdb6dc235e0f0c8bd3eb07177f430057feb076415f"
+dependencies = [
+ "anyhow",
+ "cargo_metadata",
+ "uniffi_bindgen",
+ "uniffi_core",
+ "uniffi_macros",
+ "uniffi_pipeline",
+]
+
+[[package]]
+name = "uniffi_bindgen"
+version = "0.29.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a04b99fa7796eaaa7b87976a0dbdd1178dc1ee702ea00aca2642003aef9b669e"
+dependencies = [
+ "anyhow",
+ "askama",
+ "camino",
+ "cargo_metadata",
+ "fs-err",
+ "glob",
+ "goblin",
+ "heck",
+ "indexmap",
+ "once_cell",
+ "serde",
+ "tempfile",
+ "textwrap",
+ "toml",
+ "uniffi_internal_macros",
+ "uniffi_meta",
+ "uniffi_pipeline",
+ "uniffi_udl",
+]
+
+[[package]]
+name = "uniffi_core"
+version = "0.29.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "once_cell",
+ "static_assertions",
+]
+
+[[package]]
+name = "uniffi_internal_macros"
+version = "0.29.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09acd2ce09c777dd65ee97c251d33c8a972afc04873f1e3b21eb3492ade16933"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "uniffi_macros"
+version = "0.29.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5596f178c4f7aafa1a501c4e0b96236a96bc2ef92bdb453d83e609dad0040152"
+dependencies = [
+ "camino",
+ "fs-err",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn",
+ "toml",
+ "uniffi_meta",
+]
+
+[[package]]
+name = "uniffi_meta"
+version = "0.29.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "beadc1f460eb2e209263c49c4f5b19e9a02e00a3b2b393f78ad10d766346ecff"
+dependencies = [
+ "anyhow",
+ "siphasher",
+ "uniffi_internal_macros",
+ "uniffi_pipeline",
+]
+
+[[package]]
+name = "uniffi_pipeline"
+version = "0.29.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd76b3ac8a2d964ca9fce7df21c755afb4c77b054a85ad7a029ad179cc5abb8a"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "tempfile",
+ "uniffi_internal_macros",
+]
+
+[[package]]
+name = "uniffi_udl"
+version = "0.29.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4319cf905911d70d5b97ce0f46f101619a22e9a189c8c46d797a9955e9233716"
+dependencies = [
+ "anyhow",
+ "textwrap",
+ "uniffi_meta",
+ "weedle2",
+]
+
+[[package]]
+name = "weedle2"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/composeApp/Cargo.toml b/composeApp/Cargo.toml
new file mode 100644
index 00000000..8617b0f7
--- /dev/null
+++ b/composeApp/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "compose-app"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+uniffi = "0.29.4"
+lofty = "0.24.0"
+
+[lib]
+crate-type = ["cdylib", "staticlib"]
+path = "src/commonMain/rust/lib.rs"
\ No newline at end of file
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 5b0e6c41..a2dc6e7f 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -15,6 +15,8 @@
* along with this program. If not, see .
*/
+import gobley.gradle.GobleyHost
+import gobley.gradle.cargo.dsl.jvm
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.reload.gradle.ComposeHotRun
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
@@ -30,6 +32,9 @@ plugins {
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.zipline.gradle.plugin)
alias(libs.plugins.vlcjBundler)
+ alias(libs.plugins.uniffi)
+ alias(libs.plugins.cargo)
+ kotlin("plugin.atomicfu") version libs.versions.kotlin
}
vlcjBundler {
@@ -334,3 +339,9 @@ tasks.withType().configureEach {
project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get()
)
}
+
+cargo {
+ builds.jvm {
+ embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget)
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt
index f00da582..cc1aea59 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt
@@ -392,25 +392,42 @@ class DownloadManager(
}
}
- private fun applyMetadata(item: DownloadItem, filename: String, downloadDir: Path) {
+ private suspend fun applyMetadata(item: DownloadItem, filename: String, downloadDir: Path) {
val filePath = downloadDir / filename.toPath()
if (!fileSystem.exists(filePath)) return
- // TODO: Write metadata/audio tags to the downloaded file.
- // This is the dedicated placeholder for metadata tagging.
- // Implementation should use a platform-specific audio tagging library
- // to write ID3 tags (MP3), MP4 atoms (M4A/AAC), Vorbis comments (OGG/FLAC), etc.
- //
- // Tags to write from the track metadata:
- // - Title: item.title
- // - Artist: item.artists
- // - Album: item.album
- // - Track number: item.track?.trackNumber
- // - Disc number: item.track?.discNumber
- // - Duration: item.track?.durationMs
- // - Album art: item.track?.thumbnails (download and embed)
- // - ISRC: item.track?.isrcCode
- // - External URI: item.track?.externalUri
+ val track = item.track ?: return
+
+ val coverBytes = try {
+ val thumbnail = track.thumbnails?.maxByOrNull { it.width * it.height }
+ if (thumbnail != null) {
+ val response = httpClient.get(thumbnail.url)
+ val channel = response.bodyAsChannel()
+ val packet = channel.readRemaining()
+ val bytes = packet.readByteArray()
+ packet.close()
+ bytes
+ } else {
+ null
+ }
+ } catch (e: Exception) {
+ logger.w(e) { "Failed to download cover art for ${item.title}" }
+ null
+ }
+
+ try {
+ uniffi.compose_app.writeAudioMetadata(
+ filePath = filePath.toString(),
+ title = item.title,
+ artists = item.artists,
+ album = item.album,
+ trackNumber = track.trackNumber,
+ discNumber = track.discNumber,
+ coverBytes = coverBytes,
+ )
+ } catch (e: Exception) {
+ logger.w(e) { "Failed to write audio metadata for ${item.title}" }
+ }
}
private fun resolveDownloadDir(): Path {
diff --git a/composeApp/src/commonMain/rust/lib.rs b/composeApp/src/commonMain/rust/lib.rs
new file mode 100644
index 00000000..8700d014
--- /dev/null
+++ b/composeApp/src/commonMain/rust/lib.rs
@@ -0,0 +1,160 @@
+use lofty::config::WriteOptions;
+use lofty::file::{AudioFile, FileType, TaggedFileExt};
+use lofty::picture::Picture;
+use lofty::probe::Probe;
+use lofty::tag::{Accessor, Tag, TagExt};
+use std::path::Path;
+
+#[derive(uniffi::Record)]
+pub struct AudioMetadata {
+ pub title: Option,
+ pub artists: Vec,
+ pub album: Option,
+ pub duration_ms: i64,
+ pub track_number: Option,
+ pub disc_number: Option,
+ pub cover_bytes: Option>,
+}
+
+#[derive(Debug, uniffi::Error)]
+pub enum AudioTagError {
+ FileNotFound,
+ ReadError { reason: String },
+ WriteError { reason: String },
+}
+
+impl std::fmt::Display for AudioTagError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ AudioTagError::FileNotFound => write!(f, "File not found"),
+ AudioTagError::ReadError { reason } => write!(f, "Read error: {}", reason),
+ AudioTagError::WriteError { reason } => write!(f, "Write error: {}", reason),
+ }
+ }
+}
+
+#[uniffi::export]
+fn read_audio_metadata(file_path: String) -> Result {
+ let path = Path::new(&file_path);
+ if !path.exists() {
+ return Err(AudioTagError::FileNotFound);
+ }
+
+ let tagged_file = Probe::open(path)
+ .map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?
+ .read()
+ .map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?;
+
+ let duration = tagged_file.properties().duration();
+ let duration_ms = duration.as_millis() as i64;
+
+ let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag());
+
+ let mut title = None;
+ let mut artists = Vec::new();
+ let mut album = None;
+ let mut track_number = None;
+ let mut disc_number = None;
+ let mut cover_bytes = None;
+
+ if let Some(tag) = tag {
+ title = tag.title().map(|s| s.to_string());
+
+ if let Some(artist) = tag.artist() {
+ artists = artist
+ .split(';')
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+ .collect();
+ }
+
+ album = tag.album().map(|s| s.to_string());
+ track_number = tag.track();
+ disc_number = tag.disk();
+
+ if let Some(pic) = tag.pictures().first() {
+ cover_bytes = Some(pic.data().to_vec());
+ }
+ }
+
+ Ok(AudioMetadata {
+ title,
+ artists,
+ album,
+ duration_ms,
+ track_number,
+ disc_number,
+ cover_bytes,
+ })
+}
+
+#[uniffi::export]
+fn write_audio_metadata(
+ file_path: String,
+ title: String,
+ artists: String,
+ album: Option,
+ track_number: Option,
+ disc_number: Option,
+ cover_bytes: Option>,
+) -> Result<(), AudioTagError> {
+ let path = Path::new(&file_path);
+ if !path.exists() {
+ return Err(AudioTagError::FileNotFound);
+ }
+
+ let mut tagged_file = Probe::open(path)
+ .map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?
+ .read()
+ .map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?;
+
+ let file_type = tagged_file.file_type();
+
+ if tagged_file.primary_tag().is_none() && tagged_file.first_tag().is_none() {
+ let new_tag = match file_type {
+ FileType::Mpeg => Tag::new(lofty::tag::TagType::Id3v2),
+ FileType::Mp4 => Tag::new(lofty::tag::TagType::Mp4Ilst),
+ FileType::Flac => Tag::new(lofty::tag::TagType::VorbisComments),
+ FileType::Opus | FileType::Vorbis => Tag::new(lofty::tag::TagType::VorbisComments),
+ _ => Tag::new(lofty::tag::TagType::Id3v2),
+ };
+ tagged_file.insert_tag(new_tag);
+ }
+
+ let tag = if tagged_file.primary_tag().is_some() {
+ tagged_file.primary_tag_mut().unwrap()
+ } else if tagged_file.first_tag().is_some() {
+ tagged_file.first_tag_mut().unwrap()
+ } else {
+ unreachable!()
+ };
+
+ tag.set_title(title);
+ tag.set_artist(artists);
+
+ if let Some(album_name) = album {
+ tag.set_album(album_name);
+ }
+
+ if let Some(num) = track_number {
+ tag.set_track(num as u32);
+ }
+
+ if let Some(num) = disc_number {
+ tag.set_disk(num as u32);
+ }
+
+ if let Some(bytes) = cover_bytes {
+ if !bytes.is_empty() {
+ let picture = Picture::unchecked(bytes).build();
+ tag.push_picture(picture);
+ }
+ }
+
+ tag.save_to_path(path, WriteOptions::default())
+ .map_err(|e| AudioTagError::WriteError { reason: e.to_string() })?;
+
+ Ok(())
+}
+
+uniffi::setup_scaffolding!();
diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt
index 16718db9..fa2b1cf5 100644
--- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt
+++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt
@@ -54,16 +54,23 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService {
if (scanned.incrementAndGet() > MAX_FILES_PER_SCAN) return@forEach
val parent = filePath.parent?.absolutePathString() ?: return@forEach
val trackPath = filePath.toAbsolutePath().toString()
- println("Debug: Found track path = $trackPath")
+
+ val metadata = try {
+ uniffi.compose_app.readAudioMetadata(trackPath)
+ } catch (_: Exception) {
+ null
+ }
+
val track = LocalMediaTrack(
path = trackPath,
- name = filePath.nameWithoutExtension.ifBlank {
- filePath.fileName.toString()
- },
- artists = emptyList(),
- durationMs = 0L,
- album = null,
- coverBytes = null,
+ name = metadata?.title?.takeIf { it.isNotBlank() }
+ ?: filePath.nameWithoutExtension.ifBlank {
+ filePath.fileName.toString()
+ },
+ artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(),
+ durationMs = metadata?.durationMs ?: 0L,
+ album = metadata?.album,
+ coverBytes = metadata?.coverBytes,
)
folders.getOrPut(parent) { mutableListOf() }.add(track)
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 99253336..3f71336f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -72,6 +72,8 @@ spotube-gradle = "0.1.0"
cryptography = "0.6.0"
reorderable = "3.1.0"
vlcjBundler = "0.1.0"
+uniffi = "0.3.7"
+kotlinx-atomicfu = "0.33.0"
[libraries]
appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
@@ -174,4 +176,7 @@ androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.lib
zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" }
mavenPublish = { id = "maven-publish" }
spotubeGradle = { id = "dev.krtirtho.spotube.gradle-plugin", version.ref = "spotube-gradle" }
-vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "vlcjBundler" }
\ No newline at end of file
+vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "vlcjBundler" }
+kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" }
+uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
+cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
\ No newline at end of file