A tiny Subsonic/Jellyfin/S3 server in Rust
navidrome subsonic s3 emby jellyfin
0

Configure Feed

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

Merge pull request #1 from tsirysndr/feat/internet-radio

Add Subsonic internet radio API with station management

+425 -19
+1 -1
Cargo.lock
··· 2472 2472 2473 2473 [[package]] 2474 2474 name = "smolsonic" 2475 - version = "0.9.0" 2475 + version = "0.10.0" 2476 2476 dependencies = [ 2477 2477 "actix-cors", 2478 2478 "actix-rt",
+1 -1
Cargo.toml
··· 1 1 [package] 2 2 name = "smolsonic" 3 - version = "0.9.0" 3 + version = "0.10.0" 4 4 edition = "2021" 5 5 description = "A tiny Subsonic-compatible music server" 6 6 license = "MIT"
+9 -6
README.md
··· 93 93 94 94 ```sh 95 95 curl -fsSL https://raw.githubusercontent.com/tsirysndr/smolsonic/main/install.sh \ 96 - | SMOLSONIC_VERSION=v0.9.0 SMOLSONIC_INSTALL=$HOME/.local/bin sh 96 + | SMOLSONIC_VERSION=v0.10.0 SMOLSONIC_INSTALL=$HOME/.local/bin sh 97 97 ``` 98 98 99 99 Homebrew (macOS / Linux): ··· 112 112 sudo apt-get install smolsonic 113 113 114 114 # Or download a .deb directly from the release page 115 - curl -fsSLO https://github.com/tsirysndr/smolsonic/releases/latest/download/smolsonic_0.9.0_amd64.deb 116 - sudo dpkg -i smolsonic_0.9.0_amd64.deb 115 + curl -fsSLO https://github.com/tsirysndr/smolsonic/releases/latest/download/smolsonic_0.10.0_amd64.deb 116 + sudo dpkg -i smolsonic_0.10.0_amd64.deb 117 117 ``` 118 118 119 119 Fedora / RHEL (`.rpm` for `x86_64`): ··· 130 130 sudo dnf install smolsonic 131 131 132 132 # Or download an .rpm directly from the release page 133 - curl -fsSLO https://github.com/tsirysndr/smolsonic/releases/latest/download/smolsonic-0.9.0-1.x86_64.rpm 134 - sudo rpm -i smolsonic-0.9.0-1.x86_64.rpm 133 + curl -fsSLO https://github.com/tsirysndr/smolsonic/releases/latest/download/smolsonic-0.10.0-1.x86_64.rpm 134 + sudo rpm -i smolsonic-0.10.0-1.x86_64.rpm 135 135 ``` 136 136 137 137 The `.deb` / `.rpm` packages drop the binary at `/usr/local/bin/smolsonic`, ··· 573 573 # from /ws/2/artist?inc=artist-rels, plus cover art from the Cover Art 574 574 # Archive. MusicBrainz requires a descriptive user agent per their TOS. 575 575 [musicbrainz] 576 - user_agent = "smolsonic/0.9.0 ( you@example.com )" 576 + user_agent = "smolsonic/0.10.0 ( you@example.com )" 577 577 ``` 578 578 579 579 What each enables: ··· 635 635 `updatePlaylist`, `deletePlaylist` 636 636 637 637 **Starring** — `star`, `unstar` 638 + 639 + **Internet radio** — `getInternetRadioStations`, `createInternetRadioStation`, 640 + `updateInternetRadioStation`, `deleteInternetRadioStation` 638 641 639 642 **Artist / album info** — `getArtistInfo`, `getArtistInfo2`, `getAlbumInfo`, 640 643 `getAlbumInfo2`, `getSimilarSongs`, `getSimilarSongs2`, `getTopSongs`,
+1 -1
dist/debian/amd64/DEBIAN/control
··· 1 1 Package: smolsonic 2 - Version: 0.9.0 2 + Version: 0.10.0 3 3 Section: sound 4 4 Priority: optional 5 5 Architecture: amd64
+1 -1
dist/debian/arm64/DEBIAN/control
··· 1 1 Package: smolsonic 2 - Version: 0.9.0 2 + Version: 0.10.0 3 3 Section: sound 4 4 Priority: optional 5 5 Architecture: arm64
+1 -1
dist/debian/armhf/DEBIAN/control
··· 1 1 Package: smolsonic 2 - Version: 0.9.0 2 + Version: 0.10.0 3 3 Section: sound 4 4 Priority: optional 5 5 Architecture: armhf
+1 -1
dist/rpm/amd64/smolsonic.spec
··· 1 1 Name: smolsonic 2 - Version: 0.9.0 2 + Version: 0.10.0 3 3 Release: 1%{?dist} 4 4 Summary: A tiny Subsonic-compatible music server written in Rust 5 5
+1 -1
dist/rpm/arm64/smolsonic.spec
··· 1 1 Name: smolsonic 2 - Version: 0.9.0 2 + Version: 0.10.0 3 3 Release: 1%{?dist} 4 4 Summary: A tiny Subsonic-compatible music server written in Rust 5 5
+3 -3
flake.nix
··· 48 48 # reports on mismatch back in. Repeat per platform. 49 49 s3webuiNodeModules = pkgs.stdenv.mkDerivation { 50 50 pname = "smolsonic-s3webui-node-modules"; 51 - version = "0.9.0"; 51 + version = "0.10.0"; 52 52 53 53 src = lib.fileset.toSource { 54 54 root = ./s3webui; ··· 91 91 # smolsonic binary at compile time via rust-embed. 92 92 s3webui = pkgs.stdenv.mkDerivation { 93 93 pname = "smolsonic-s3webui"; 94 - version = "0.9.0"; 94 + version = "0.10.0"; 95 95 96 96 src = ./s3webui; 97 97 ··· 124 124 inherit src; 125 125 126 126 pname = "smolsonic"; 127 - version = "0.9.0"; 127 + version = "0.10.0"; 128 128 129 129 nativeBuildInputs = [ 130 130 pkgs.pkg-config
+11
src/db.rs
··· 152 152 153 153 CREATE INDEX IF NOT EXISTS idx_playlist_songs_pl ON playlist_songs(playlist_id); 154 154 155 + -- ── Internet radio ────────────────────────────────────────────────── 156 + -- User-managed list of external stream URLs surfaced through the 157 + -- Subsonic getInternetRadioStations family. `homepage_url` is optional. 158 + CREATE TABLE IF NOT EXISTS internet_radio_stations ( 159 + id TEXT PRIMARY KEY, 160 + name TEXT NOT NULL, 161 + stream_url TEXT NOT NULL, 162 + homepage_url TEXT, 163 + created_at TEXT NOT NULL 164 + ); 165 + 155 166 -- ── Videos ────────────────────────────────────────────────────────── 156 167 CREATE TABLE IF NOT EXISTS videos ( 157 168 id TEXT PRIMARY KEY,
+8
src/models.rs
··· 28 28 } 29 29 30 30 #[derive(Debug, Clone, FromRow, Serialize)] 31 + pub struct InternetRadioStation { 32 + pub id: String, 33 + pub name: String, 34 + pub stream_url: String, 35 + pub homepage_url: Option<String>, 36 + } 37 + 38 + #[derive(Debug, Clone, FromRow, Serialize)] 31 39 pub struct Video { 32 40 pub id: String, 33 41 pub path: String,
+150
src/server/handlers.rs
··· 70 70 GET /rest/star ?id=so-…|albumId=al-…|artistId=ar-… 71 71 GET /rest/unstar ?id=so-…|albumId=al-…|artistId=ar-… 72 72 73 + Internet radio 74 + GET /rest/getInternetRadioStations 75 + GET /rest/createInternetRadioStation ?streamUrl=…&name=…&homepageUrl=… 76 + GET /rest/updateInternetRadioStation ?id=ir-…&streamUrl=…&name=…&homepageUrl=… 77 + GET /rest/deleteInternetRadioStation ?id=ir-… 78 + 73 79 Artist / album info (minimal stubs — no Last.fm lookup) 74 80 GET /rest/getArtistInfo ?id=ar-… 75 81 GET /rest/getArtistInfo2 ?id=ar-… ··· 1547 1553 }; 1548 1554 if let Err(e) = repo::delete_playlist(&state.pool, id).await { 1549 1555 tracing::error!("deletePlaylist: {e}"); 1556 + return response::error_json(0, "database error"); 1557 + } 1558 + response::ok_json(json!({})) 1559 + } 1560 + 1561 + // ── Internet radio ──────────────────────────────────────────────────────────── 1562 + 1563 + fn radio_station_json(s: &crate::models::InternetRadioStation) -> Value { 1564 + let mut m = serde_json::Map::new(); 1565 + m.insert("id".into(), json!(s.id)); 1566 + m.insert("name".into(), json!(s.name)); 1567 + m.insert("streamUrl".into(), json!(s.stream_url)); 1568 + if let Some(h) = &s.homepage_url { 1569 + m.insert("homepageUrl".into(), json!(h)); 1570 + } 1571 + Value::Object(m) 1572 + } 1573 + 1574 + fn new_radio_id() -> String { 1575 + use std::collections::hash_map::DefaultHasher; 1576 + use std::hash::{Hash, Hasher}; 1577 + use std::time::{SystemTime, UNIX_EPOCH}; 1578 + let mut h = DefaultHasher::new(); 1579 + SystemTime::now() 1580 + .duration_since(UNIX_EPOCH) 1581 + .unwrap_or_default() 1582 + .as_nanos() 1583 + .hash(&mut h); 1584 + std::thread::current().id().hash(&mut h); 1585 + format!("ir-{:016x}", h.finish()) 1586 + } 1587 + 1588 + pub async fn get_internet_radio_stations( 1589 + state: web::Data<SubsonicState>, 1590 + query: web::Query<CommonParams>, 1591 + ) -> HttpResponse { 1592 + let q = query.into_inner(); 1593 + if let Some(r) = require_auth(&state, &CommonLike::from_common(&q)) { 1594 + return r; 1595 + } 1596 + let stations = repo::all_internet_radio_stations(&state.pool) 1597 + .await 1598 + .unwrap_or_default(); 1599 + let out: Vec<Value> = stations.iter().map(radio_station_json).collect(); 1600 + response::ok_json(json!({ 1601 + "internetRadioStations": { "internetRadioStation": out } 1602 + })) 1603 + } 1604 + 1605 + pub async fn create_internet_radio_station( 1606 + state: web::Data<SubsonicState>, 1607 + req: HttpRequest, 1608 + ) -> HttpResponse { 1609 + let qs = req.query_string(); 1610 + if let Some(r) = auth_from_qs(&state, qs) { 1611 + return r; 1612 + } 1613 + let Some(stream_url) = query_value(qs, "streamUrl").filter(|s| !s.is_empty()) else { 1614 + return response::error_json(10, "Required parameter is missing: streamUrl"); 1615 + }; 1616 + let Some(name) = query_value(qs, "name").filter(|s| !s.is_empty()) else { 1617 + return response::error_json(10, "Required parameter is missing: name"); 1618 + }; 1619 + let homepage_url = query_value(qs, "homepageUrl").filter(|s| !s.is_empty()); 1620 + let id = new_radio_id(); 1621 + let now = now_iso8601(); 1622 + if let Err(e) = repo::create_internet_radio_station( 1623 + &state.pool, 1624 + &id, 1625 + &name, 1626 + &stream_url, 1627 + homepage_url.as_deref(), 1628 + &now, 1629 + ) 1630 + .await 1631 + { 1632 + tracing::error!("createInternetRadioStation: {e}"); 1633 + return response::error_json(0, "database error"); 1634 + } 1635 + response::ok_json(json!({})) 1636 + } 1637 + 1638 + pub async fn update_internet_radio_station( 1639 + state: web::Data<SubsonicState>, 1640 + req: HttpRequest, 1641 + ) -> HttpResponse { 1642 + let qs = req.query_string(); 1643 + if let Some(r) = auth_from_qs(&state, qs) { 1644 + return r; 1645 + } 1646 + let Some(id) = query_value(qs, "id").filter(|s| !s.is_empty()) else { 1647 + return response::error_json(10, "Required parameter is missing: id"); 1648 + }; 1649 + let Some(stream_url) = query_value(qs, "streamUrl").filter(|s| !s.is_empty()) else { 1650 + return response::error_json(10, "Required parameter is missing: streamUrl"); 1651 + }; 1652 + let Some(name) = query_value(qs, "name").filter(|s| !s.is_empty()) else { 1653 + return response::error_json(10, "Required parameter is missing: name"); 1654 + }; 1655 + if repo::find_internet_radio_station(&state.pool, &id) 1656 + .await 1657 + .ok() 1658 + .flatten() 1659 + .is_none() 1660 + { 1661 + return response::error_json(70, "Internet radio station not found"); 1662 + } 1663 + let homepage_url = query_value(qs, "homepageUrl").filter(|s| !s.is_empty()); 1664 + if let Err(e) = repo::update_internet_radio_station( 1665 + &state.pool, 1666 + &id, 1667 + &name, 1668 + &stream_url, 1669 + homepage_url.as_deref(), 1670 + ) 1671 + .await 1672 + { 1673 + tracing::error!("updateInternetRadioStation: {e}"); 1674 + return response::error_json(0, "database error"); 1675 + } 1676 + response::ok_json(json!({})) 1677 + } 1678 + 1679 + pub async fn delete_internet_radio_station( 1680 + state: web::Data<SubsonicState>, 1681 + query: web::Query<IdParam>, 1682 + ) -> HttpResponse { 1683 + let q = query.into_inner(); 1684 + if let Some(r) = require_auth(&state, &CommonLike::from_id(&q)) { 1685 + return r; 1686 + } 1687 + let Some(id) = q.id.as_deref().filter(|s| !s.is_empty()) else { 1688 + return response::error_json(10, "Required parameter is missing: id"); 1689 + }; 1690 + if repo::find_internet_radio_station(&state.pool, id) 1691 + .await 1692 + .ok() 1693 + .flatten() 1694 + .is_none() 1695 + { 1696 + return response::error_json(70, "Internet radio station not found"); 1697 + } 1698 + if let Err(e) = repo::delete_internet_radio_station(&state.pool, id).await { 1699 + tracing::error!("deleteInternetRadioStation: {e}"); 1550 1700 return response::error_json(0, "database error"); 1551 1701 } 1552 1702 response::ok_json(json!({}))
+155
src/server/mod.rs
··· 305 305 "/rest/deletePlaylist{_:(\\.view)?}", 306 306 web::post().to(handlers::delete_playlist), 307 307 ) 308 + // Internet radio 309 + .route( 310 + "/rest/getInternetRadioStations{_:(\\.view)?}", 311 + web::get().to(handlers::get_internet_radio_stations), 312 + ) 313 + .route( 314 + "/rest/getInternetRadioStations{_:(\\.view)?}", 315 + web::post().to(handlers::get_internet_radio_stations), 316 + ) 317 + .route( 318 + "/rest/createInternetRadioStation{_:(\\.view)?}", 319 + web::get().to(handlers::create_internet_radio_station), 320 + ) 321 + .route( 322 + "/rest/createInternetRadioStation{_:(\\.view)?}", 323 + web::post().to(handlers::create_internet_radio_station), 324 + ) 325 + .route( 326 + "/rest/updateInternetRadioStation{_:(\\.view)?}", 327 + web::get().to(handlers::update_internet_radio_station), 328 + ) 329 + .route( 330 + "/rest/updateInternetRadioStation{_:(\\.view)?}", 331 + web::post().to(handlers::update_internet_radio_station), 332 + ) 333 + .route( 334 + "/rest/deleteInternetRadioStation{_:(\\.view)?}", 335 + web::get().to(handlers::delete_internet_radio_station), 336 + ) 337 + .route( 338 + "/rest/deleteInternetRadioStation{_:(\\.view)?}", 339 + web::post().to(handlers::delete_internet_radio_station), 340 + ) 308 341 // Artist / album info / similar / top / lyrics 309 342 .route( 310 343 "/rest/getArtistInfo{_:(\\.view)?}", ··· 772 805 .to_request(); 773 806 let resp = test::call_service(&app, req).await; 774 807 assert_eq!(resp.status(), StatusCode::NOT_FOUND); 808 + } 809 + 810 + #[actix_web::test] 811 + async fn internet_radio_station_crud_roundtrip() { 812 + let dir = tempdir("radio"); 813 + let state = fixture_state(&dir, &dir).await; 814 + let app = test::init_service( 815 + App::new() 816 + .app_data(build_app_state(state)) 817 + .configure(configure_routes), 818 + ) 819 + .await; 820 + 821 + // Empty to start. 822 + let req = test::TestRequest::get() 823 + .uri(&format!("/rest/getInternetRadioStations?{}", token_qs("s"))) 824 + .to_request(); 825 + let body: Value = test::call_and_read_body_json(&app, req).await; 826 + assert_eq!( 827 + body["subsonic-response"]["internetRadioStations"]["internetRadioStation"] 828 + .as_array() 829 + .map(|a| a.len()), 830 + Some(0) 831 + ); 832 + 833 + // Create one. 834 + let req = test::TestRequest::get() 835 + .uri(&format!( 836 + "/rest/createInternetRadioStation?streamUrl=http://example.com/stream&name=Tronic&homepageUrl=http://example.com&{}", 837 + token_qs("s") 838 + )) 839 + .to_request(); 840 + let body: Value = test::call_and_read_body_json(&app, req).await; 841 + assert_eq!(body["subsonic-response"]["status"], "ok"); 842 + 843 + // It shows up with all fields. 844 + let req = test::TestRequest::get() 845 + .uri(&format!("/rest/getInternetRadioStations?{}", token_qs("s"))) 846 + .to_request(); 847 + let body: Value = test::call_and_read_body_json(&app, req).await; 848 + let stations = body["subsonic-response"]["internetRadioStations"]["internetRadioStation"] 849 + .as_array() 850 + .unwrap() 851 + .clone(); 852 + assert_eq!(stations.len(), 1); 853 + let station = &stations[0]; 854 + assert_eq!(station["name"], "Tronic"); 855 + assert_eq!(station["streamUrl"], "http://example.com/stream"); 856 + assert_eq!(station["homepageUrl"], "http://example.com"); 857 + let id = station["id"].as_str().unwrap().to_string(); 858 + 859 + // Update it. 860 + let req = test::TestRequest::get() 861 + .uri(&format!( 862 + "/rest/updateInternetRadioStation?id={id}&streamUrl=http://example.com/hd&name=Tronic+HD&{}", 863 + token_qs("s") 864 + )) 865 + .to_request(); 866 + let body: Value = test::call_and_read_body_json(&app, req).await; 867 + assert_eq!(body["subsonic-response"]["status"], "ok"); 868 + 869 + let req = test::TestRequest::get() 870 + .uri(&format!("/rest/getInternetRadioStations?{}", token_qs("s"))) 871 + .to_request(); 872 + let body: Value = test::call_and_read_body_json(&app, req).await; 873 + let station = 874 + &body["subsonic-response"]["internetRadioStations"]["internetRadioStation"][0]; 875 + assert_eq!(station["name"], "Tronic HD"); 876 + assert_eq!(station["streamUrl"], "http://example.com/hd"); 877 + // homepageUrl cleared on update (omitted, so null-stripped away). 878 + assert!(station["homepageUrl"].is_null()); 879 + 880 + // Updating a missing id → error 70. 881 + let req = test::TestRequest::get() 882 + .uri(&format!( 883 + "/rest/updateInternetRadioStation?id=ir-nope&streamUrl=x&name=y&{}", 884 + token_qs("s") 885 + )) 886 + .to_request(); 887 + let body: Value = test::call_and_read_body_json(&app, req).await; 888 + assert_eq!(body["subsonic-response"]["error"]["code"], 70); 889 + 890 + // createInternetRadioStation without streamUrl → error 10. 891 + let req = test::TestRequest::get() 892 + .uri(&format!( 893 + "/rest/createInternetRadioStation?name=NoUrl&{}", 894 + token_qs("s") 895 + )) 896 + .to_request(); 897 + let body: Value = test::call_and_read_body_json(&app, req).await; 898 + assert_eq!(body["subsonic-response"]["error"]["code"], 10); 899 + 900 + // Delete it. 901 + let req = test::TestRequest::get() 902 + .uri(&format!( 903 + "/rest/deleteInternetRadioStation?id={id}&{}", 904 + token_qs("s") 905 + )) 906 + .to_request(); 907 + let body: Value = test::call_and_read_body_json(&app, req).await; 908 + assert_eq!(body["subsonic-response"]["status"], "ok"); 909 + 910 + let req = test::TestRequest::get() 911 + .uri(&format!("/rest/getInternetRadioStations?{}", token_qs("s"))) 912 + .to_request(); 913 + let body: Value = test::call_and_read_body_json(&app, req).await; 914 + assert_eq!( 915 + body["subsonic-response"]["internetRadioStations"]["internetRadioStation"] 916 + .as_array() 917 + .map(|a| a.len()), 918 + Some(0) 919 + ); 920 + 921 + // Deleting a missing id → error 70. 922 + let req = test::TestRequest::get() 923 + .uri(&format!( 924 + "/rest/deleteInternetRadioStation?id={id}&{}", 925 + token_qs("s") 926 + )) 927 + .to_request(); 928 + let body: Value = test::call_and_read_body_json(&app, req).await; 929 + assert_eq!(body["subsonic-response"]["error"]["code"], 70); 775 930 } 776 931 777 932 #[actix_web::test]
+76 -1
src/server/repo.rs
··· 1 1 use crate::db::Db; 2 - use crate::models::{Album, Artist, Playlist, Song, Video}; 2 + use crate::models::{Album, Artist, InternetRadioStation, Playlist, Song, Video}; 3 3 use crate::typesense::TypesenseClient; 4 4 use anyhow::Result; 5 5 ··· 1274 1274 .execute(pool) 1275 1275 .await?; 1276 1276 sqlx::query("DELETE FROM playlist_songs WHERE playlist_id = ?1") 1277 + .bind(id) 1278 + .execute(pool) 1279 + .await?; 1280 + Ok(()) 1281 + } 1282 + 1283 + // ── Internet radio ──────────────────────────────────────────────────────────── 1284 + 1285 + pub async fn all_internet_radio_stations(pool: &Db) -> Result<Vec<InternetRadioStation>> { 1286 + let rows = sqlx::query_as::<_, InternetRadioStation>( 1287 + "SELECT id, name, stream_url, homepage_url FROM internet_radio_stations 1288 + ORDER BY name COLLATE NOCASE", 1289 + ) 1290 + .fetch_all(pool) 1291 + .await?; 1292 + Ok(rows) 1293 + } 1294 + 1295 + pub async fn find_internet_radio_station( 1296 + pool: &Db, 1297 + id: &str, 1298 + ) -> Result<Option<InternetRadioStation>> { 1299 + let row = sqlx::query_as::<_, InternetRadioStation>( 1300 + "SELECT id, name, stream_url, homepage_url FROM internet_radio_stations WHERE id = ?1", 1301 + ) 1302 + .bind(id) 1303 + .fetch_optional(pool) 1304 + .await?; 1305 + Ok(row) 1306 + } 1307 + 1308 + pub async fn create_internet_radio_station( 1309 + pool: &Db, 1310 + id: &str, 1311 + name: &str, 1312 + stream_url: &str, 1313 + homepage_url: Option<&str>, 1314 + now: &str, 1315 + ) -> Result<()> { 1316 + sqlx::query( 1317 + "INSERT INTO internet_radio_stations (id, name, stream_url, homepage_url, created_at) 1318 + VALUES (?1, ?2, ?3, ?4, ?5)", 1319 + ) 1320 + .bind(id) 1321 + .bind(name) 1322 + .bind(stream_url) 1323 + .bind(homepage_url) 1324 + .bind(now) 1325 + .execute(pool) 1326 + .await?; 1327 + Ok(()) 1328 + } 1329 + 1330 + pub async fn update_internet_radio_station( 1331 + pool: &Db, 1332 + id: &str, 1333 + name: &str, 1334 + stream_url: &str, 1335 + homepage_url: Option<&str>, 1336 + ) -> Result<()> { 1337 + sqlx::query( 1338 + "UPDATE internet_radio_stations 1339 + SET name = ?2, stream_url = ?3, homepage_url = ?4 WHERE id = ?1", 1340 + ) 1341 + .bind(id) 1342 + .bind(name) 1343 + .bind(stream_url) 1344 + .bind(homepage_url) 1345 + .execute(pool) 1346 + .await?; 1347 + Ok(()) 1348 + } 1349 + 1350 + pub async fn delete_internet_radio_station(pool: &Db, id: &str) -> Result<()> { 1351 + sqlx::query("DELETE FROM internet_radio_stations WHERE id = ?1") 1277 1352 .bind(id) 1278 1353 .execute(pool) 1279 1354 .await?;
+6 -2
src/upnp/ssdp.rs
··· 153 153 fn notify_targets(uuid: &str) -> Vec<(String, String)> { 154 154 let root = format!("uuid:{uuid}"); 155 155 vec![ 156 - ("upnp:rootdevice".to_string(), format!("{root}::upnp:rootdevice")), 156 + ( 157 + "upnp:rootdevice".to_string(), 158 + format!("{root}::upnp:rootdevice"), 159 + ), 157 160 (root.clone(), root.clone()), 158 161 (DEVICE_TYPE.to_string(), format!("{root}::{DEVICE_TYPE}")), 159 162 ( ··· 230 233 231 234 #[test] 232 235 fn header_value_is_case_insensitive() { 233 - let msg = "M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nst: ssdp:all\r\nMX: 2\r\n\r\n"; 236 + let msg = 237 + "M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nst: ssdp:all\r\nMX: 2\r\n\r\n"; 234 238 assert_eq!(header_value(msg, "ST").as_deref(), Some("ssdp:all")); 235 239 assert_eq!(header_value(msg, "MX").as_deref(), Some("2")); 236 240 assert_eq!(header_value(msg, "Missing"), None);