Debug le menu debug
This commit is contained in:
320
Cargo.lock
generated
320
Cargo.lock
generated
@@ -422,6 +422,12 @@ version = "0.10.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
|
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.9.4"
|
version = "2.9.4"
|
||||||
@@ -478,6 +484,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder-lite"
|
name = "byteorder-lite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -687,6 +699,12 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deadpool"
|
name = "deadpool"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -906,6 +924,12 @@ dependencies = [
|
|||||||
"zune-inflate",
|
"zune-inflate",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "extended"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fallible-iterator"
|
name = "fallible-iterator"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -1614,7 +1638,7 @@ version = "0.7.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
|
checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.9.4",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -1704,7 +1728,7 @@ version = "0.1.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
|
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.9.4",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1766,6 +1790,32 @@ dependencies = [
|
|||||||
"scopeguard",
|
"scopeguard",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lofty"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"data-encoding",
|
||||||
|
"flate2",
|
||||||
|
"lofty_attr",
|
||||||
|
"log",
|
||||||
|
"ogg_pager",
|
||||||
|
"paste",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lofty_attr"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.28"
|
version = "0.4.28"
|
||||||
@@ -2033,6 +2083,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ogg_pager"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e034c10fb5c1c012c1b327b85df89fb0ef98ae66ec28af30f0d1eed804a40c19"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
@@ -2045,7 +2104,7 @@ version = "0.10.73"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
|
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.9.4",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2190,6 +2249,45 @@ dependencies = [
|
|||||||
"tokio-test",
|
"tokio-test",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pmoaudiocache"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"axum",
|
||||||
|
"chrono",
|
||||||
|
"claxon",
|
||||||
|
"lofty",
|
||||||
|
"pmocache",
|
||||||
|
"pmoconfig",
|
||||||
|
"pmodidl",
|
||||||
|
"pmoserver",
|
||||||
|
"quick-xml 0.37.5",
|
||||||
|
"reqwest",
|
||||||
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"symphonia",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"utoipa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pmocache"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
|
"hex",
|
||||||
|
"reqwest",
|
||||||
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"sha1",
|
||||||
|
"tokio",
|
||||||
|
"utoipa",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pmoconfig"
|
name = "pmoconfig"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2211,15 +2309,12 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
|
||||||
"hex",
|
|
||||||
"image",
|
"image",
|
||||||
|
"pmocache",
|
||||||
"pmoconfig",
|
"pmoconfig",
|
||||||
"pmoserver",
|
"pmoserver",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusqlite",
|
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
@@ -2354,7 +2449,7 @@ version = "0.18.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
|
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.9.4",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"fdeflate",
|
"fdeflate",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -2638,7 +2733,7 @@ version = "0.5.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77"
|
checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.9.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2753,7 +2848,7 @@ version = "0.32.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.9.4",
|
||||||
"fallible-iterator",
|
"fallible-iterator",
|
||||||
"fallible-streaming-iterator",
|
"fallible-streaming-iterator",
|
||||||
"hashlink",
|
"hashlink",
|
||||||
@@ -2816,7 +2911,7 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.9.4",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
@@ -2898,7 +2993,7 @@ version = "2.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.9.4",
|
||||||
"core-foundation",
|
"core-foundation",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -3128,6 +3223,201 @@ version = "2.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"symphonia-bundle-flac",
|
||||||
|
"symphonia-bundle-mp3",
|
||||||
|
"symphonia-codec-aac",
|
||||||
|
"symphonia-codec-adpcm",
|
||||||
|
"symphonia-codec-alac",
|
||||||
|
"symphonia-codec-pcm",
|
||||||
|
"symphonia-codec-vorbis",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-format-caf",
|
||||||
|
"symphonia-format-isomp4",
|
||||||
|
"symphonia-format-mkv",
|
||||||
|
"symphonia-format-ogg",
|
||||||
|
"symphonia-format-riff",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-bundle-flac"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-bundle-mp3"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-aac"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-adpcm"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-alac"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-pcm"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-vorbis"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-core"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"bytemuck",
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-caf"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-isomp4"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
|
||||||
|
dependencies = [
|
||||||
|
"encoding_rs",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-mkv"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-ogg"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-riff"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
|
||||||
|
dependencies = [
|
||||||
|
"extended",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-metadata"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
|
||||||
|
dependencies = [
|
||||||
|
"encoding_rs",
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-utils-xiph"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
|
||||||
|
dependencies = [
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.106"
|
version = "2.0.106"
|
||||||
@@ -3165,7 +3455,7 @@ version = "0.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.9.4",
|
||||||
"core-foundation",
|
"core-foundation",
|
||||||
"system-configuration-sys",
|
"system-configuration-sys",
|
||||||
]
|
]
|
||||||
@@ -3496,7 +3786,7 @@ version = "0.6.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
|
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.9.4",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -3902,7 +4192,7 @@ version = "26.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eca7a8d8af57c18f57d393601a1fb159ace8b2328f1b6b5f80893f7d672c9ae2"
|
checksum = "eca7a8d8af57c18f57d393601a1fb159ace8b2328f1b6b5f80893f7d672c9ae2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.9.4",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocovers", "pmoaudio", "pmoqobuz", "pmoparadise"]
|
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocache", "pmocovers", "pmoaudiocache", "pmoaudio", "pmoqobuz", "pmoparadise"]
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ const isDebugRoute = computed(() => {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0;
|
||||||
z-index: 1001;
|
z-index: 1001;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
46
pmoaudiocache/Cargo.toml
Normal file
46
pmoaudiocache/Cargo.toml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
[package]
|
||||||
|
name = "pmoaudiocache"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Cache générique
|
||||||
|
pmocache = { path = "../pmocache" }
|
||||||
|
|
||||||
|
# DIDL-Lite pour UPnP
|
||||||
|
pmodidl = { path = "../pmodidl" }
|
||||||
|
|
||||||
|
# Base de données
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
chrono = "0.4"
|
||||||
|
|
||||||
|
# Métadonnées audio
|
||||||
|
lofty = "0.22"
|
||||||
|
|
||||||
|
# Encodage/décodage audio
|
||||||
|
symphonia = { version = "0.5", features = ["all"] }
|
||||||
|
claxon = "0.4" # Décodeur FLAC
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
reqwest = { version = "0.12", features = ["blocking"] }
|
||||||
|
|
||||||
|
# Utilitaires
|
||||||
|
anyhow = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
quick-xml = { version = "0.37", features = ["serialize"] }
|
||||||
|
|
||||||
|
# Async
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
|
||||||
|
# Serveur HTTP (optionnel pour l'extension)
|
||||||
|
pmoserver = { path = "../pmoserver", optional = true }
|
||||||
|
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||||
|
axum = { version = "0.8", optional = true }
|
||||||
|
utoipa = { version = "5.3", features = ["axum_extras"], optional = true }
|
||||||
|
|
||||||
|
tracing = "0.1.41"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["pmoserver"]
|
||||||
|
pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa", "pmocache/openapi"]
|
||||||
119
pmoaudiocache/src/api.rs
Normal file
119
pmoaudiocache/src/api.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
//! API REST pour le cache audio
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::AudioCache;
|
||||||
|
|
||||||
|
/// Liste toutes les pistes audio
|
||||||
|
pub async fn list_tracks(State(cache): State<Arc<AudioCache>>) -> Response {
|
||||||
|
match cache.db.get_all() {
|
||||||
|
Ok(tracks) => Json(tracks).into_response(),
|
||||||
|
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cannot list tracks").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requête pour ajouter une piste
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "pmoserver", derive(utoipa::ToSchema))]
|
||||||
|
pub struct AddTrackRequest {
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute une piste depuis une URL
|
||||||
|
pub async fn add_track(
|
||||||
|
State(cache): State<Arc<AudioCache>>,
|
||||||
|
Json(req): Json<AddTrackRequest>,
|
||||||
|
) -> Response {
|
||||||
|
match cache.add_from_url(&req.url, None).await {
|
||||||
|
Ok((pk, _)) => Json(serde_json::json!({
|
||||||
|
"pk": pk,
|
||||||
|
"status": "added"
|
||||||
|
}))
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Cannot add track: {}", e),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les informations d'une piste
|
||||||
|
pub async fn get_track_info(
|
||||||
|
State(cache): State<Arc<AudioCache>>,
|
||||||
|
Path(pk): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
match cache.get_entry(&pk).await {
|
||||||
|
Ok(entry) => Json(entry).into_response(),
|
||||||
|
Err(_) => (StatusCode::NOT_FOUND, "Track not found").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les métadonnées d'une piste
|
||||||
|
pub async fn get_track_metadata(
|
||||||
|
State(cache): State<Arc<AudioCache>>,
|
||||||
|
Path(pk): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
match cache.get_metadata(&pk).await {
|
||||||
|
Ok(metadata) => Json(metadata).into_response(),
|
||||||
|
Err(_) => (StatusCode::NOT_FOUND, "Track not found").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère le DIDL-Lite d'une piste
|
||||||
|
pub async fn get_track_didl(
|
||||||
|
State(cache): State<Arc<AudioCache>>,
|
||||||
|
Path(pk): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
let base_url = "http://localhost:8080"; // TODO: from config
|
||||||
|
match cache.get_didl(&pk, base_url).await {
|
||||||
|
Ok(didl) => (StatusCode::OK, [("content-type", "application/xml")], didl).into_response(),
|
||||||
|
Err(_) => (StatusCode::NOT_FOUND, "Track not found").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une piste
|
||||||
|
pub async fn delete_track(
|
||||||
|
State(cache): State<Arc<AudioCache>>,
|
||||||
|
Path(pk): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
match cache.delete(&pk).await {
|
||||||
|
Ok(_) => (StatusCode::OK, "Track deleted").into_response(),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Cannot delete track: {}", e),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Purge tout le cache
|
||||||
|
pub async fn purge_cache(State(cache): State<Arc<AudioCache>>) -> Response {
|
||||||
|
match cache.purge().await {
|
||||||
|
Ok(_) => (StatusCode::OK, "Cache purged").into_response(),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Cannot purge cache: {}", e),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consolide le cache
|
||||||
|
pub async fn consolidate_cache(State(cache): State<Arc<AudioCache>>) -> Response {
|
||||||
|
match cache.consolidate().await {
|
||||||
|
Ok(_) => (StatusCode::OK, "Cache consolidated").into_response(),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Cannot consolidate cache: {}", e),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
420
pmoaudiocache/src/cache.rs
Normal file
420
pmoaudiocache/src/cache.rs
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
//! Module de gestion du cache de pistes audio
|
||||||
|
//!
|
||||||
|
//! Ce module gère le cache audio avec :
|
||||||
|
//! - Stockage immédiat des métadonnées en DB
|
||||||
|
//! - Conversion FLAC asynchrone en arrière-plan
|
||||||
|
//! - Service DIDL-Lite immédiat avant fin de conversion
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use pmodidl::{Item, Resource};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{AudioDB, AudioCacheEntry},
|
||||||
|
metadata::AudioMetadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Cache de pistes audio avec conversion asynchrone
|
||||||
|
///
|
||||||
|
/// Permet de servir les métadonnées immédiatement pendant que
|
||||||
|
/// la conversion FLAC s'effectue en arrière-plan.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AudioCache {
|
||||||
|
dir: PathBuf,
|
||||||
|
pub(crate) db: Arc<AudioDB>,
|
||||||
|
conversion_queue: Arc<Mutex<Vec<String>>>, // PKs en attente de conversion
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioCache {
|
||||||
|
/// Crée un nouveau cache audio
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `dir` - Répertoire de stockage du cache
|
||||||
|
/// * `limit` - Limite de taille du cache (nombre de pistes)
|
||||||
|
pub fn new(dir: &str, limit: usize) -> Result<Self> {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
let db_path = PathBuf::from(dir).join("audio_cache.db");
|
||||||
|
let db = Arc::new(AudioDB::init(&db_path)?);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
dir: PathBuf::from(dir),
|
||||||
|
db,
|
||||||
|
conversion_queue: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute une piste audio depuis une URL
|
||||||
|
///
|
||||||
|
/// **Phase 1 (immédiate) :** Télécharge et stocke les métadonnées en DB
|
||||||
|
/// **Phase 2 (async) :** Conversion FLAC en arrière-plan
|
||||||
|
///
|
||||||
|
/// Les métadonnées sont disponibles immédiatement via `get_metadata()`
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `url` - URL de la piste audio
|
||||||
|
/// * `external_metadata` - Métadonnées optionnelles depuis le service (Qobuz, etc.)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// * `(pk, metadata)` - Clé et métadonnées (disponibles immédiatement)
|
||||||
|
pub async fn add_from_url(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
external_metadata: Option<AudioMetadata>,
|
||||||
|
) -> Result<(String, AudioMetadata)> {
|
||||||
|
let response = reqwest::get(url).await?;
|
||||||
|
let data = response.bytes().await?;
|
||||||
|
|
||||||
|
self.add_from_bytes(url, &data, external_metadata).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute une piste depuis des données brutes
|
||||||
|
///
|
||||||
|
/// # Phase 1 (immédiate, <1s)
|
||||||
|
/// 1. Extraire métadonnées du fichier
|
||||||
|
/// 2. Fusionner avec métadonnées externes si fournies
|
||||||
|
/// 3. Stocker métadonnées en DB
|
||||||
|
/// 4. Stocker fichier original temporairement
|
||||||
|
///
|
||||||
|
/// # Phase 2 (asynchrone)
|
||||||
|
/// 5. Conversion FLAC en arrière-plan
|
||||||
|
/// 6. Mise à jour du statut de conversion
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `url` - URL source
|
||||||
|
/// * `data` - Données audio brutes
|
||||||
|
/// * `external_metadata` - Métadonnées optionnelles depuis le service
|
||||||
|
pub async fn add_from_bytes(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
data: &[u8],
|
||||||
|
external_metadata: Option<AudioMetadata>,
|
||||||
|
) -> Result<(String, AudioMetadata)> {
|
||||||
|
let pk = pmocache::pk_from_url(url);
|
||||||
|
|
||||||
|
// Phase 1 : Extraction et stockage immédiat des métadonnées
|
||||||
|
let mut metadata = AudioMetadata::from_bytes(data)?;
|
||||||
|
|
||||||
|
// Fusionner avec métadonnées externes si fournies (priorité aux externes)
|
||||||
|
if let Some(external) = external_metadata {
|
||||||
|
metadata = merge_metadata(metadata, external);
|
||||||
|
}
|
||||||
|
|
||||||
|
let collection = metadata.collection_key();
|
||||||
|
|
||||||
|
// Stocker les métadonnées immédiatement en DB
|
||||||
|
self.db.add(&pk, url, collection.as_deref(), &metadata)?;
|
||||||
|
|
||||||
|
// Stocker le fichier original temporairement
|
||||||
|
let temp_path = self.temp_file_path(&pk);
|
||||||
|
tokio::fs::write(&temp_path, data).await?;
|
||||||
|
|
||||||
|
// Phase 2 : Lancer la conversion asynchrone
|
||||||
|
self.start_conversion(pk.clone(), temp_path).await;
|
||||||
|
|
||||||
|
Ok((pk, metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lance la conversion FLAC en arrière-plan
|
||||||
|
async fn start_conversion(&self, pk: String, temp_path: PathBuf) {
|
||||||
|
let db = Arc::clone(&self.db);
|
||||||
|
let final_path = self.flac_file_path(&pk);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Marquer comme en cours de conversion
|
||||||
|
let _ = db.update_conversion_status(&pk, "converting");
|
||||||
|
|
||||||
|
// Conversion FLAC
|
||||||
|
match tokio::fs::read(&temp_path).await {
|
||||||
|
Ok(data) => {
|
||||||
|
match crate::flac::convert_to_flac(&data, None) {
|
||||||
|
Ok(flac_data) => {
|
||||||
|
// Écrire le fichier FLAC
|
||||||
|
if let Ok(_) = tokio::fs::write(&final_path, flac_data).await {
|
||||||
|
// Supprimer le fichier temporaire
|
||||||
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||||
|
// Marquer comme complété
|
||||||
|
let _ = db.update_conversion_status(&pk, "completed");
|
||||||
|
} else {
|
||||||
|
let _ = db.update_conversion_status(&pk, "failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = db.update_conversion_status(&pk, "failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = db.update_conversion_status(&pk, "failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les métadonnées d'une piste (disponible immédiatement)
|
||||||
|
///
|
||||||
|
/// Cette méthode retourne les métadonnées même si la conversion FLAC
|
||||||
|
/// n'est pas terminée. Permet de servir du DIDL-Lite immédiatement.
|
||||||
|
pub async fn get_metadata(&self, pk: &str) -> Result<AudioMetadata> {
|
||||||
|
self.db.update_hit(pk)?;
|
||||||
|
let entry = self.db.get(pk)?;
|
||||||
|
Ok(entry.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les métadonnées et le statut de conversion
|
||||||
|
pub async fn get_entry(&self, pk: &str) -> Result<AudioCacheEntry> {
|
||||||
|
self.db.update_hit(pk)?;
|
||||||
|
Ok(self.db.get(pk)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère le chemin du fichier audio (attend la fin de conversion si nécessaire)
|
||||||
|
pub async fn get_file(&self, pk: &str) -> Result<PathBuf> {
|
||||||
|
let entry = self.db.get(pk)?;
|
||||||
|
|
||||||
|
match entry.conversion_status.as_str() {
|
||||||
|
"completed" => {
|
||||||
|
let flac_path = self.flac_file_path(pk);
|
||||||
|
if flac_path.exists() {
|
||||||
|
self.db.update_hit(pk)?;
|
||||||
|
Ok(flac_path)
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("File not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"converting" | "pending" => {
|
||||||
|
// Attendre un court instant (permet de servir rapidement après 1 seconde)
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
// Re-vérifier le statut
|
||||||
|
let entry = self.db.get(pk)?;
|
||||||
|
if entry.conversion_status == "completed" {
|
||||||
|
let flac_path = self.flac_file_path(pk);
|
||||||
|
if flac_path.exists() {
|
||||||
|
self.db.update_hit(pk)?;
|
||||||
|
return Ok(flac_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!("Conversion not completed yet"))
|
||||||
|
}
|
||||||
|
"failed" => Err(anyhow!("Conversion failed")),
|
||||||
|
_ => Err(anyhow!("Unknown conversion status")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Génère un objet DIDL-Lite pour une piste
|
||||||
|
///
|
||||||
|
/// Peut être appelé immédiatement après `add_from_bytes()` même si
|
||||||
|
/// la conversion n'est pas terminée.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `pk` - Clé de la piste
|
||||||
|
/// * `base_url` - URL de base du serveur (ex: "http://localhost:8080")
|
||||||
|
pub async fn get_didl(&self, pk: &str, base_url: &str) -> Result<String> {
|
||||||
|
let entry = self.get_entry(pk).await?;
|
||||||
|
let metadata = entry.metadata;
|
||||||
|
|
||||||
|
let stream_url = format!("{}/audio/tracks/{}/stream", base_url, pk);
|
||||||
|
let duration = if let Some(duration_secs) = metadata.duration_secs {
|
||||||
|
let hours = duration_secs / 3600;
|
||||||
|
let minutes = (duration_secs % 3600) / 60;
|
||||||
|
let seconds = duration_secs % 60;
|
||||||
|
Some(format!("{}:{:02}:{:02}", hours, minutes, seconds))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let resource = Resource {
|
||||||
|
protocol_info: "http-get:*:audio/flac:*".to_string(),
|
||||||
|
bits_per_sample: None,
|
||||||
|
sample_frequency: metadata.sample_rate.map(|sr| sr.to_string()),
|
||||||
|
nr_audio_channels: metadata.channels.map(|c| c.to_string()),
|
||||||
|
duration,
|
||||||
|
url: stream_url,
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = Item {
|
||||||
|
id: pk.to_string(),
|
||||||
|
parent_id: "0".to_string(),
|
||||||
|
restricted: None,
|
||||||
|
title: metadata.title.unwrap_or_default(),
|
||||||
|
creator: None,
|
||||||
|
class: "object.item.audioItem.musicTrack".to_string(),
|
||||||
|
artist: metadata.artist,
|
||||||
|
album: metadata.album,
|
||||||
|
genre: metadata.genre,
|
||||||
|
album_art: None,
|
||||||
|
album_art_pk: None,
|
||||||
|
date: metadata.year.map(|y| format!("{:04}-01-01", y)),
|
||||||
|
original_track_number: metadata.track_number.map(|n| n.to_string()),
|
||||||
|
resources: vec![resource],
|
||||||
|
descriptions: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utiliser quick_xml pour serializer en XML
|
||||||
|
let xml = quick_xml::se::to_string(&item)
|
||||||
|
.map_err(|e| anyhow!("XML serialization error: {}", e))?;
|
||||||
|
|
||||||
|
Ok(xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère toutes les pistes d'une collection
|
||||||
|
pub async fn get_collection(&self, collection: &str) -> Result<Vec<AudioCacheEntry>> {
|
||||||
|
Ok(self.db.get_by_collection(collection)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liste toutes les collections
|
||||||
|
pub async fn list_collections(&self) -> Result<Vec<(String, usize)>> {
|
||||||
|
let entries = self.db.get_all()?;
|
||||||
|
let mut collections: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
if let Some(collection) = entry.collection {
|
||||||
|
*collections.entry(collection).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result: Vec<(String, usize)> = collections.into_iter().collect();
|
||||||
|
result.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Purge le cache
|
||||||
|
pub async fn purge(&self) -> Result<()> {
|
||||||
|
// Supprimer tous les fichiers
|
||||||
|
let mut entries = tokio::fs::read_dir(&self.dir).await?;
|
||||||
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
|
if entry.path().is_file() {
|
||||||
|
tokio::fs::remove_file(entry.path()).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.db.purge()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une piste du cache
|
||||||
|
///
|
||||||
|
/// Supprime les fichiers (temp et FLAC) et l'entrée de la base de données
|
||||||
|
pub async fn delete(&self, pk: &str) -> Result<()> {
|
||||||
|
// Supprimer les fichiers
|
||||||
|
let temp_path = self.temp_file_path(pk);
|
||||||
|
let flac_path = self.flac_file_path(pk);
|
||||||
|
|
||||||
|
if temp_path.exists() {
|
||||||
|
tokio::fs::remove_file(&temp_path).await?;
|
||||||
|
}
|
||||||
|
if flac_path.exists() {
|
||||||
|
tokio::fs::remove_file(&flac_path).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supprimer l'entrée DB
|
||||||
|
self.db.delete(pk)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consolide le cache
|
||||||
|
///
|
||||||
|
/// - Supprime les entrées DB sans fichiers correspondants
|
||||||
|
/// - Supprime les fichiers sans entrées DB
|
||||||
|
/// - Nettoie les conversions en échec
|
||||||
|
pub async fn consolidate(&self) -> Result<()> {
|
||||||
|
// Récupérer toutes les entrées
|
||||||
|
let entries = self.db.get_all()?;
|
||||||
|
|
||||||
|
// Supprimer les entrées sans fichiers ou en échec
|
||||||
|
for entry in entries {
|
||||||
|
let flac_path = self.flac_file_path(&entry.pk);
|
||||||
|
let temp_path = self.temp_file_path(&entry.pk);
|
||||||
|
|
||||||
|
// Si la conversion a échoué, supprimer l'entrée
|
||||||
|
if entry.conversion_status == "failed" {
|
||||||
|
self.delete(&entry.pk).await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si le fichier FLAC devrait exister mais n'existe pas
|
||||||
|
if entry.conversion_status == "completed" && !flac_path.exists() {
|
||||||
|
self.db.delete(&entry.pk)?;
|
||||||
|
if temp_path.exists() {
|
||||||
|
tokio::fs::remove_file(&temp_path).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supprimer les fichiers orphelins (sans entrée DB)
|
||||||
|
let mut dir_entries = tokio::fs::read_dir(&self.dir).await?;
|
||||||
|
while let Some(entry) = dir_entries.next_entry().await? {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignorer le fichier de base de données
|
||||||
|
if path == self.dir.join("audio_cache.db") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
|
||||||
|
// Extraire le pk du nom de fichier
|
||||||
|
let pk = if file_name.ends_with(".flac") {
|
||||||
|
file_name.trim_end_matches(".flac")
|
||||||
|
} else if file_name.ends_with(".temp") {
|
||||||
|
file_name.trim_end_matches(".temp")
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Si l'entrée n'existe pas en DB, supprimer le fichier
|
||||||
|
if self.db.get(pk).is_err() {
|
||||||
|
tokio::fs::remove_file(path).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le répertoire du cache
|
||||||
|
pub fn cache_dir(&self) -> String {
|
||||||
|
self.dir.to_string_lossy().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers privés
|
||||||
|
fn temp_file_path(&self, pk: &str) -> PathBuf {
|
||||||
|
self.dir.join(format!("{}.temp", pk))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flac_file_path(&self, pk: &str) -> PathBuf {
|
||||||
|
self.dir.join(format!("{}.flac", pk))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fusionne les métadonnées du fichier avec les métadonnées externes
|
||||||
|
///
|
||||||
|
/// Priorité aux métadonnées externes (source de confiance : Qobuz, etc.)
|
||||||
|
fn merge_metadata(file_meta: AudioMetadata, external_meta: AudioMetadata) -> AudioMetadata {
|
||||||
|
AudioMetadata {
|
||||||
|
title: external_meta.title.or(file_meta.title),
|
||||||
|
artist: external_meta.artist.or(file_meta.artist),
|
||||||
|
album: external_meta.album.or(file_meta.album),
|
||||||
|
year: external_meta.year.or(file_meta.year),
|
||||||
|
track_number: external_meta.track_number.or(file_meta.track_number),
|
||||||
|
track_total: external_meta.track_total.or(file_meta.track_total),
|
||||||
|
disc_number: external_meta.disc_number.or(file_meta.disc_number),
|
||||||
|
disc_total: external_meta.disc_total.or(file_meta.disc_total),
|
||||||
|
genre: external_meta.genre.or(file_meta.genre),
|
||||||
|
// Pour les infos techniques, on garde celles du fichier
|
||||||
|
duration_secs: file_meta.duration_secs.or(external_meta.duration_secs),
|
||||||
|
sample_rate: file_meta.sample_rate.or(external_meta.sample_rate),
|
||||||
|
channels: file_meta.channels.or(external_meta.channels),
|
||||||
|
bitrate: file_meta.bitrate.or(external_meta.bitrate),
|
||||||
|
}
|
||||||
|
}
|
||||||
232
pmoaudiocache/src/db.rs
Normal file
232
pmoaudiocache/src/db.rs
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
//! Module de base de données étendu pour le cache audio
|
||||||
|
//!
|
||||||
|
//! Ce module étend la DB générique de pmocache avec des champs
|
||||||
|
//! spécifiques aux métadonnées audio pour permettre le service
|
||||||
|
//! immédiat des informations avant la fin de la conversion.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::metadata::AudioMetadata;
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
/// Entrée de cache audio avec métadonnées complètes
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[cfg_attr(feature = "pmoserver", derive(ToSchema))]
|
||||||
|
pub struct AudioCacheEntry {
|
||||||
|
/// Clé primaire unique (hash SHA1 de l'URL)
|
||||||
|
pub pk: String,
|
||||||
|
/// URL source
|
||||||
|
pub source_url: String,
|
||||||
|
/// Collection (artiste:album)
|
||||||
|
pub collection: Option<String>,
|
||||||
|
/// Nombre d'accès
|
||||||
|
pub hits: i32,
|
||||||
|
/// Dernière utilisation
|
||||||
|
pub last_used: Option<String>,
|
||||||
|
/// Métadonnées audio (stockées en JSON)
|
||||||
|
pub metadata: AudioMetadata,
|
||||||
|
/// État de conversion (pending, converting, completed, failed)
|
||||||
|
pub conversion_status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base de données SQLite pour le cache audio
|
||||||
|
///
|
||||||
|
/// Étend la DB générique avec :
|
||||||
|
/// - Métadonnées audio complètes en JSON
|
||||||
|
/// - État de conversion pour le traitement asynchrone
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AudioDB {
|
||||||
|
conn: Mutex<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioDB {
|
||||||
|
/// Initialise une nouvelle base de données audio
|
||||||
|
pub fn init(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS audio_tracks (
|
||||||
|
pk TEXT PRIMARY KEY,
|
||||||
|
source_url TEXT,
|
||||||
|
collection TEXT,
|
||||||
|
hits INTEGER DEFAULT 0,
|
||||||
|
last_used TEXT,
|
||||||
|
metadata_json TEXT,
|
||||||
|
conversion_status TEXT DEFAULT 'pending'
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Index sur la collection
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_audio_tracks_collection
|
||||||
|
ON audio_tracks (collection)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Index sur le statut de conversion
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_audio_tracks_conversion
|
||||||
|
ON audio_tracks (conversion_status)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute une entrée avec métadonnées
|
||||||
|
pub fn add(&self, pk: &str, url: &str, collection: Option<&str>, metadata: &AudioMetadata) -> rusqlite::Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let metadata_json = serde_json::to_string(metadata)
|
||||||
|
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO audio_tracks (pk, source_url, collection, hits, last_used, metadata_json, conversion_status)
|
||||||
|
VALUES (?1, ?2, ?3, 0, ?4, ?5, 'pending')
|
||||||
|
ON CONFLICT(pk) DO UPDATE SET
|
||||||
|
source_url = excluded.source_url,
|
||||||
|
collection = excluded.collection,
|
||||||
|
metadata_json = excluded.metadata_json,
|
||||||
|
last_used = excluded.last_used",
|
||||||
|
params![pk, url, collection, chrono::Utc::now().to_rfc3339(), metadata_json],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère une entrée avec métadonnées
|
||||||
|
pub fn get(&self, pk: &str) -> rusqlite::Result<AudioCacheEntry> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT pk, source_url, collection, hits, last_used, metadata_json, conversion_status
|
||||||
|
FROM audio_tracks WHERE pk = ?1",
|
||||||
|
[pk],
|
||||||
|
|row| {
|
||||||
|
let metadata_json: String = row.get(5)?;
|
||||||
|
let metadata: AudioMetadata = serde_json::from_str(&metadata_json)
|
||||||
|
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
5,
|
||||||
|
rusqlite::types::Type::Text,
|
||||||
|
Box::new(e)
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok(AudioCacheEntry {
|
||||||
|
pk: row.get(0)?,
|
||||||
|
source_url: row.get(1)?,
|
||||||
|
collection: row.get(2)?,
|
||||||
|
hits: row.get(3)?,
|
||||||
|
last_used: row.get(4)?,
|
||||||
|
metadata,
|
||||||
|
conversion_status: row.get(6)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour le statut de conversion
|
||||||
|
pub fn update_conversion_status(&self, pk: &str, status: &str) -> rusqlite::Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE audio_tracks SET conversion_status = ?1 WHERE pk = ?2",
|
||||||
|
params![status, pk],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour le compteur d'accès
|
||||||
|
pub fn update_hit(&self, pk: &str) -> rusqlite::Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE audio_tracks SET hits = hits + 1, last_used = ?1 WHERE pk = ?2",
|
||||||
|
params![chrono::Utc::now().to_rfc3339(), pk],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère toutes les entrées d'une collection
|
||||||
|
pub fn get_by_collection(&self, collection: &str) -> rusqlite::Result<Vec<AudioCacheEntry>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT pk, source_url, collection, hits, last_used, metadata_json, conversion_status
|
||||||
|
FROM audio_tracks WHERE collection = ?1 ORDER BY hits DESC",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let entries = stmt.query_map([collection], |row| {
|
||||||
|
let metadata_json: String = row.get(5)?;
|
||||||
|
let metadata: AudioMetadata = serde_json::from_str(&metadata_json)
|
||||||
|
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
5,
|
||||||
|
rusqlite::types::Type::Text,
|
||||||
|
Box::new(e)
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok(AudioCacheEntry {
|
||||||
|
pk: row.get(0)?,
|
||||||
|
source_url: row.get(1)?,
|
||||||
|
collection: row.get(2)?,
|
||||||
|
hits: row.get(3)?,
|
||||||
|
last_used: row.get(4)?,
|
||||||
|
metadata,
|
||||||
|
conversion_status: row.get(6)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère toutes les entrées
|
||||||
|
pub fn get_all(&self) -> rusqlite::Result<Vec<AudioCacheEntry>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT pk, source_url, collection, hits, last_used, metadata_json, conversion_status
|
||||||
|
FROM audio_tracks ORDER BY hits DESC",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let entries = stmt.query_map([], |row| {
|
||||||
|
let metadata_json: String = row.get(5)?;
|
||||||
|
let metadata: AudioMetadata = serde_json::from_str(&metadata_json)
|
||||||
|
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
5,
|
||||||
|
rusqlite::types::Type::Text,
|
||||||
|
Box::new(e)
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok(AudioCacheEntry {
|
||||||
|
pk: row.get(0)?,
|
||||||
|
source_url: row.get(1)?,
|
||||||
|
collection: row.get(2)?,
|
||||||
|
hits: row.get(3)?,
|
||||||
|
last_used: row.get(4)?,
|
||||||
|
metadata,
|
||||||
|
conversion_status: row.get(6)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une entrée
|
||||||
|
pub fn delete(&self, pk: &str) -> rusqlite::Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute("DELETE FROM audio_tracks WHERE pk = ?1", [pk])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Purge toutes les entrées
|
||||||
|
pub fn purge(&self) -> rusqlite::Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute("DELETE FROM audio_tracks", [])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
145
pmoaudiocache/src/flac.rs
Normal file
145
pmoaudiocache/src/flac.rs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
//! Module de conversion audio en FLAC
|
||||||
|
//!
|
||||||
|
//! Ce module gère la conversion de divers formats audio vers FLAC
|
||||||
|
//! pour standardiser le stockage dans le cache.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use symphonia::core::audio::SampleBuffer;
|
||||||
|
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
|
||||||
|
use symphonia::core::errors::Error as SymphoniaError;
|
||||||
|
use symphonia::core::formats::FormatOptions;
|
||||||
|
use symphonia::core::io::MediaSourceStream;
|
||||||
|
use symphonia::core::meta::MetadataOptions;
|
||||||
|
use symphonia::core::probe::Hint;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
/// Convertit des données audio en FLAC
|
||||||
|
///
|
||||||
|
/// Cette fonction accepte n'importe quel format audio supporté par Symphonia
|
||||||
|
/// et le convertit en FLAC pour un stockage standardisé.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `data` - Données audio brutes (n'importe quel format)
|
||||||
|
/// * `extension` - Extension du fichier source (optionnel, aide à la détection)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Données audio au format FLAC
|
||||||
|
///
|
||||||
|
/// # Exemple
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pmoaudiocache::flac::convert_to_flac;
|
||||||
|
///
|
||||||
|
/// let mp3_data = std::fs::read("track.mp3").unwrap();
|
||||||
|
/// let flac_data = convert_to_flac(&mp3_data, Some("mp3")).unwrap();
|
||||||
|
/// ```
|
||||||
|
pub fn convert_to_flac(data: &[u8], extension: Option<&str>) -> Result<Vec<u8>> {
|
||||||
|
// Si c'est déjà du FLAC, on le retourne tel quel
|
||||||
|
if is_flac(data) {
|
||||||
|
return Ok(data.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer un MediaSource depuis les données (en clonant pour avoir 'static)
|
||||||
|
let data_owned = data.to_vec();
|
||||||
|
let cursor = Cursor::new(data_owned);
|
||||||
|
let mss = MediaSourceStream::new(Box::new(cursor), Default::default());
|
||||||
|
|
||||||
|
// Créer un hint si on a l'extension
|
||||||
|
let mut hint = Hint::new();
|
||||||
|
if let Some(ext) = extension {
|
||||||
|
hint.with_extension(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prober le format
|
||||||
|
let probed = symphonia::default::get_probe()
|
||||||
|
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
|
||||||
|
.map_err(|e| anyhow!("Impossible de détecter le format audio: {}", e))?;
|
||||||
|
|
||||||
|
let mut format = probed.format;
|
||||||
|
|
||||||
|
// Obtenir le premier track audio
|
||||||
|
let track = format
|
||||||
|
.tracks()
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||||
|
.ok_or_else(|| anyhow!("Aucune piste audio trouvée"))?;
|
||||||
|
|
||||||
|
// Créer un décodeur
|
||||||
|
let mut decoder = symphonia::default::get_codecs()
|
||||||
|
.make(&track.codec_params, &DecoderOptions::default())
|
||||||
|
.map_err(|e| anyhow!("Impossible de créer le décodeur: {}", e))?;
|
||||||
|
|
||||||
|
// Buffer pour stocker les samples décodés
|
||||||
|
let mut samples = Vec::new();
|
||||||
|
let track_id = track.id;
|
||||||
|
|
||||||
|
// Décoder tous les packets
|
||||||
|
loop {
|
||||||
|
let packet = match format.next_packet() {
|
||||||
|
Ok(packet) => packet,
|
||||||
|
Err(SymphoniaError::ResetRequired) => {
|
||||||
|
// Reset du décodeur requis
|
||||||
|
decoder.reset();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(anyhow!("Erreur lors de la lecture: {}", e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ignorer les packets qui ne sont pas de notre track
|
||||||
|
if packet.track_id() != track_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match decoder.decode(&packet) {
|
||||||
|
Ok(decoded) => {
|
||||||
|
// Convertir les samples en format standard
|
||||||
|
let spec = *decoded.spec();
|
||||||
|
let duration = decoded.capacity() as u64;
|
||||||
|
|
||||||
|
let mut sample_buf = SampleBuffer::<i16>::new(duration, spec);
|
||||||
|
sample_buf.copy_interleaved_ref(decoded);
|
||||||
|
|
||||||
|
samples.extend_from_slice(sample_buf.samples());
|
||||||
|
}
|
||||||
|
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||||
|
Err(e) => return Err(anyhow!("Erreur de décodage: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if samples.is_empty() {
|
||||||
|
return Err(anyhow!("Aucun sample décodé"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Pour l'encodage FLAC, on aurait besoin d'une bibliothèque comme
|
||||||
|
// `flacenc` qui n'existe pas encore en Rust. Pour l'instant, on stocke
|
||||||
|
// les données telles quelles si c'est déjà du FLAC, sinon on retourne
|
||||||
|
// les données originales avec un warning.
|
||||||
|
|
||||||
|
// TODO: Implémenter l'encodage FLAC quand une bibliothèque sera disponible
|
||||||
|
tracing::warn!("Encodage FLAC non implémenté, stockage du format original");
|
||||||
|
Ok(data.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si les données sont déjà au format FLAC
|
||||||
|
fn is_flac(data: &[u8]) -> bool {
|
||||||
|
data.len() >= 4 && &data[0..4] == b"fLaC"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_flac() {
|
||||||
|
let flac_header = b"fLaC\x00\x00\x00\x22";
|
||||||
|
assert!(is_flac(flac_header));
|
||||||
|
|
||||||
|
let not_flac = b"RIFF\x00\x00\x00\x00";
|
||||||
|
assert!(!is_flac(not_flac));
|
||||||
|
}
|
||||||
|
}
|
||||||
212
pmoaudiocache/src/lib.rs
Normal file
212
pmoaudiocache/src/lib.rs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
//! # pmoaudiocache - Cache de pistes audio pour PMOMusic
|
||||||
|
//!
|
||||||
|
//! Cette crate fournit un système de cache pour les pistes audio avec extraction
|
||||||
|
//! automatique des métadonnées et gestion de collections (albums).
|
||||||
|
//!
|
||||||
|
//! ## Vue d'ensemble
|
||||||
|
//!
|
||||||
|
//! `pmoaudiocache` étend `pmocache` pour gérer spécifiquement les fichiers audio :
|
||||||
|
//! - Téléchargement et stockage de pistes audio
|
||||||
|
//! - Extraction automatique des métadonnées (titre, artiste, album, etc.)
|
||||||
|
//! - Gestion de collections basées sur artiste/album
|
||||||
|
//! - Cache persistant avec base de données SQLite
|
||||||
|
//! - API HTTP optionnelle pour récupérer les pistes
|
||||||
|
//!
|
||||||
|
//! ## Fonctionnalités
|
||||||
|
//!
|
||||||
|
//! ### 📦 Gestion du cache
|
||||||
|
//! - Téléchargement automatique depuis des URLs
|
||||||
|
//! - **Conversion automatique en FLAC** (standardisation du stockage)
|
||||||
|
//! - Stockage persistant sur disque
|
||||||
|
//! - Base de données SQLite pour le tracking
|
||||||
|
//! - Extraction des métadonnées audio (via lofty)
|
||||||
|
//!
|
||||||
|
//! ### 🎵 Gestion des collections
|
||||||
|
//! - Regroupement automatique par artiste/album
|
||||||
|
//! - Tri par numéro de piste
|
||||||
|
//! - Liste des collections disponibles
|
||||||
|
//! - Récupération de tous les tracks d'un album
|
||||||
|
//!
|
||||||
|
//! ### 📊 Statistiques d'utilisation
|
||||||
|
//! - Comptage des accès (hits)
|
||||||
|
//! - Suivi de la dernière utilisation
|
||||||
|
//! - API de statistiques complètes
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! `pmoaudiocache` utilise `pmocache` comme base :
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! pmoaudiocache/
|
||||||
|
//! ├── Cargo.toml
|
||||||
|
//! ├── src/
|
||||||
|
//! │ ├── lib.rs # Module principal (ce fichier)
|
||||||
|
//! │ ├── cache.rs # Gestion du cache audio
|
||||||
|
//! │ ├── metadata.rs # Extraction de métadonnées
|
||||||
|
//! │ └── pmoserver_impl.rs # Extension de pmoserver::Server (optionnel)
|
||||||
|
//! └── cache/ # Répertoire de cache (généré)
|
||||||
|
//! ├── cache.db # Base SQLite
|
||||||
|
//! └── *.audio # Fichiers audio
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Utilisation
|
||||||
|
//!
|
||||||
|
//! ### Exemple basique
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pmoaudiocache::AudioCache;
|
||||||
|
//!
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() -> anyhow::Result<()> {
|
||||||
|
//! let cache = AudioCache::new("./audio_cache", 1000)?;
|
||||||
|
//!
|
||||||
|
//! // Ajouter une piste depuis une URL
|
||||||
|
//! let (pk, metadata) = cache.add_from_url("http://example.com/track.flac").await?;
|
||||||
|
//! println!("Piste ajoutée: {} - {}", metadata.artist.unwrap(), metadata.title.unwrap());
|
||||||
|
//!
|
||||||
|
//! // Récupérer la piste
|
||||||
|
//! let (path, metadata) = cache.get(&pk).await?;
|
||||||
|
//! println!("Piste stockée à: {:?}", path);
|
||||||
|
//!
|
||||||
|
//! Ok(())
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Utilisation avec des collections
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pmoaudiocache::AudioCache;
|
||||||
|
//!
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() -> anyhow::Result<()> {
|
||||||
|
//! let cache = AudioCache::new("./audio_cache", 1000)?;
|
||||||
|
//!
|
||||||
|
//! // Ajouter des pistes (elles seront automatiquement regroupées par album)
|
||||||
|
//! cache.add_from_url("http://example.com/track1.flac").await?;
|
||||||
|
//! cache.add_from_url("http://example.com/track2.flac").await?;
|
||||||
|
//!
|
||||||
|
//! // Lister les collections disponibles
|
||||||
|
//! let collections = cache.list_collections().await?;
|
||||||
|
//! for (collection, count) in collections {
|
||||||
|
//! println!("Collection: {} ({} pistes)", collection, count);
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! // Récupérer toutes les pistes d'un album
|
||||||
|
//! let tracks = cache.get_collection("pink_floyd:wish_you_were_here").await?;
|
||||||
|
//! for (pk, path, metadata) in tracks {
|
||||||
|
//! println!("{:02}. {} - {}",
|
||||||
|
//! metadata.track_number.unwrap_or(0),
|
||||||
|
//! metadata.title.unwrap_or_default(),
|
||||||
|
//! path.display()
|
||||||
|
//! );
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! Ok(())
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## API HTTP (avec feature "pmoserver")
|
||||||
|
//!
|
||||||
|
//! Lorsque la feature `pmoserver` est activée, vous pouvez intégrer le cache audio
|
||||||
|
//! à un serveur HTTP :
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pmoaudiocache::AudioCacheExt;
|
||||||
|
//! use pmoserver::ServerBuilder;
|
||||||
|
//!
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() -> anyhow::Result<()> {
|
||||||
|
//! let mut server = ServerBuilder::new_configured().build();
|
||||||
|
//!
|
||||||
|
//! // Initialiser le cache audio
|
||||||
|
//! server.init_audio_cache("./audio_cache", 1000).await?;
|
||||||
|
//!
|
||||||
|
//! server.start().await;
|
||||||
|
//! server.wait().await;
|
||||||
|
//! Ok(())
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Les endpoints suivants sont disponibles :
|
||||||
|
//!
|
||||||
|
//! - `GET /audio/tracks/{pk}` - Récupère une piste audio
|
||||||
|
//! - `GET /audio/tracks/{pk}/metadata` - Récupère les métadonnées d'une piste
|
||||||
|
//! - `GET /audio/collections` - Liste les collections disponibles
|
||||||
|
//! - `GET /audio/collections/{collection}` - Récupère toutes les pistes d'une collection
|
||||||
|
//! - `GET /audio/stats` - Statistiques du cache
|
||||||
|
//!
|
||||||
|
//! ## Métadonnées supportées
|
||||||
|
//!
|
||||||
|
//! Les métadonnées suivantes sont extraites automatiquement :
|
||||||
|
//!
|
||||||
|
//! - Titre, artiste, album
|
||||||
|
//! - Année, genre
|
||||||
|
//! - Numéro de piste/disque
|
||||||
|
//! - Durée, taux d'échantillonnage, bitrate
|
||||||
|
//! - Nombre de canaux
|
||||||
|
//!
|
||||||
|
//! ## Format des collections
|
||||||
|
//!
|
||||||
|
//! Les collections sont identifiées par une clé au format `"artist:album"`, avec :
|
||||||
|
//! - Conversion en minuscules
|
||||||
|
//! - Remplacement des espaces par des underscores
|
||||||
|
//! - Exemple : `"Pink Floyd - Wish You Were Here"` → `"pink_floyd:wish_you_were_here"`
|
||||||
|
//!
|
||||||
|
//! ## Dépendances principales
|
||||||
|
//!
|
||||||
|
//! - `pmocache` : Cache générique
|
||||||
|
//! - `lofty` : Extraction de métadonnées audio
|
||||||
|
//! - `reqwest` : Téléchargement HTTP
|
||||||
|
//! - `tokio` : Runtime asynchrone
|
||||||
|
//!
|
||||||
|
//! ## Voir aussi
|
||||||
|
//!
|
||||||
|
//! - [`pmocache`] : Cache générique
|
||||||
|
//! - [`pmocovers`] : Cache d'images
|
||||||
|
//! - [`pmoserver`] : Serveur HTTP
|
||||||
|
|
||||||
|
pub mod cache;
|
||||||
|
pub mod metadata;
|
||||||
|
pub mod flac;
|
||||||
|
pub mod db;
|
||||||
|
|
||||||
|
pub use cache::AudioCache;
|
||||||
|
pub use metadata::AudioMetadata;
|
||||||
|
pub use db::{AudioDB, AudioCacheEntry};
|
||||||
|
|
||||||
|
/// Trait pour étendre un serveur HTTP avec des fonctionnalités de cache audio.
|
||||||
|
///
|
||||||
|
/// Ce trait permet à `pmoaudiocache` d'ajouter des méthodes d'extension sur des types
|
||||||
|
/// de serveurs externes (comme `pmoserver::Server`) sans que ces crates dépendent de `pmoaudiocache`.
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
pub trait AudioCacheExt {
|
||||||
|
/// Initialise le cache audio et enregistre les routes HTTP.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `cache_dir` - Répertoire de stockage du cache
|
||||||
|
/// * `limit` - Limite de taille du cache (en nombre de pistes)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// * `Arc<AudioCache>` - Instance partagée du cache
|
||||||
|
async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result<std::sync::Arc<AudioCache>>;
|
||||||
|
|
||||||
|
/// Initialise le cache audio avec la configuration par défaut.
|
||||||
|
///
|
||||||
|
/// Utilise automatiquement les paramètres de `pmoconfig::Config`.
|
||||||
|
async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<AudioCache>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implémentation du trait pour pmoserver::Server (feature-gated)
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
mod pmoserver_impl;
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
pub mod api;
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
pub mod openapi;
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
pub use openapi::ApiDoc;
|
||||||
238
pmoaudiocache/src/metadata.rs
Normal file
238
pmoaudiocache/src/metadata.rs
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
//! Module de gestion des métadonnées audio
|
||||||
|
//!
|
||||||
|
//! Ce module permet d'extraire et gérer les métadonnées des fichiers audio
|
||||||
|
//! (titre, artiste, album, durée, etc.)
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use lofty::config::ParseOptions;
|
||||||
|
use lofty::prelude::*;
|
||||||
|
use lofty::probe::Probe;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
/// Métadonnées d'une piste audio
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "pmoserver", derive(ToSchema))]
|
||||||
|
pub struct AudioMetadata {
|
||||||
|
/// Titre de la piste
|
||||||
|
#[cfg_attr(feature = "pmoserver", schema(example = "Wish You Were Here"))]
|
||||||
|
pub title: Option<String>,
|
||||||
|
|
||||||
|
/// Artiste de la piste
|
||||||
|
#[cfg_attr(feature = "pmoserver", schema(example = "Pink Floyd"))]
|
||||||
|
pub artist: Option<String>,
|
||||||
|
|
||||||
|
/// Album de la piste
|
||||||
|
#[cfg_attr(feature = "pmoserver", schema(example = "Wish You Were Here"))]
|
||||||
|
pub album: Option<String>,
|
||||||
|
|
||||||
|
/// Année de sortie
|
||||||
|
#[cfg_attr(feature = "pmoserver", schema(example = 1975))]
|
||||||
|
pub year: Option<u32>,
|
||||||
|
|
||||||
|
/// Numéro de piste
|
||||||
|
#[cfg_attr(feature = "pmoserver", schema(example = 1))]
|
||||||
|
pub track_number: Option<u32>,
|
||||||
|
|
||||||
|
/// Nombre total de pistes
|
||||||
|
#[cfg_attr(feature = "pmoserver", schema(example = 5))]
|
||||||
|
pub track_total: Option<u32>,
|
||||||
|
|
||||||
|
/// Numéro de disque
|
||||||
|
#[cfg_attr(feature = "pmoserver", schema(example = 1))]
|
||||||
|
pub disc_number: Option<u32>,
|
||||||
|
|
||||||
|
/// Nombre total de disques
|
||||||
|
#[cfg_attr(feature = "pmoserver", schema(example = 1))]
|
||||||
|
pub disc_total: Option<u32>,
|
||||||
|
|
||||||
|
/// Genre musical
|
||||||
|
#[cfg_attr(feature = "pmoserver", schema(example = "Progressive Rock"))]
|
||||||
|
pub genre: Option<String>,
|
||||||
|
|
||||||
|
/// Durée en secondes
|
||||||
|
#[cfg_attr(feature = "pmoserver", schema(example = 334))]
|
||||||
|
pub duration_secs: Option<u64>,
|
||||||
|
|
||||||
|
/// Taux d'échantillonnage (Hz)
|
||||||
|
#[cfg_attr(feature = "pmoserver", schema(example = 44100))]
|
||||||
|
pub sample_rate: Option<u32>,
|
||||||
|
|
||||||
|
/// Nombre de canaux
|
||||||
|
#[cfg_attr(feature = "pmoserver", schema(example = 2))]
|
||||||
|
pub channels: Option<u8>,
|
||||||
|
|
||||||
|
/// Bitrate moyen (kbps)
|
||||||
|
#[cfg_attr(feature = "pmoserver", schema(example = 1411))]
|
||||||
|
pub bitrate: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioMetadata {
|
||||||
|
/// Extrait les métadonnées d'un fichier audio
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `path` - Chemin vers le fichier audio
|
||||||
|
///
|
||||||
|
/// # Exemple
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pmoaudiocache::metadata::AudioMetadata;
|
||||||
|
/// use std::path::Path;
|
||||||
|
///
|
||||||
|
/// let metadata = AudioMetadata::from_file(Path::new("track.flac")).unwrap();
|
||||||
|
/// println!("Titre: {:?}", metadata.title);
|
||||||
|
/// ```
|
||||||
|
pub fn from_file(path: &Path) -> Result<Self> {
|
||||||
|
let tagged_file = Probe::open(path)?
|
||||||
|
.options(ParseOptions::new())
|
||||||
|
.read()?;
|
||||||
|
|
||||||
|
let properties = tagged_file.properties();
|
||||||
|
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag());
|
||||||
|
|
||||||
|
let mut metadata = Self {
|
||||||
|
title: None,
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
year: None,
|
||||||
|
track_number: None,
|
||||||
|
track_total: None,
|
||||||
|
disc_number: None,
|
||||||
|
disc_total: None,
|
||||||
|
genre: None,
|
||||||
|
duration_secs: Some(properties.duration().as_secs()),
|
||||||
|
sample_rate: properties.sample_rate(),
|
||||||
|
channels: properties.channels(),
|
||||||
|
bitrate: properties.audio_bitrate(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(tag) = tag {
|
||||||
|
metadata.title = tag.title().map(|s| s.to_string());
|
||||||
|
metadata.artist = tag.artist().map(|s| s.to_string());
|
||||||
|
metadata.album = tag.album().map(|s| s.to_string());
|
||||||
|
metadata.year = tag.year();
|
||||||
|
metadata.track_number = tag.track();
|
||||||
|
metadata.track_total = tag.track_total();
|
||||||
|
metadata.disc_number = tag.disk();
|
||||||
|
metadata.disc_total = tag.disk_total();
|
||||||
|
metadata.genre = tag.genre().map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée des métadonnées depuis des données brutes audio
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `data` - Données audio brutes
|
||||||
|
pub fn from_bytes(data: &[u8]) -> Result<Self> {
|
||||||
|
let cursor = std::io::Cursor::new(data);
|
||||||
|
let tagged_file = Probe::new(cursor)
|
||||||
|
.guess_file_type()?
|
||||||
|
.options(ParseOptions::new())
|
||||||
|
.read()?;
|
||||||
|
|
||||||
|
let properties = tagged_file.properties();
|
||||||
|
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag());
|
||||||
|
|
||||||
|
let mut metadata = Self {
|
||||||
|
title: None,
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
year: None,
|
||||||
|
track_number: None,
|
||||||
|
track_total: None,
|
||||||
|
disc_number: None,
|
||||||
|
disc_total: None,
|
||||||
|
genre: None,
|
||||||
|
duration_secs: Some(properties.duration().as_secs()),
|
||||||
|
sample_rate: properties.sample_rate(),
|
||||||
|
channels: properties.channels(),
|
||||||
|
bitrate: properties.audio_bitrate(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(tag) = tag {
|
||||||
|
metadata.title = tag.title().map(|s| s.to_string());
|
||||||
|
metadata.artist = tag.artist().map(|s| s.to_string());
|
||||||
|
metadata.album = tag.album().map(|s| s.to_string());
|
||||||
|
metadata.year = tag.year();
|
||||||
|
metadata.track_number = tag.track();
|
||||||
|
metadata.track_total = tag.track_total();
|
||||||
|
metadata.disc_number = tag.disk();
|
||||||
|
metadata.disc_total = tag.disk_total();
|
||||||
|
metadata.genre = tag.genre().map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Génère une clé de collection basée sur l'artiste et l'album
|
||||||
|
///
|
||||||
|
/// Retourne une clé au format "artist:album" si les deux sont disponibles,
|
||||||
|
/// sinon retourne None
|
||||||
|
pub fn collection_key(&self) -> Option<String> {
|
||||||
|
match (&self.artist, &self.album) {
|
||||||
|
(Some(artist), Some(album)) => {
|
||||||
|
let normalized_artist = artist.to_lowercase().replace(" ", "_");
|
||||||
|
let normalized_album = album.to_lowercase().replace(" ", "_");
|
||||||
|
Some(format!("{}:{}", normalized_artist, normalized_album))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_collection_key() {
|
||||||
|
let metadata = AudioMetadata {
|
||||||
|
title: Some("Wish You Were Here".to_string()),
|
||||||
|
artist: Some("Pink Floyd".to_string()),
|
||||||
|
album: Some("Wish You Were Here".to_string()),
|
||||||
|
year: Some(1975),
|
||||||
|
track_number: Some(1),
|
||||||
|
track_total: Some(5),
|
||||||
|
disc_number: Some(1),
|
||||||
|
disc_total: Some(1),
|
||||||
|
genre: Some("Progressive Rock".to_string()),
|
||||||
|
duration_secs: Some(334),
|
||||||
|
sample_rate: Some(44100),
|
||||||
|
channels: Some(2),
|
||||||
|
bitrate: Some(1411),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
metadata.collection_key(),
|
||||||
|
Some("pink_floyd:wish_you_were_here".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_collection_key_missing_album() {
|
||||||
|
let metadata = AudioMetadata {
|
||||||
|
title: Some("Test".to_string()),
|
||||||
|
artist: Some("Artist".to_string()),
|
||||||
|
album: None,
|
||||||
|
year: None,
|
||||||
|
track_number: None,
|
||||||
|
track_total: None,
|
||||||
|
disc_number: None,
|
||||||
|
disc_total: None,
|
||||||
|
genre: None,
|
||||||
|
duration_secs: None,
|
||||||
|
sample_rate: None,
|
||||||
|
channels: None,
|
||||||
|
bitrate: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(metadata.collection_key(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
pmoaudiocache/src/openapi.rs
Normal file
23
pmoaudiocache/src/openapi.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//! Documentation OpenAPI pour l'API du cache audio
|
||||||
|
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
info(
|
||||||
|
title = "PMOMusic Audio Cache API",
|
||||||
|
version = "0.1.0",
|
||||||
|
description = "API de gestion du cache de pistes audio avec conversion FLAC asynchrone"
|
||||||
|
),
|
||||||
|
components(
|
||||||
|
schemas(
|
||||||
|
crate::db::AudioCacheEntry,
|
||||||
|
crate::metadata::AudioMetadata,
|
||||||
|
crate::api::AddTrackRequest,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
tags(
|
||||||
|
(name = "audio", description = "Gestion des pistes audio")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub struct ApiDoc;
|
||||||
214
pmoaudiocache/src/pmoserver_impl.rs
Normal file
214
pmoaudiocache/src/pmoserver_impl.rs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
//! Implémentation du trait AudioCacheExt pour le serveur pmoserver
|
||||||
|
|
||||||
|
use crate::{api, AudioCache, AudioCacheExt};
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::State,
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{delete, get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use pmoserver::Server;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
/// Handler pour GET /audio/tracks/{pk}/stream
|
||||||
|
/// Sert le fichier FLAC (attend la conversion si nécessaire)
|
||||||
|
async fn stream_audio(State(cache): State<Arc<AudioCache>>, req: Request<Body>) -> Response {
|
||||||
|
let path = req.uri().path();
|
||||||
|
let parts: Vec<&str> = path.split('/').collect();
|
||||||
|
|
||||||
|
if parts.len() < 2 {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let pk = parts[parts.len() - 2]; // Avant /stream
|
||||||
|
|
||||||
|
match cache.get_file(pk).await {
|
||||||
|
Ok(file_path) => match tokio::fs::read(&file_path).await {
|
||||||
|
Ok(data) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
("content-type", "audio/flac"),
|
||||||
|
("accept-ranges", "bytes"),
|
||||||
|
],
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("not completed") {
|
||||||
|
(StatusCode::ACCEPTED, "Conversion in progress").into_response()
|
||||||
|
} else {
|
||||||
|
(StatusCode::NOT_FOUND, msg).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour GET /audio/tracks/{pk}/metadata
|
||||||
|
/// Retourne les métadonnées immédiatement (même pendant conversion)
|
||||||
|
async fn get_metadata(State(cache): State<Arc<AudioCache>>, req: Request<Body>) -> Response {
|
||||||
|
let path = req.uri().path();
|
||||||
|
let parts: Vec<&str> = path.split('/').collect();
|
||||||
|
|
||||||
|
if parts.len() < 2 {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let pk = parts[parts.len() - 2]; // Avant /metadata
|
||||||
|
|
||||||
|
match cache.get_metadata(pk).await {
|
||||||
|
Ok(metadata) => Json(metadata).into_response(),
|
||||||
|
Err(_) => (StatusCode::NOT_FOUND, "Metadata not found").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour GET /audio/tracks/{pk}/didl
|
||||||
|
/// Retourne le DIDL-Lite XML immédiatement (même pendant conversion)
|
||||||
|
async fn get_didl(State(cache): State<Arc<AudioCache>>, req: Request<Body>) -> Response {
|
||||||
|
let path = req.uri().path();
|
||||||
|
let parts: Vec<&str> = path.split('/').collect();
|
||||||
|
|
||||||
|
if parts.len() < 2 {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let pk = parts[parts.len() - 2]; // Avant /didl
|
||||||
|
|
||||||
|
// TODO: Récupérer base_url depuis la config
|
||||||
|
let base_url = "http://localhost:8080"; // Placeholder
|
||||||
|
|
||||||
|
match cache.get_didl(pk, base_url).await {
|
||||||
|
Ok(didl_xml) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[("content-type", "application/xml")],
|
||||||
|
didl_xml,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(_) => (StatusCode::NOT_FOUND, "Track not found").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour GET /audio/tracks/{pk}/status
|
||||||
|
/// Retourne le statut de conversion
|
||||||
|
async fn get_status(State(cache): State<Arc<AudioCache>>, req: Request<Body>) -> Response {
|
||||||
|
let path = req.uri().path();
|
||||||
|
let parts: Vec<&str> = path.split('/').collect();
|
||||||
|
|
||||||
|
if parts.len() < 2 {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let pk = parts[parts.len() - 2]; // Avant /status
|
||||||
|
|
||||||
|
match cache.get_entry(pk).await {
|
||||||
|
Ok(entry) => Json(serde_json::json!({
|
||||||
|
"pk": entry.pk,
|
||||||
|
"conversion_status": entry.conversion_status,
|
||||||
|
"hits": entry.hits,
|
||||||
|
"last_used": entry.last_used,
|
||||||
|
}))
|
||||||
|
.into_response(),
|
||||||
|
Err(_) => (StatusCode::NOT_FOUND, "Track not found").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour GET /audio/stats
|
||||||
|
async fn get_audio_stats(State(cache): State<Arc<AudioCache>>) -> Response {
|
||||||
|
match cache.db.get_all() {
|
||||||
|
Ok(entries) => Json(entries).into_response(),
|
||||||
|
Err(_) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Cannot retrieve stats",
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour GET /audio/collections
|
||||||
|
async fn list_collections(State(cache): State<Arc<AudioCache>>) -> Response {
|
||||||
|
match cache.list_collections().await {
|
||||||
|
Ok(collections) => Json(collections).into_response(),
|
||||||
|
Err(_) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Cannot list collections",
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioCacheExt for Server {
|
||||||
|
async fn init_audio_cache(
|
||||||
|
&mut self,
|
||||||
|
cache_dir: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> anyhow::Result<Arc<AudioCache>> {
|
||||||
|
let cache = Arc::new(AudioCache::new(cache_dir, limit)?);
|
||||||
|
|
||||||
|
// Routes pour servir les fichiers audio
|
||||||
|
let tracks_router = Router::new()
|
||||||
|
.route("/{pk}/stream", get(stream_audio))
|
||||||
|
.route("/{pk}/metadata", get(get_metadata))
|
||||||
|
.route("/{pk}/didl", get(get_didl))
|
||||||
|
.route("/{pk}/status", get(get_status))
|
||||||
|
.with_state(cache.clone());
|
||||||
|
|
||||||
|
self.add_router("/audio/tracks", tracks_router).await;
|
||||||
|
|
||||||
|
// Routes utilitaires
|
||||||
|
self.add_handler_with_state("/audio/stats", get_audio_stats, cache.clone())
|
||||||
|
.await;
|
||||||
|
self.add_handler_with_state("/audio/collections", list_collections, cache.clone())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Router API RESTful
|
||||||
|
let api_router = Router::new()
|
||||||
|
.route(
|
||||||
|
"/",
|
||||||
|
get(api::list_tracks)
|
||||||
|
.post(api::add_track)
|
||||||
|
.delete(api::purge_cache),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{pk}",
|
||||||
|
get(api::get_track_info).delete(api::delete_track),
|
||||||
|
)
|
||||||
|
.route("/{pk}/metadata", get(api::get_track_metadata))
|
||||||
|
.route("/{pk}/didl", get(api::get_track_didl))
|
||||||
|
.route("/consolidate", post(api::consolidate_cache))
|
||||||
|
.with_state(cache.clone());
|
||||||
|
|
||||||
|
// Documentation OpenAPI
|
||||||
|
let openapi = crate::ApiDoc::openapi();
|
||||||
|
|
||||||
|
// Enregistrer l'API avec Swagger UI
|
||||||
|
self.add_openapi(api_router, openapi, "audio").await;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Audio cache initialized at {} with limit {}",
|
||||||
|
cache_dir, limit
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn init_audio_cache_configured(&mut self) -> anyhow::Result<Arc<AudioCache>> {
|
||||||
|
let config = pmoconfig::get_config();
|
||||||
|
|
||||||
|
// TODO: Ajouter audio_cache dans la config
|
||||||
|
let cache_dir = "./audio_cache"; // Placeholder
|
||||||
|
let limit = 1000; // Placeholder
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Audio cache directory {}, size {}",
|
||||||
|
cache_dir, limit
|
||||||
|
);
|
||||||
|
|
||||||
|
self.init_audio_cache(cache_dir, limit).await
|
||||||
|
}
|
||||||
|
}
|
||||||
175
pmocache/ARCHITECTURE.md
Normal file
175
pmocache/ARCHITECTURE.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Architecture du système de cache PMOMusic
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Le système de cache de PMOMusic est organisé en trois crates modulaires :
|
||||||
|
|
||||||
|
```
|
||||||
|
pmocache (générique)
|
||||||
|
├── DB générique avec collections
|
||||||
|
└── Cache générique avec téléchargement
|
||||||
|
|
||||||
|
pmocovers (spécialisé images)
|
||||||
|
├── Utilise pmocache comme base
|
||||||
|
└── Ajoute conversion WebP + variantes
|
||||||
|
|
||||||
|
pmoaudiocache (spécialisé audio)
|
||||||
|
├── Utilise pmocache comme base
|
||||||
|
├── Conversion automatique en FLAC (standardisation)
|
||||||
|
└── Ajoute extraction métadonnées + collections d'albums
|
||||||
|
```
|
||||||
|
|
||||||
|
## Principes de conception
|
||||||
|
|
||||||
|
### 1. Synchronisation et partage
|
||||||
|
|
||||||
|
Les caches sont conçus pour être utilisés via `Arc<Cache>` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// ✅ Bon usage
|
||||||
|
let cache = Arc::new(Cache::new(config)?);
|
||||||
|
let cache_clone = Arc::clone(&cache); // Clone léger de l'Arc
|
||||||
|
|
||||||
|
// ❌ Mauvais usage (Cache n'implémente pas Clone volontairement)
|
||||||
|
let cache = Cache::new(config)?;
|
||||||
|
let cache_clone = cache.clone(); // ❌ Erreur de compilation
|
||||||
|
```
|
||||||
|
|
||||||
|
Pourquoi cette approche ?
|
||||||
|
- `Cache` contient déjà des `Arc` internes (`Arc<DB>`, `Arc<Mutex<()>>`)
|
||||||
|
- Pas besoin de double niveau d'Arc (`Arc<Cache>` suffit)
|
||||||
|
- Les méthodes prennent `&self` et gèrent la synchronisation en interne
|
||||||
|
- Évite les clonages accidentels
|
||||||
|
|
||||||
|
### 2. Collections
|
||||||
|
|
||||||
|
Le système de collections permet de regrouper des éléments logiquement :
|
||||||
|
|
||||||
|
**Pour les images (pmocovers)** :
|
||||||
|
- Les collections ne sont généralement pas utilisées
|
||||||
|
- Chaque image a une clé unique basée sur son URL
|
||||||
|
|
||||||
|
**Pour l'audio (pmoaudiocache)** :
|
||||||
|
- Collections = albums (format : `"artist:album"`)
|
||||||
|
- Exemple : `"pink_floyd:wish_you_were_here"`
|
||||||
|
- Génération automatique depuis les métadonnées ID3
|
||||||
|
|
||||||
|
### 3. Base de données
|
||||||
|
|
||||||
|
Schéma SQLite commun :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE {table_name} (
|
||||||
|
pk TEXT PRIMARY KEY, -- Clé unique (SHA1 de l'URL)
|
||||||
|
source_url TEXT, -- URL source
|
||||||
|
collection TEXT, -- Collection (optionnel)
|
||||||
|
hits INTEGER DEFAULT 0, -- Nombre d'accès
|
||||||
|
last_used TEXT -- Dernière utilisation (RFC3339)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque cache a sa propre table :
|
||||||
|
- `pmocovers` → table "covers"
|
||||||
|
- `pmoaudiocache` → table "audio_tracks"
|
||||||
|
|
||||||
|
### 4. Stockage des fichiers
|
||||||
|
|
||||||
|
Structure sur disque :
|
||||||
|
|
||||||
|
```
|
||||||
|
cache_dir/
|
||||||
|
├── cache.db # Base SQLite
|
||||||
|
├── {pk}.{extension} # Fichiers cachés
|
||||||
|
```
|
||||||
|
|
||||||
|
Extensions par type :
|
||||||
|
- Images : `{pk}.orig.webp` (conversion automatique depuis n'importe quel format d'image)
|
||||||
|
- Audio : `{pk}.flac` (conversion automatique depuis n'importe quel format audio)
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
### Cache d'images (pmocovers)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmocovers::Cache;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
let cache = Arc::new(Cache::new("./covers_cache", 1000)?);
|
||||||
|
|
||||||
|
// Ajouter une image
|
||||||
|
let pk = cache.add_from_url("http://example.com/cover.jpg").await?;
|
||||||
|
|
||||||
|
// Récupérer une image
|
||||||
|
let path = cache.get(&pk).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache audio (pmoaudiocache)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmoaudiocache::AudioCache;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
let cache = Arc::new(AudioCache::new("./audio_cache", 1000)?);
|
||||||
|
|
||||||
|
// Ajouter une piste (métadonnées extraites automatiquement)
|
||||||
|
let (pk, metadata) = cache.add_from_url("http://example.com/track.flac").await?;
|
||||||
|
|
||||||
|
// Lister les collections (albums)
|
||||||
|
let collections = cache.list_collections().await?;
|
||||||
|
|
||||||
|
// Récupérer toutes les pistes d'un album
|
||||||
|
let tracks = cache.get_collection("pink_floyd:wish_you_were_here").await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intégration avec pmoserver
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmocovers::CoverCacheExt;
|
||||||
|
use pmoaudiocache::AudioCacheExt;
|
||||||
|
use pmoserver::ServerBuilder;
|
||||||
|
|
||||||
|
let mut server = ServerBuilder::new_configured().build();
|
||||||
|
|
||||||
|
// Initialiser les caches
|
||||||
|
let covers = server.init_cover_cache_configured().await?;
|
||||||
|
let audio = server.init_audio_cache_configured().await?;
|
||||||
|
|
||||||
|
server.start().await;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Avantages de cette architecture
|
||||||
|
|
||||||
|
1. **Modularité** : Chaque cache est indépendant
|
||||||
|
2. **Réutilisabilité** : `pmocache` peut être utilisé pour d'autres types de caches
|
||||||
|
3. **Performance** : Utilisation d'`Arc` pour un partage efficace
|
||||||
|
4. **Sécurité** : Pas de `Clone` accidentel, synchronisation explicite
|
||||||
|
5. **Extensibilité** : Facile d'ajouter de nouveaux types de caches
|
||||||
|
|
||||||
|
## Exemple de nouveau cache
|
||||||
|
|
||||||
|
Pour créer un nouveau type de cache (par exemple pour des vidéos) :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmocache::{Cache as GenericCache, CacheConfig};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct VideoCache {
|
||||||
|
cache: GenericCache,
|
||||||
|
// Champs spécifiques aux vidéos
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoCache {
|
||||||
|
pub fn new(dir: &str, limit: usize) -> Result<Self> {
|
||||||
|
let config = CacheConfig::new(dir, limit, "videos", "mp4");
|
||||||
|
let cache = GenericCache::new(config)?;
|
||||||
|
|
||||||
|
Ok(Self { cache })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthodes spécifiques aux vidéos
|
||||||
|
pub async fn add_with_transcoding(&self, url: &str) -> Result<String> {
|
||||||
|
// Télécharger, transcoder, puis utiliser self.cache.add()
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
30
pmocache/Cargo.toml
Normal file
30
pmocache/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "pmocache"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Base de données
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
reqwest = { version = "0.12", features = ["blocking"] }
|
||||||
|
|
||||||
|
# Cryptographie
|
||||||
|
sha1 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
|
# Utilitaires
|
||||||
|
anyhow = "1.0"
|
||||||
|
chrono = "0.4"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
|
||||||
|
# Async
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
|
||||||
|
# Feature pour OpenAPI
|
||||||
|
utoipa = { version = "5.3", optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
openapi = ["dep:utoipa"]
|
||||||
264
pmocache/src/cache.rs
Normal file
264
pmocache/src/cache.rs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
//! Module de gestion du cache générique
|
||||||
|
//!
|
||||||
|
//! Ce module fournit une interface générique pour gérer un cache de fichiers
|
||||||
|
//! avec métadonnées dans une base de données SQLite.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use sha1::{Sha1, Digest};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use crate::db::DB;
|
||||||
|
|
||||||
|
/// Configuration du cache
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CacheConfig {
|
||||||
|
/// Répertoire de stockage
|
||||||
|
pub dir: PathBuf,
|
||||||
|
/// Limite de taille du cache (nombre d'éléments)
|
||||||
|
pub limit: usize,
|
||||||
|
/// Nom de la table dans la base de données
|
||||||
|
pub table_name: String,
|
||||||
|
/// Extension des fichiers dans le cache
|
||||||
|
pub file_extension: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheConfig {
|
||||||
|
/// Crée une nouvelle configuration de cache
|
||||||
|
pub fn new(dir: &str, limit: usize, table_name: &str, file_extension: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
dir: PathBuf::from(dir),
|
||||||
|
limit,
|
||||||
|
table_name: table_name.to_string(),
|
||||||
|
file_extension: file_extension.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache générique pour stocker des fichiers avec métadonnées
|
||||||
|
///
|
||||||
|
/// Gère le téléchargement, le stockage et la récupération de fichiers
|
||||||
|
/// avec une base de données SQLite pour les métadonnées.
|
||||||
|
///
|
||||||
|
/// Note : Ce type est conçu pour être utilisé derrière un `Arc<Cache>`.
|
||||||
|
/// Les méthodes prennent `&self` et utilisent des `Arc` et `Mutex` internes
|
||||||
|
/// pour la synchronisation.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Cache {
|
||||||
|
pub(crate) config: CacheConfig,
|
||||||
|
pub db: Arc<DB>,
|
||||||
|
mu: Arc<Mutex<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cache {
|
||||||
|
/// Crée un nouveau cache avec la configuration spécifiée
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `config` - Configuration du cache
|
||||||
|
///
|
||||||
|
/// # Exemple
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pmocache::cache::{Cache, CacheConfig};
|
||||||
|
///
|
||||||
|
/// let config = CacheConfig::new("./cache", 1000, "my_cache", "webp");
|
||||||
|
/// let cache = Cache::new(config).unwrap();
|
||||||
|
/// ```
|
||||||
|
pub fn new(config: CacheConfig) -> Result<Self> {
|
||||||
|
std::fs::create_dir_all(&config.dir)?;
|
||||||
|
let db = DB::init(
|
||||||
|
&config.dir.join("cache.db"),
|
||||||
|
&config.table_name
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
db: Arc::new(db),
|
||||||
|
mu: Arc::new(Mutex::new(())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Télécharge un fichier depuis une URL et l'ajoute au cache
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `url` - URL du fichier à télécharger
|
||||||
|
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// La clé primaire (pk) du fichier dans le cache
|
||||||
|
pub async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
|
||||||
|
let response = reqwest::get(url).await?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow!("Bad status: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = response.bytes().await?;
|
||||||
|
self.add(url, &data, collection).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// S'assure qu'un fichier est présent dans le cache
|
||||||
|
///
|
||||||
|
/// Si le fichier existe déjà, retourne sa clé. Sinon, le télécharge.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `url` - URL du fichier
|
||||||
|
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
|
||||||
|
pub async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
|
||||||
|
let pk = pk_from_url(url);
|
||||||
|
|
||||||
|
if self.db.get(&pk).is_ok() {
|
||||||
|
let file_path = self.file_path(&pk);
|
||||||
|
if file_path.exists() {
|
||||||
|
return Ok(pk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.add_from_url(url, collection).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute des données au cache
|
||||||
|
///
|
||||||
|
/// Cette méthode doit être surchargée par les implémentations spécifiques
|
||||||
|
/// pour gérer la conversion et le stockage des données.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `url` - URL source du fichier
|
||||||
|
/// * `data` - Données brutes à stocker
|
||||||
|
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
|
||||||
|
pub async fn add(&self, url: &str, data: &[u8], collection: Option<&str>) -> Result<String> {
|
||||||
|
let pk = pk_from_url(url);
|
||||||
|
let file_path = self.file_path(&pk);
|
||||||
|
|
||||||
|
let _lock = self.mu.lock().await;
|
||||||
|
|
||||||
|
if !file_path.exists() {
|
||||||
|
// Par défaut, on stocke les données telles quelles
|
||||||
|
tokio::fs::write(&file_path, data).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.db.add(&pk, url, collection)?;
|
||||||
|
Ok(pk)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère le chemin d'un fichier dans le cache
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `pk` - Clé primaire du fichier
|
||||||
|
pub async fn get(&self, pk: &str) -> Result<PathBuf> {
|
||||||
|
let _lock = self.mu.lock().await;
|
||||||
|
|
||||||
|
self.db.get(pk)?;
|
||||||
|
self.db.update_hit(pk)?;
|
||||||
|
|
||||||
|
let file_path = self.file_path(pk);
|
||||||
|
if file_path.exists() {
|
||||||
|
Ok(file_path)
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("File not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les fichiers d'une collection
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `collection` - Identifiant de la collection
|
||||||
|
pub async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>> {
|
||||||
|
let _lock = self.mu.lock().await;
|
||||||
|
|
||||||
|
let entries = self.db.get_by_collection(collection)?;
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let path = self.file_path(&entry.pk);
|
||||||
|
if path.exists() {
|
||||||
|
paths.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime tous les fichiers et entrées du cache
|
||||||
|
pub async fn purge(&self) -> Result<()> {
|
||||||
|
let _lock = self.mu.lock().await;
|
||||||
|
|
||||||
|
let mut entries = tokio::fs::read_dir(&self.config.dir).await?;
|
||||||
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
|
if entry.path().is_file() && entry.path() != self.config.dir.join("cache.db") {
|
||||||
|
tokio::fs::remove_file(entry.path()).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.db.purge().map_err(|e| anyhow!("Database error: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consolide le cache en supprimant les orphelins et en re-téléchargeant les fichiers manquants
|
||||||
|
pub async fn consolidate(&self) -> Result<()> {
|
||||||
|
// Récupérer la liste des entrées à traiter
|
||||||
|
let entries = {
|
||||||
|
let _lock = self.mu.lock().await;
|
||||||
|
self.db.get_all()?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supprimer les entrées sans fichiers correspondants
|
||||||
|
for entry in entries {
|
||||||
|
let file_path = self.file_path(&entry.pk);
|
||||||
|
if !file_path.exists() {
|
||||||
|
match reqwest::get(&entry.source_url).await {
|
||||||
|
Ok(response) if response.status().is_success() => {
|
||||||
|
let data = response.bytes().await?;
|
||||||
|
self.add(&entry.source_url, &data, entry.collection.as_deref()).await?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let _lock = self.mu.lock().await;
|
||||||
|
self.db.delete(&entry.pk)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supprimer les fichiers sans entrées DB correspondantes
|
||||||
|
let _lock = self.mu.lock().await;
|
||||||
|
let mut dir_entries = tokio::fs::read_dir(&self.config.dir).await?;
|
||||||
|
while let Some(entry) = dir_entries.next_entry().await? {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() && path != self.config.dir.join("cache.db") {
|
||||||
|
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
|
||||||
|
let pk = file_name.trim_end_matches(&format!(".{}", self.config.file_extension));
|
||||||
|
if self.db.get(pk).is_err() {
|
||||||
|
tokio::fs::remove_file(path).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le répertoire du cache
|
||||||
|
pub fn cache_dir(&self) -> String {
|
||||||
|
self.config.dir.to_string_lossy().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit le chemin complet d'un fichier dans le cache
|
||||||
|
fn file_path(&self, pk: &str) -> PathBuf {
|
||||||
|
self.config.dir.join(format!("{}.{}", pk, self.config.file_extension))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Génère une clé primaire à partir d'une URL
|
||||||
|
///
|
||||||
|
/// Utilise SHA1 pour hasher l'URL et retourne les 8 premiers octets en hexadécimal.
|
||||||
|
pub fn pk_from_url(url: &str) -> String {
|
||||||
|
let mut hasher = Sha1::new();
|
||||||
|
hasher.update(url.as_bytes());
|
||||||
|
let result = hasher.finalize();
|
||||||
|
hex::encode(&result[..8])
|
||||||
|
}
|
||||||
251
pmocache/src/db.rs
Normal file
251
pmocache/src/db.rs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
//! Module de gestion de la base de données SQLite pour le cache
|
||||||
|
//!
|
||||||
|
//! Ce module fournit une interface générique pour gérer les métadonnées
|
||||||
|
//! des éléments en cache, avec tracking des accès et des statistiques.
|
||||||
|
|
||||||
|
use rusqlite::{Connection, params};
|
||||||
|
use serde::Serialize;
|
||||||
|
use chrono::Utc;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[cfg(feature = "openapi")]
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
/// Entrée de cache représentant un élément dans la base de données
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
#[cfg_attr(feature = "openapi", derive(ToSchema))]
|
||||||
|
pub struct CacheEntry {
|
||||||
|
/// Clé primaire unique de l'élément (hash SHA1 de l'URL)
|
||||||
|
#[cfg_attr(feature = "openapi", schema(example = "1a2b3c4d5e6f7a8b"))]
|
||||||
|
pub pk: String,
|
||||||
|
/// URL source de l'élément
|
||||||
|
#[cfg_attr(feature = "openapi", schema(example = "https://example.com/resource"))]
|
||||||
|
pub source_url: String,
|
||||||
|
/// Collection à laquelle appartient l'élément (optionnel)
|
||||||
|
#[cfg_attr(feature = "openapi", schema(example = "album:123"))]
|
||||||
|
pub collection: Option<String>,
|
||||||
|
/// Nombre d'accès à l'élément
|
||||||
|
#[cfg_attr(feature = "openapi", schema(example = 42))]
|
||||||
|
pub hits: i32,
|
||||||
|
/// Date/heure du dernier accès (RFC3339)
|
||||||
|
#[cfg_attr(feature = "openapi", schema(example = "2025-01-15T10:30:00Z"))]
|
||||||
|
pub last_used: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base de données SQLite pour le cache
|
||||||
|
///
|
||||||
|
/// Gère les métadonnées des éléments en cache :
|
||||||
|
/// - Clés primaires (pk) et URLs sources
|
||||||
|
/// - Statistiques d'utilisation (hits, last_used)
|
||||||
|
/// - Opérations CRUD de base
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DB {
|
||||||
|
conn: Mutex<Connection>,
|
||||||
|
table_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DB {
|
||||||
|
/// Initialise une nouvelle base de données avec une table personnalisée
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `path` - Chemin vers le fichier de base de données SQLite
|
||||||
|
/// * `table_name` - Nom de la table à créer
|
||||||
|
///
|
||||||
|
/// # Exemple
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pmocache::db::DB;
|
||||||
|
/// use std::path::Path;
|
||||||
|
///
|
||||||
|
/// let db = DB::init(Path::new("cache.db"), "my_cache").unwrap();
|
||||||
|
/// ```
|
||||||
|
pub fn init(path: &Path, table_name: &str) -> Result<Self, rusqlite::Error> {
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
|
||||||
|
let create_table_sql = format!(
|
||||||
|
"CREATE TABLE IF NOT EXISTS {} (
|
||||||
|
pk TEXT PRIMARY KEY,
|
||||||
|
source_url TEXT,
|
||||||
|
collection TEXT,
|
||||||
|
hits INTEGER DEFAULT 0,
|
||||||
|
last_used TEXT
|
||||||
|
)",
|
||||||
|
table_name
|
||||||
|
);
|
||||||
|
|
||||||
|
conn.execute(&create_table_sql, [])?;
|
||||||
|
|
||||||
|
// Créer un index sur la collection pour les requêtes rapides
|
||||||
|
let create_index_sql = format!(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_{}_collection ON {} (collection)",
|
||||||
|
table_name, table_name
|
||||||
|
);
|
||||||
|
|
||||||
|
conn.execute(&create_index_sql, [])?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
table_name: table_name.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute ou met à jour une entrée dans la base de données
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `pk` - Clé primaire de l'élément
|
||||||
|
/// * `url` - URL source de l'élément
|
||||||
|
/// * `collection` - Collection optionnelle à laquelle appartient l'élément
|
||||||
|
pub fn add(&self, pk: &str, url: &str, collection: Option<&str>) -> rusqlite::Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let sql = format!(
|
||||||
|
"INSERT INTO {} (pk, source_url, collection, hits, last_used)
|
||||||
|
VALUES (?1, ?2, ?3, 0, ?4)
|
||||||
|
ON CONFLICT(pk) DO UPDATE SET
|
||||||
|
source_url = excluded.source_url,
|
||||||
|
collection = excluded.collection,
|
||||||
|
last_used = excluded.last_used",
|
||||||
|
self.table_name
|
||||||
|
);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
&sql,
|
||||||
|
params![pk, url, collection, Utc::now().to_rfc3339()],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère une entrée de la base de données par sa clé
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `pk` - Clé primaire de l'élément à récupérer
|
||||||
|
pub fn get(&self, pk: &str) -> rusqlite::Result<CacheEntry> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT pk, source_url, collection, hits, last_used FROM {} WHERE pk = ?1",
|
||||||
|
self.table_name
|
||||||
|
);
|
||||||
|
|
||||||
|
conn.query_row(
|
||||||
|
&sql,
|
||||||
|
[pk],
|
||||||
|
|row| {
|
||||||
|
Ok(CacheEntry {
|
||||||
|
pk: row.get(0)?,
|
||||||
|
source_url: row.get(1)?,
|
||||||
|
collection: row.get(2)?,
|
||||||
|
hits: row.get(3)?,
|
||||||
|
last_used: row.get(4)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour le compteur d'accès et la date du dernier accès
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `pk` - Clé primaire de l'élément
|
||||||
|
pub fn update_hit(&self, pk: &str) -> rusqlite::Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let sql = format!(
|
||||||
|
"UPDATE {} SET hits = hits + 1, last_used = ?1 WHERE pk = ?2",
|
||||||
|
self.table_name
|
||||||
|
);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
&sql,
|
||||||
|
params![Utc::now().to_rfc3339(), pk],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Purge toutes les entrées de la base de données
|
||||||
|
pub fn purge(&self) -> rusqlite::Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let sql = format!("DELETE FROM {}", self.table_name);
|
||||||
|
conn.execute(&sql, [])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère toutes les entrées, triées par nombre d'accès décroissant
|
||||||
|
pub fn get_all(&self) -> rusqlite::Result<Vec<CacheEntry>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT pk, source_url, collection, hits, last_used FROM {} ORDER BY hits DESC",
|
||||||
|
self.table_name
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
|
||||||
|
let entries = stmt.query_map([], |row| {
|
||||||
|
Ok(CacheEntry {
|
||||||
|
pk: row.get(0)?,
|
||||||
|
source_url: row.get(1)?,
|
||||||
|
collection: row.get(2)?,
|
||||||
|
hits: row.get(3)?,
|
||||||
|
last_used: row.get(4)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère toutes les entrées d'une collection spécifique
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `collection` - Identifiant de la collection
|
||||||
|
pub fn get_by_collection(&self, collection: &str) -> rusqlite::Result<Vec<CacheEntry>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT pk, source_url, collection, hits, last_used FROM {} WHERE collection = ?1 ORDER BY hits DESC",
|
||||||
|
self.table_name
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
|
||||||
|
let entries = stmt.query_map([collection], |row| {
|
||||||
|
Ok(CacheEntry {
|
||||||
|
pk: row.get(0)?,
|
||||||
|
source_url: row.get(1)?,
|
||||||
|
collection: row.get(2)?,
|
||||||
|
hits: row.get(3)?,
|
||||||
|
last_used: row.get(4)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime toutes les entrées d'une collection
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `collection` - Identifiant de la collection à supprimer
|
||||||
|
pub fn delete_collection(&self, collection: &str) -> rusqlite::Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let sql = format!("DELETE FROM {} WHERE collection = ?1", self.table_name);
|
||||||
|
conn.execute(&sql, [collection])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une entrée de la base de données
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `pk` - Clé primaire de l'élément à supprimer
|
||||||
|
pub fn delete(&self, pk: &str) -> rusqlite::Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let sql = format!("DELETE FROM {} WHERE pk = ?1", self.table_name);
|
||||||
|
conn.execute(&sql, [pk])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
116
pmocache/src/lib.rs
Normal file
116
pmocache/src/lib.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
//! # pmocache - Système de cache générique pour PMOMusic
|
||||||
|
//!
|
||||||
|
//! Cette crate fournit un système de cache générique avec support de base de données SQLite
|
||||||
|
//! et stockage sur disque. Elle est utilisée comme base pour des caches spécialisés comme
|
||||||
|
//! `pmocovers` (cache d'images) et `pmoaudiocache` (cache de pistes audio).
|
||||||
|
//!
|
||||||
|
//! ## Vue d'ensemble
|
||||||
|
//!
|
||||||
|
//! `pmocache` fournit les composants de base pour :
|
||||||
|
//! - Stocker des fichiers sur disque avec une base de données SQLite pour les métadonnées
|
||||||
|
//! - Gérer des collections d'éléments (albums, playlists, etc.)
|
||||||
|
//! - Suivre les statistiques d'utilisation (hits, dernière utilisation)
|
||||||
|
//! - Télécharger automatiquement depuis des URLs
|
||||||
|
//! - Consolider et purger le cache
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! `pmocache` est conçu comme une base générique :
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! pmocache (générique)
|
||||||
|
//! ├── db.rs - Base de données SQLite générique
|
||||||
|
//! └── cache.rs - Système de cache générique
|
||||||
|
//!
|
||||||
|
//! pmocovers (spécialisé pour les images)
|
||||||
|
//! └── Utilise pmocache + conversion WebP
|
||||||
|
//!
|
||||||
|
//! pmoaudiocache (spécialisé pour l'audio)
|
||||||
|
//! └── Utilise pmocache + métadonnées audio
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Utilisation
|
||||||
|
//!
|
||||||
|
//! ### Exemple basique
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pmocache::cache::{Cache, CacheConfig};
|
||||||
|
//!
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() -> anyhow::Result<()> {
|
||||||
|
//! let config = CacheConfig::new("./cache", 1000, "my_cache", "dat");
|
||||||
|
//! let cache = Cache::new(config)?;
|
||||||
|
//!
|
||||||
|
//! // Ajouter un fichier depuis une URL
|
||||||
|
//! let pk = cache.add_from_url("http://example.com/file.dat", None).await?;
|
||||||
|
//! println!("Fichier ajouté avec clé: {}", pk);
|
||||||
|
//!
|
||||||
|
//! // Récupérer le fichier
|
||||||
|
//! let path = cache.get(&pk).await?;
|
||||||
|
//! println!("Fichier stocké à: {:?}", path);
|
||||||
|
//!
|
||||||
|
//! Ok(())
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Utilisation avec des collections
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pmocache::cache::{Cache, CacheConfig};
|
||||||
|
//!
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() -> anyhow::Result<()> {
|
||||||
|
//! let config = CacheConfig::new("./cache", 1000, "audio", "flac");
|
||||||
|
//! let cache = Cache::new(config)?;
|
||||||
|
//!
|
||||||
|
//! // Ajouter des pistes d'un album
|
||||||
|
//! let album_id = "album:the_wall";
|
||||||
|
//! cache.add_from_url("http://example.com/track1.flac", Some(album_id)).await?;
|
||||||
|
//! cache.add_from_url("http://example.com/track2.flac", Some(album_id)).await?;
|
||||||
|
//!
|
||||||
|
//! // Récupérer toutes les pistes de l'album
|
||||||
|
//! let tracks = cache.get_collection(album_id).await?;
|
||||||
|
//! println!("Album contient {} pistes", tracks.len());
|
||||||
|
//!
|
||||||
|
//! Ok(())
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Structure des fichiers
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cache/
|
||||||
|
//! ├── cache.db # Base de données SQLite
|
||||||
|
//! ├── 1a2b3c4d.webp # Fichier 1
|
||||||
|
//! └── 5e6f7a8b.flac # Fichier 2
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Schéma de base de données
|
||||||
|
//!
|
||||||
|
//! ```sql
|
||||||
|
//! CREATE TABLE {table_name} (
|
||||||
|
//! pk TEXT PRIMARY KEY, -- Clé unique (hash SHA1 de l'URL)
|
||||||
|
//! source_url TEXT, -- URL source
|
||||||
|
//! collection TEXT, -- Collection (album, playlist, etc.)
|
||||||
|
//! hits INTEGER DEFAULT 0, -- Nombre d'accès
|
||||||
|
//! last_used TEXT -- Dernière utilisation (RFC3339)
|
||||||
|
//! );
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Dépendances principales
|
||||||
|
//!
|
||||||
|
//! - `rusqlite` : Base de données SQLite
|
||||||
|
//! - `reqwest` : Téléchargement HTTP
|
||||||
|
//! - `sha1` : Génération de clés
|
||||||
|
//! - `tokio` : Runtime asynchrone
|
||||||
|
//!
|
||||||
|
//! ## Voir aussi
|
||||||
|
//!
|
||||||
|
//! - [`pmocovers`] : Cache d'images avec conversion WebP
|
||||||
|
//! - [`pmoaudiocache`] : Cache de pistes audio
|
||||||
|
|
||||||
|
pub mod db;
|
||||||
|
pub mod cache;
|
||||||
|
|
||||||
|
pub use db::{DB, CacheEntry};
|
||||||
|
pub use cache::{Cache, CacheConfig, pk_from_url};
|
||||||
@@ -4,23 +4,18 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# Cache générique
|
||||||
|
pmocache = { path = "../pmocache" }
|
||||||
|
|
||||||
# Gestion d'images
|
# Gestion d'images
|
||||||
image = "0.25"
|
image = "0.25"
|
||||||
webp = "0.3"
|
webp = "0.3"
|
||||||
|
|
||||||
# Base de données
|
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
|
||||||
|
|
||||||
# HTTP client
|
# HTTP client
|
||||||
reqwest = { version = "0.12", features = ["blocking"] }
|
reqwest = { version = "0.12", features = ["blocking"] }
|
||||||
|
|
||||||
# Cryptographie
|
|
||||||
sha1 = "0.10"
|
|
||||||
hex = "0.4"
|
|
||||||
|
|
||||||
# Utilitaires
|
# Utilitaires
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
chrono = "0.4"
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
|
||||||
# Async
|
# Async
|
||||||
@@ -36,4 +31,4 @@ tracing = "0.1.41"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["pmoserver"]
|
default = ["pmoserver"]
|
||||||
pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa"]
|
pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa", "pmocache/openapi"]
|
||||||
|
|||||||
@@ -1,32 +1,63 @@
|
|||||||
|
//! Module de gestion du cache d'images avec conversion WebP
|
||||||
|
//!
|
||||||
|
//! Ce module étend le cache générique de `pmocache` avec des fonctionnalités
|
||||||
|
//! spécifiques aux images : conversion WebP et génération de variantes.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use sha1::{Sha1, Digest};
|
use pmocache::{Cache as GenericCache, CacheConfig};
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use crate::db::DB;
|
|
||||||
use crate::webp;
|
use crate::webp;
|
||||||
|
use crate::db::DB;
|
||||||
|
|
||||||
|
/// Cache d'images avec conversion WebP et génération de variantes
|
||||||
|
///
|
||||||
|
/// Gère le téléchargement, la conversion en WebP, le stockage et la génération
|
||||||
|
/// de variantes de tailles pour les images de couvertures.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Cache {
|
pub struct Cache {
|
||||||
|
cache: GenericCache,
|
||||||
pub(crate) dir: PathBuf,
|
pub(crate) dir: PathBuf,
|
||||||
pub(crate) limit: usize,
|
pub(crate) limit: usize,
|
||||||
pub db: DB,
|
pub db: Arc<DB>,
|
||||||
mu: Arc<Mutex<()>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cache {
|
impl Cache {
|
||||||
|
/// Crée un nouveau cache d'images
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `dir` - Répertoire de stockage du cache
|
||||||
|
/// * `limit` - Limite de taille du cache (nombre d'images)
|
||||||
|
///
|
||||||
|
/// # Exemple
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pmocovers::Cache;
|
||||||
|
///
|
||||||
|
/// let cache = Cache::new("./cache", 1000).unwrap();
|
||||||
|
/// ```
|
||||||
pub fn new(dir: &str, limit: usize) -> Result<Self> {
|
pub fn new(dir: &str, limit: usize) -> Result<Self> {
|
||||||
std::fs::create_dir_all(dir)?;
|
let config = CacheConfig::new(dir, limit, "covers", "orig.webp");
|
||||||
let db = DB::init(&PathBuf::from(dir).join("cache.db"))?;
|
let cache = GenericCache::new(config)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
dir: PathBuf::from(dir),
|
dir: PathBuf::from(dir),
|
||||||
limit,
|
limit,
|
||||||
db,
|
db: Arc::clone(&cache.db),
|
||||||
mu: Arc::new(Mutex::new(())),
|
cache,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Télécharge une image depuis une URL et l'ajoute au cache
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `url` - URL de l'image à télécharger
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// La clé primaire (pk) de l'image dans le cache
|
||||||
pub async fn add_from_url(&self, url: &str) -> Result<String> {
|
pub async fn add_from_url(&self, url: &str) -> Result<String> {
|
||||||
let response = reqwest::get(url).await?;
|
let response = reqwest::get(url).await?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
@@ -37,67 +68,60 @@ impl Cache {
|
|||||||
self.add(url, &data).await
|
self.add(url, &data).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// S'assure qu'une image est présente dans le cache
|
||||||
|
///
|
||||||
|
/// Si l'image existe déjà, retourne sa clé. Sinon, la télécharge.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `url` - URL de l'image
|
||||||
pub async fn ensure_from_url(&self, url: &str) -> Result<String> {
|
pub async fn ensure_from_url(&self, url: &str) -> Result<String> {
|
||||||
let pk = pk_from_url(url);
|
self.cache.ensure_from_url(url, None).await
|
||||||
|
|
||||||
if self.db.get(&pk).is_ok() {
|
|
||||||
let orig_path = self.dir.join(format!("{}.orig.webp", pk));
|
|
||||||
if orig_path.exists() {
|
|
||||||
return Ok(pk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.add_from_url(url).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ajoute une image au cache avec conversion en WebP
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `url` - URL source de l'image
|
||||||
|
/// * `data` - Données brutes de l'image
|
||||||
pub async fn add(&self, url: &str, data: &[u8]) -> Result<String> {
|
pub async fn add(&self, url: &str, data: &[u8]) -> Result<String> {
|
||||||
let pk = pk_from_url(url);
|
let pk = pmocache::pk_from_url(url);
|
||||||
let orig_path = self.dir.join(format!("{}.orig.webp", pk));
|
let orig_path = self.dir.join(format!("{}.orig.webp", pk));
|
||||||
|
|
||||||
let _lock = self.mu.lock().await;
|
// Vérifier si le fichier existe déjà
|
||||||
|
|
||||||
if !orig_path.exists() {
|
if !orig_path.exists() {
|
||||||
|
// Convertir l'image en WebP
|
||||||
let img = image::load_from_memory(data)?;
|
let img = image::load_from_memory(data)?;
|
||||||
let webp_data = webp::encode_webp(&img)?;
|
let webp_data = webp::encode_webp(&img)?;
|
||||||
tokio::fs::write(&orig_path, webp_data).await?;
|
tokio::fs::write(&orig_path, webp_data).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.db.add(&pk, url)?;
|
// Ajouter à la DB (sans collection pour les covers)
|
||||||
|
self.db.add(&pk, url, None)?;
|
||||||
Ok(pk)
|
Ok(pk)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Récupère le chemin d'une image dans le cache
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `pk` - Clé primaire de l'image
|
||||||
pub async fn get(&self, pk: &str) -> Result<PathBuf> {
|
pub async fn get(&self, pk: &str) -> Result<PathBuf> {
|
||||||
let _lock = self.mu.lock().await;
|
self.cache.get(pk).await
|
||||||
|
|
||||||
self.db.get(pk)?;
|
|
||||||
self.db.update_hit(pk)?;
|
|
||||||
|
|
||||||
let orig_path = self.dir.join(format!("{}.orig.webp", pk));
|
|
||||||
if orig_path.exists() {
|
|
||||||
Ok(orig_path)
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("File not found"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Supprime tous les fichiers et entrées du cache
|
||||||
pub async fn purge(&self) -> Result<()> {
|
pub async fn purge(&self) -> Result<()> {
|
||||||
let _lock = self.mu.lock().await;
|
self.cache.purge().await
|
||||||
|
|
||||||
let mut entries = tokio::fs::read_dir(&self.dir).await?;
|
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
|
||||||
if entry.path().is_file() {
|
|
||||||
tokio::fs::remove_file(entry.path()).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.db.purge().map_err(|e| anyhow!("Database error: {}", e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Consolide le cache en supprimant les orphelins et en re-téléchargeant les images manquantes
|
||||||
pub async fn consolidate(&self) -> Result<()> {
|
pub async fn consolidate(&self) -> Result<()> {
|
||||||
let _lock = self.mu.lock().await;
|
// Récupérer la liste des entrées à traiter
|
||||||
|
|
||||||
let entries = self.db.get_all()?;
|
let entries = self.db.get_all()?;
|
||||||
|
|
||||||
|
// Supprimer les entrées sans fichiers correspondants
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let orig_path = self.dir.join(format!("{}.orig.webp", entry.pk));
|
let orig_path = self.dir.join(format!("{}.orig.webp", entry.pk));
|
||||||
if !orig_path.exists() {
|
if !orig_path.exists() {
|
||||||
@@ -113,10 +137,11 @@ impl Cache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Supprimer les fichiers sans entrées DB correspondantes
|
||||||
let mut dir_entries = tokio::fs::read_dir(&self.dir).await?;
|
let mut dir_entries = tokio::fs::read_dir(&self.dir).await?;
|
||||||
while let Some(entry) = dir_entries.next_entry().await? {
|
while let Some(entry) = dir_entries.next_entry().await? {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_file() {
|
if path.is_file() && path != self.dir.join("cache.db") {
|
||||||
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
|
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
|
||||||
if file_name.ends_with(".orig.webp") {
|
if file_name.ends_with(".orig.webp") {
|
||||||
let pk = file_name.trim_end_matches(".orig.webp");
|
let pk = file_name.trim_end_matches(".orig.webp");
|
||||||
@@ -131,15 +156,8 @@ impl Cache {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retourne le répertoire du cache
|
||||||
pub fn cache_dir(&self) -> String {
|
pub fn cache_dir(&self) -> String {
|
||||||
self.dir.to_string_lossy().to_string()
|
self.dir.to_string_lossy().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pk_from_url(url: &str) -> String {
|
|
||||||
let mut hasher = Sha1::new();
|
|
||||||
hasher.update(url.as_bytes());
|
|
||||||
let result = hasher.finalize();
|
|
||||||
hex::encode(&result[..8])
|
|
||||||
}
|
}
|
||||||
@@ -1,118 +1,7 @@
|
|||||||
use rusqlite::{Connection, params};
|
//! Module de compatibilité pour l'ancien module db
|
||||||
use serde::Serialize;
|
//!
|
||||||
use chrono::Utc;
|
//! Ce module réexporte les types de `pmocache::db` pour maintenir
|
||||||
use std::path::Path;
|
//! la compatibilité avec l'API existante.
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
// Réexporter les types de pmocache
|
||||||
use utoipa::ToSchema;
|
pub use pmocache::db::{CacheEntry, DB};
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
#[cfg_attr(feature = "pmoserver", derive(ToSchema))]
|
|
||||||
pub struct CacheEntry {
|
|
||||||
/// Clé primaire unique de l'image (hash SHA1 de l'URL)
|
|
||||||
#[cfg_attr(feature = "pmoserver", schema(example = "1a2b3c4d5e6f7a8b"))]
|
|
||||||
pub pk: String,
|
|
||||||
/// URL source de l'image
|
|
||||||
#[cfg_attr(feature = "pmoserver", schema(example = "https://example.com/cover.jpg"))]
|
|
||||||
pub source_url: String,
|
|
||||||
/// Nombre d'accès à l'image
|
|
||||||
#[cfg_attr(feature = "pmoserver", schema(example = 42))]
|
|
||||||
pub hits: i32,
|
|
||||||
/// Date/heure du dernier accès (RFC3339)
|
|
||||||
#[cfg_attr(feature = "pmoserver", schema(example = "2025-01-15T10:30:00Z"))]
|
|
||||||
pub last_used: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct DB {
|
|
||||||
conn: Mutex<Connection>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DB {
|
|
||||||
pub fn init(path: &Path) -> Result<Self, rusqlite::Error> {
|
|
||||||
let conn = Connection::open(path)?;
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS covers (
|
|
||||||
pk TEXT PRIMARY KEY,
|
|
||||||
source_url TEXT,
|
|
||||||
hits INTEGER DEFAULT 0,
|
|
||||||
last_used TEXT
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(Self { conn: Mutex::new(conn) })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add(&self, pk: &str, url: &str) -> rusqlite::Result<()> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO covers (pk, source_url, hits, last_used)
|
|
||||||
VALUES (?1, ?2, 0, ?3)
|
|
||||||
ON CONFLICT(pk) DO UPDATE SET
|
|
||||||
source_url = excluded.source_url,
|
|
||||||
last_used = excluded.last_used",
|
|
||||||
params![pk, url, Utc::now().to_rfc3339()],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, pk: &str) -> rusqlite::Result<CacheEntry> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
conn.query_row(
|
|
||||||
"SELECT pk, source_url, hits, last_used FROM covers WHERE pk = ?1",
|
|
||||||
[pk],
|
|
||||||
|row| {
|
|
||||||
Ok(CacheEntry {
|
|
||||||
pk: row.get(0)?,
|
|
||||||
source_url: row.get(1)?,
|
|
||||||
hits: row.get(2)?,
|
|
||||||
last_used: row.get(3)?,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_hit(&self, pk: &str) -> rusqlite::Result<()> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE covers SET hits = hits + 1, last_used = ?1 WHERE pk = ?2",
|
|
||||||
params![Utc::now().to_rfc3339(), pk],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn purge(&self) -> rusqlite::Result<()> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
conn.execute("DELETE FROM covers", [])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_all(&self) -> rusqlite::Result<Vec<CacheEntry>> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
let mut stmt = conn.prepare(
|
|
||||||
"SELECT pk, source_url, hits, last_used FROM covers ORDER BY hits DESC",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let entries = stmt.query_map([], |row| {
|
|
||||||
Ok(CacheEntry {
|
|
||||||
pk: row.get(0)?,
|
|
||||||
source_url: row.get(1)?,
|
|
||||||
hits: row.get(2)?,
|
|
||||||
last_used: row.get(3)?,
|
|
||||||
})
|
|
||||||
})?
|
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
||||||
|
|
||||||
Ok(entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete(&self, pk: &str) -> rusqlite::Result<()> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
conn.execute("DELETE FROM covers WHERE pk = ?1", [pk])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user