aboutsummaryrefslogtreecommitdiff
path: root/server/src/routes
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-02-03 22:42:13 +0100
committermetamuffin <metamuffin@disroot.org>2025-02-03 22:42:13 +0100
commite43dc75e3cfb950ac0d0308900c20fa292de0c46 (patch)
treefce989ad0292328166efede8cfb2b769c370ab24 /server/src/routes
parent11c5be29987912b89fd6d351938d08fe6a561ad2 (diff)
downloadjellything-e43dc75e3cfb950ac0d0308900c20fa292de0c46.tar
jellything-e43dc75e3cfb950ac0d0308900c20fa292de0c46.tar.bz2
jellything-e43dc75e3cfb950ac0d0308900c20fa292de0c46.tar.zst
some jellyfin api endpoints
Diffstat (limited to 'server/src/routes')
-rw-r--r--server/src/routes/compat/jellyfin.rs393
-rw-r--r--server/src/routes/compat/mod.rs7
-rw-r--r--server/src/routes/compat/youtube.rs (renamed from server/src/routes/external_compat.rs)17
-rw-r--r--server/src/routes/mod.rs36
-rw-r--r--server/src/routes/ui/account/session/guard.rs2
-rw-r--r--server/src/routes/ui/assets.rs12
6 files changed, 447 insertions, 20 deletions
diff --git a/server/src/routes/compat/jellyfin.rs b/server/src/routes/compat/jellyfin.rs
new file mode 100644
index 0000000..18f0242
--- /dev/null
+++ b/server/src/routes/compat/jellyfin.rs
@@ -0,0 +1,393 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2025 metamuffin <metamuffin.org>
+*/
+use crate::routes::ui::{
+ account::{login_logic, session::Session},
+ assets::rocket_uri_macro_r_item_poster,
+ error::MyResult,
+ node::{aspect_class, DatabaseNodeUserDataExt},
+};
+use anyhow::Context;
+use jellybase::{database::Database, CONF};
+use jellycommon::{user::WatchedState, NodeID, NodeKind};
+use rocket::{get, post, response::Redirect, serde::json::Json, State};
+use serde::Deserialize;
+use serde_json::{json, Value};
+use std::net::IpAddr;
+
+const SERVER_ID: &'static str = "1694a95daf70708147f16103ce7b7566";
+const USER_ID: &'static str = "33f772aae6c2495ca89fe00340dbd17c";
+const VERSION: &'static str = "10.10.0";
+
+#[get("/System/Info/Public")]
+pub fn r_jellyfin_system_info_public_case() -> Json<Value> {
+ r_jellyfin_system_info_public()
+}
+#[get("/system/info/public")]
+pub fn r_jellyfin_system_info_public() -> Json<Value> {
+ Json(json!({
+ "LocalAddress": "http://127.0.0.1:8000",
+ "ServerName": CONF.brand.clone(),
+ "Version": VERSION,
+ "ProductName": "Jellything",
+ "OperatingSystem": "",
+ "Id": SERVER_ID,
+ "StartupWizardCompleted": true,
+ }))
+}
+
+#[get("/Branding/Configuration")]
+pub fn r_jellyfin_branding_configuration() -> Json<Value> {
+ Json(json!({
+ "LoginDisclaimer": format!("{} - {}", CONF.brand, CONF.slogan),
+ "CustomCss": "",
+ "SplashscreenEnabled": false,
+ }))
+}
+
+#[get("/users/public")]
+pub fn r_jellyfin_users_public() -> Json<Value> {
+ Json(json!([]))
+}
+
+#[get("/QuickConnect/Enabled")]
+pub fn r_jellyfin_quickconnect_enabled(_session: Session) -> Json<Value> {
+ Json(json!(false))
+}
+
+#[get("/System/Endpoint")]
+pub fn r_jellyfin_system_endpoint(_session: Session) -> Json<Value> {
+ Json(json!({
+ "IsLocal": false,
+ "IsInNetwork": false,
+ }))
+}
+
+// use rocket_ws::{Message, Stream, WebSocket};
+// #[get("/socket")]
+// pub fn r_jellyfin_socket(_session: Session, ws: WebSocket) -> Stream!['static] {
+// Stream! { ws =>
+// for await message in ws {
+// eprintln!("{message:?}");
+// }
+// yield Message::Text("test".to_string())
+// }
+// }
+
+#[get("/System/Info")]
+pub fn r_jellyfin_system_info(_session: Session) -> Json<Value> {
+ Json(json!({
+ "OperatingSystemDisplayName": "",
+ "HasPendingRestart": false,
+ "IsShuttingDown": false,
+ "SupportsLibraryMonitor": true,
+ "WebSocketPortNumber": 8096,
+ "CompletedInstallations": [],
+ "CanSelfRestart": true,
+ "CanLaunchWebBrowser": false,
+ "ProgramDataPath": "/config",
+ "WebPath": "/jellyfin/jellyfin-web",
+ "ItemsByNamePath": "/var/lib/jellyfin/metadata",
+ "CachePath": "/var/cache/jellyfin",
+ "LogPath": "/config/log",
+ "InternalMetadataPath": "/var/lib/jellyfin/metadata",
+ "TranscodingTempPath": "/var/lib/jellyfin/transcodes",
+ "CastReceiverApplications": [],
+ "HasUpdateAvailable": false,
+ "EncoderLocation": "System",
+ "SystemArchitecture": "X64",
+ "LocalAddress": "http://127.0.0.1:8000",
+ "ServerName": CONF.brand,
+ "Version": VERSION,
+ "OperatingSystem": "",
+ "Id": SERVER_ID
+ }))
+}
+
+#[get("/DisplayPreferences/usersettings")]
+pub fn r_jellyfin_displaypreferences_usersettings(_session: Session) -> Json<Value> {
+ Json(json!({
+ "Id": "3ce5b65d-e116-d731-65d1-efc4a30ec35c",
+ "SortBy": "SortName",
+ "RememberIndexing": false,
+ "PrimaryImageHeight": 250,
+ "PrimaryImageWidth": 250,
+ "CustomPrefs": false,
+ "ScrollDirection": "Horizontal",
+ "ShowBackdrop": true,
+ "RememberSorting": false,
+ "SortOrder": "Ascending",
+ "ShowSidebar": false,
+ "Client": "emby",
+ }))
+}
+
+#[get("/Users/<id>")]
+pub fn r_jellyfin_users_id(session: Session, id: &str) -> Json<Value> {
+ let _ = id;
+ Json(user_object(session.user.name))
+}
+
+#[get("/Items/<id>/Images/Primary?<fillWidth>")]
+#[allow(non_snake_case)]
+pub fn r_jellyfin_items_image_primary(
+ _session: Session,
+ id: &str,
+ fillWidth: Option<usize>,
+) -> Redirect {
+ Redirect::permanent(rocket::uri!(r_item_poster(id, fillWidth)))
+}
+
+#[get("/UserViews?<userId>")]
+#[allow(non_snake_case)]
+pub fn r_jellyfin_users_views(
+ session: Session,
+ database: &State<Database>,
+ userId: &str,
+) -> MyResult<Json<Value>> {
+ let _ = userId;
+
+ let toplevel = database
+ .get_node_children(NodeID::from_slug("library"))
+ .context("root node missing")?;
+
+ let mut items = Vec::new();
+ for nid in toplevel {
+ let (n, ud) = database.get_node_with_userdata(nid, &session)?;
+
+ items.push(json!({
+ "Name": n.title,
+ "ServerId": SERVER_ID,
+ "Id": n.slug.clone(),
+ "Etag": "blob",
+ "DateCreated": "0001-01-01T00:00:00.0000000Z",
+ "CanDelete": false,
+ "CanDownload": false,
+ "PreferredMetadataLanguage": "",
+ "PreferredMetadataCountryCode": "",
+ "SortName": n.slug.clone(),
+ "ForcedSortName": "",
+ "ExternalUrls": [],
+ "Path": "/why/does/it/matter",
+ "EnableMediaSourceDisplay": true,
+ "CustomRating": "",
+ "ChannelId": null,
+ "Overview": "",
+ "Taglines": [],
+ "Genres": [],
+ "PlayAccess": "Full",
+ "RemoteTrailers": [],
+ "ProviderIds": {},
+ "IsFolder": true,
+ "ParentId": "todo-parent", // TODO
+ "Type": match n.kind {
+ NodeKind::Collection | _ => "CollectionFolder",
+ },
+ "People": [],
+ "Studios": [],
+ "GenreItems": [],
+ "LocalTrailerCount": 0,
+ "UserData": {
+ "PlaybackPositionTicks": 0,
+ "PlayCount": if ud.watched == WatchedState::Watched { 1 } else { 0 },
+ "IsFavorite": ud.rating > 0,
+ "Played": ud.watched == WatchedState::Watched,
+ "Key": "7a2175bc-cb1f-1a94-152c-bd2b2bae8f6d",
+ "ItemId": "00000000000000000000000000000000"
+ },
+ "ChildCount": 2,
+ "SpecialFeatureCount": 0,
+ "DisplayPreferencesId": n.slug.clone(),
+ "Tags": [],
+ "PrimaryImageAspectRatio": match aspect_class(n.kind) {
+ "aspect-thumb" => 16. / 9.,
+ "aspect-land" => 2f32.sqrt(),
+ "aspect-port" => 1. / 2f32.sqrt(),
+ "aspect-square" | _ => 1.,
+ },
+ "CollectionType": "movies",
+ "ImageTags": {
+ "Primary": "the-image"
+ },
+ "BackdropImageTags": [],
+ "LocationType": "FileSystem",
+ "MediaType": "Unknown",
+ "LockedFields": [],
+ "LockData": false
+ }))
+ }
+
+ Ok(Json(json!({
+ "Items": items,
+ "TotalRecordCount": items.len(),
+ "StartIndex": 0
+ })))
+}
+
+#[get("/LiveTv/Programs/Recommended")]
+pub fn r_jellyfin_livetv_programs_recommended(_session: Session) -> Json<Value> {
+ Json(json!({
+ "Items": [],
+ "TotalRecordCount": 0,
+ "StartIndex": 0
+ }))
+}
+
+#[get("/Playback/BitrateTest?<Size>")]
+#[allow(non_snake_case)]
+pub fn r_jellyfin_playback_bitratetest(_session: Session, Size: usize) -> Vec<u8> {
+ vec![0; Size.min(1_000_000)]
+}
+
+#[get("/Branding/Css")]
+pub fn r_jellyfin_branding_css(_session: Session) -> String {
+ "".to_string()
+}
+
+#[post("/Sessions/Capabilities/Full")]
+pub fn r_jellyfin_sessions_capabilities_full(_session: Session) -> () {
+ ()
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "PascalCase")]
+struct AuthData {
+ pw: String,
+ username: String,
+}
+
+#[post("/Users/authenticatebyname", data = "<data>")]
+#[allow(private_interfaces)]
+pub fn r_jellyfin_users_authenticatebyname(
+ client_addr: IpAddr,
+ database: &State<Database>,
+ data: Json<AuthData>,
+) -> MyResult<Json<Value>> {
+ let token = login_logic(database, &data.username, &data.pw, None, None)?;
+
+ Ok(Json(json!({
+ "User": user_object(data.username.clone()),
+ "SessionInfo": {
+ "PlayState": {
+ "CanSeek": false,
+ "IsPaused": false,
+ "IsMuted": false,
+ "RepeatMode": "RepeatNone",
+ "PlaybackOrder": "Default"
+ },
+ "AdditionalUsers": [],
+ "Capabilities": {
+ "PlayableMediaTypes": [
+ "Audio",
+ "Video"
+ ],
+ "SupportedCommands": [],
+ // "MoveUp", "MoveDown", "MoveLeft", "MoveRight", "PageUp", "PageDown", "PreviousLetter", "NextLetter", "ToggleOsd", "ToggleContextMenu", "Select", "Back", "SendKey", "SendString", "GoHome", "GoToSettings", "VolumeUp", "VolumeDown", "Mute", "Unmute", "ToggleMute", "SetVolume", "SetAudioStreamIndex", "SetSubtitleStreamIndex", "DisplayContent", "GoToSearch", "DisplayMessage", "SetRepeatMode", "SetShuffleQueue", "ChannelUp", "ChannelDown", "PlayMediaSource", "PlayTrailers"
+ "SupportsMediaControl": true,
+ "SupportsPersistentIdentifier": false
+ },
+ "RemoteEndPoint": client_addr,
+ "PlayableMediaTypes": [
+ "Audio",
+ "Video"
+ ],
+ "Id": "6e05fbb4fe33477b991455c97a57e25d",
+ "UserId": USER_ID,
+ "UserName": data.username.clone(),
+ "Client": "Jellyfin Web",
+ "LastActivityDate": "0001-01-01T00:00:00.0000000Z",
+ "LastPlaybackCheckIn": "0001-01-01T00:00:00.0000000Z",
+ "DeviceName": "blub blub blub",
+ "DeviceId": "wagening",
+ "ApplicationVersion": VERSION,
+ "IsActive": true,
+ "SupportsMediaControl": false,
+ "SupportsRemoteControl": false,
+ "NowPlayingQueue": [],
+ "NowPlayingQueueFullItems": [],
+ "HasCustomDeviceName": false,
+ "ServerId": SERVER_ID,
+ "SupportedCommands": []
+ // "MoveUp", "MoveDown", "MoveLeft", "MoveRight", "PageUp", "PageDown", "PreviousLetter", "NextLetter", "ToggleOsd", "ToggleContextMenu", "Select", "Back", "SendKey", "SendString", "GoHome", "GoToSettings", "VolumeUp", "VolumeDown", "Mute", "Unmute", "ToggleMute", "SetVolume", "SetAudioStreamIndex", "SetSubtitleStreamIndex", "DisplayContent", "GoToSearch", "DisplayMessage", "SetRepeatMode", "SetShuffleQueue", "ChannelUp", "ChannelDown", "PlayMediaSource", "PlayTrailers"
+ },
+ "AccessToken": token,
+ "ServerId": SERVER_ID
+ })))
+}
+
+fn user_object(username: String) -> Value {
+ json!({
+ "Name": username,
+ "ServerId": SERVER_ID,
+ "Id": USER_ID,
+ "HasPassword": true,
+ "HasConfiguredPassword": true,
+ "HasConfiguredEasyPassword": false,
+ "EnableAutoLogin": false,
+ "LastLoginDate": "0001-01-01T00:00:00.0000000Z",
+ "LastActivityDate": "0001-01-01T00:00:00.0000000Z",
+ "Configuration": {
+ "PlayDefaultAudioTrack": true,
+ "SubtitleLanguagePreference": "",
+ "DisplayMissingEpisodes": false,
+ "GroupedFolders": [],
+ "SubtitleMode": "Default",
+ "DisplayCollectionsView": false,
+ "EnableLocalPassword": false,
+ "OrderedViews": [],
+ "LatestItemsExcludes": [],
+ "MyMediaExcludes": [],
+ "HidePlayedInLatest": true,
+ "RememberAudioSelections": true,
+ "RememberSubtitleSelections": true,
+ "EnableNextEpisodeAutoPlay": true,
+ "CastReceiverId": "F007D354"
+ },
+ "Policy": {
+ "IsAdministrator": false,
+ "IsHidden": true,
+ "EnableCollectionManagement": false,
+ "EnableSubtitleManagement": false,
+ "EnableLyricManagement": false,
+ "IsDisabled": false,
+ "BlockedTags": [],
+ "AllowedTags": [],
+ "EnableUserPreferenceAccess": true,
+ "AccessSchedules": [],
+ "BlockUnratedItems": [],
+ "EnableRemoteControlOfOtherUsers": false,
+ "EnableSharedDeviceControl": false,
+ "EnableRemoteAccess": true,
+ "EnableLiveTvManagement": false,
+ "EnableLiveTvAccess": true,
+ "EnableMediaPlayback": true,
+ "EnableAudioPlaybackTranscoding": true,
+ "EnableVideoPlaybackTranscoding": true,
+ "EnablePlaybackRemuxing": true,
+ "ForceRemoteSourceTranscoding": false,
+ "EnableContentDeletion": false,
+ "EnableContentDeletionFromFolders": [],
+ "EnableContentDownloading": true,
+ "EnableSyncTranscoding": true,
+ "EnableMediaConversion": true,
+ "EnabledDevices": [],
+ "EnableAllDevices": true,
+ "EnabledChannels": [],
+ "EnableAllChannels": false,
+ "EnabledFolders": [],
+ "EnableAllFolders": true,
+ "InvalidLoginAttemptCount": 0,
+ "LoginAttemptsBeforeLockout": -1,
+ "MaxActiveSessions": 0,
+ "EnablePublicSharing": true,
+ "BlockedMediaFolders": [],
+ "BlockedChannels": [],
+ "RemoteClientBitrateLimit": 0,
+ "AuthenticationProviderId": "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider",
+ "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider",
+ "SyncPlayAccess": "CreateAndJoinGroups"
+ }
+ })
+}
diff --git a/server/src/routes/compat/mod.rs b/server/src/routes/compat/mod.rs
new file mode 100644
index 0000000..a7b8c0d
--- /dev/null
+++ b/server/src/routes/compat/mod.rs
@@ -0,0 +1,7 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2025 metamuffin <metamuffin.org>
+*/
+pub mod jellyfin;
+pub mod youtube;
diff --git a/server/src/routes/external_compat.rs b/server/src/routes/compat/youtube.rs
index eda3537..732431e 100644
--- a/server/src/routes/external_compat.rs
+++ b/server/src/routes/compat/youtube.rs
@@ -3,15 +3,18 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-use super::ui::{account::session::Session, error::MyResult};
-use crate::routes::ui::node::rocket_uri_macro_r_library_node;
-use crate::routes::ui::player::{rocket_uri_macro_r_player, PlayerConfig};
+use crate::routes::ui::{
+ account::session::Session,
+ error::MyResult,
+ node::rocket_uri_macro_r_library_node,
+ player::{rocket_uri_macro_r_player, PlayerConfig},
+};
use anyhow::anyhow;
use jellybase::database::Database;
use rocket::{get, response::Redirect, State};
#[get("/watch?<v>")]
-pub fn r_ext_youtube_watch(_session: Session, db: &State<Database>, v: &str) -> MyResult<Redirect> {
+pub fn r_youtube_watch(_session: Session, db: &State<Database>, v: &str) -> MyResult<Redirect> {
if v.len() != 11 {
Err(anyhow!("video id length incorrect"))?
}
@@ -26,11 +29,7 @@ pub fn r_ext_youtube_watch(_session: Session, db: &State<Database>, v: &str) ->
}
#[get("/channel/<id>")]
-pub fn r_ext_youtube_channel(
- _session: Session,
- db: &State<Database>,
- id: &str,
-) -> MyResult<Redirect> {
+pub fn r_youtube_channel(_session: Session, db: &State<Database>, id: &str) -> MyResult<Redirect> {
let Some(id) = (if id.starts_with("UC") {
db.get_node_external_id("youtube:channel", id)?
} else if id.starts_with("@") {
diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs
index e0b955a..f2cfdfd 100644
--- a/server/src/routes/mod.rs
+++ b/server/src/routes/mod.rs
@@ -7,7 +7,18 @@ use self::playersync::{r_streamsync, PlayersyncChannels};
use crate::{database::Database, routes::ui::error::MyResult};
use api::{r_api_account_login, r_api_asset_token_raw, r_api_root, r_api_version};
use base64::Engine;
-use external_compat::{r_ext_youtube_channel, r_ext_youtube_watch};
+use compat::{
+ jellyfin::{
+ r_jellyfin_branding_configuration, r_jellyfin_branding_css,
+ r_jellyfin_displaypreferences_usersettings, r_jellyfin_items_image_primary,
+ r_jellyfin_livetv_programs_recommended, r_jellyfin_playback_bitratetest,
+ r_jellyfin_quickconnect_enabled, r_jellyfin_sessions_capabilities_full,
+ r_jellyfin_system_endpoint, r_jellyfin_system_info, r_jellyfin_system_info_public,
+ r_jellyfin_system_info_public_case, r_jellyfin_users_authenticatebyname,
+ r_jellyfin_users_id, r_jellyfin_users_public, r_jellyfin_users_views,
+ },
+ youtube::{r_youtube_channel, r_youtube_watch},
+};
use jellybase::{federation::Federation, CONF, SECRETS};
use log::warn;
use rand::random;
@@ -49,7 +60,7 @@ use userdata::{
};
pub mod api;
-pub mod external_compat;
+pub mod compat;
pub mod playersync;
pub mod stream;
pub mod ui;
@@ -144,8 +155,25 @@ pub fn build_rocket(database: Database, federation: Federation) -> Rocket<Build>
r_api_account_login,
r_api_root,
r_api_asset_token_raw,
- r_ext_youtube_watch,
- r_ext_youtube_channel,
+ r_youtube_watch,
+ r_youtube_channel,
+ r_jellyfin_system_info_public,
+ r_jellyfin_system_info_public_case,
+ r_jellyfin_quickconnect_enabled,
+ r_jellyfin_users_public,
+ r_jellyfin_branding_configuration,
+ r_jellyfin_users_authenticatebyname,
+ r_jellyfin_sessions_capabilities_full,
+ r_jellyfin_system_endpoint,
+ r_jellyfin_branding_css,
+ r_jellyfin_displaypreferences_usersettings,
+ r_jellyfin_system_info,
+ r_jellyfin_users_id,
+ r_jellyfin_playback_bitratetest,
+ r_jellyfin_users_views,
+ r_jellyfin_items_image_primary,
+ r_jellyfin_livetv_programs_recommended,
+ // r_jellyfin_socket,
],
)
}
diff --git a/server/src/routes/ui/account/session/guard.rs b/server/src/routes/ui/account/session/guard.rs
index 57540cf..3a3f6d7 100644
--- a/server/src/routes/ui/account/session/guard.rs
+++ b/server/src/routes/ui/account/session/guard.rs
@@ -23,6 +23,8 @@ impl Session {
{
let token = req
.query_value("session")
+ .or(req.query_value("api_key"))
+ .or(req.headers().get_one("X-MediaBrowser-Token").map(Ok)) // for jellyfin compat
.map(|e| e.expect("str parse should not fail, right?"))
.or(req.cookies().get("session").map(|cookie| cookie.value()))
.ok_or(anyhow!("not logged in"))?;
diff --git a/server/src/routes/ui/assets.rs b/server/src/routes/ui/assets.rs
index a01c8bc..bd48f35 100644
--- a/server/src/routes/ui/assets.rs
+++ b/server/src/routes/ui/assets.rs
@@ -80,10 +80,9 @@ pub async fn r_item_poster(
}
};
let asset = asset.unwrap_or_else(|| {
- AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into())
- .ser()
+ AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()).ser()
});
- Ok(Redirect::temporary(rocket::uri!(r_asset(asset.0, width))))
+ Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width))))
}
#[get("/n/<id>/backdrop?<width>")]
pub async fn r_item_backdrop(
@@ -105,10 +104,9 @@ pub async fn r_item_backdrop(
}
};
let asset = asset.unwrap_or_else(|| {
- AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into())
- .ser()
+ AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()).ser()
});
- Ok(Redirect::temporary(rocket::uri!(r_asset(asset.0, width))))
+ Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width))))
}
#[get("/n/<id>/person/<index>/asset?<group>&<width>")]
@@ -137,7 +135,7 @@ pub async fn r_person_asset(
.headshot
.to_owned()
.unwrap_or(AssetInner::Assets("fallback-Person.avif".into()).ser());
- Ok(Redirect::temporary(rocket::uri!(r_asset(asset.0, width))))
+ Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width))))
}
// TODO this can create "federation recursion" because track selection cannot be relied on.