Compare commits

..

6 Commits

48 changed files with 23 additions and 544 deletions

199
AGENTS.md
View File

@@ -1,199 +0,0 @@
# Agent Guidelines for Jello
This document provides guidelines for AI coding agents working on the Jello codebase.
## Project Overview
Jello is a WIP video client for Jellyfin written in Rust, focusing on HDR video playback using:
- **iced** - Primary GUI toolkit
- **gstreamer** - Video + audio decoding library
- **wgpu** - Rendering video from GStreamer in iced
## Build, Test, and Lint Commands
### Building
```bash
# Build in release mode
cargo build --release
cargo build -r
# Build specific workspace member
cargo build -p api
cargo build -p gst
cargo build -p ui-iced
# Run the application
cargo run --release -- -vv
just jello # Uses justfile
```
### Testing
```bash
# Run all tests in workspace
cargo test --workspace
# Run tests for a specific package
cargo test -p gst
cargo test -p api
cargo test -p iced-video
# Run a single test by name
cargo test test_appsink
cargo test -p gst test_appsink
# Run a specific test in a specific file
cargo test -p gst --test <test_file_name> <test_function_name>
# Run tests with output
cargo test -- --nocapture
cargo test -- --show-output
```
### Linting and Formatting
```bash
# Check code without building
cargo check
cargo check --workspace
# Run clippy (linter)
cargo clippy
cargo clippy --workspace
cargo clippy --workspace -- -D warnings
# Format code
cargo fmt
cargo fmt --all
# Check formatting without modifying files
cargo fmt --all -- --check
```
### Other Tools
```bash
# Check for security vulnerabilities and license compliance
cargo deny check
# Generate Jellyfin type definitions
just typegen
```
## Code Style Guidelines
### Rust Edition
- Use **Rust 2024 edition** (as specified in Cargo.toml files)
### Imports
- Use `use` statements at the top of files
- Group imports: std library, external crates, then local modules
- Use `crate::` for absolute paths within the crate
- Common pattern: create a `priv_prelude` module for internal imports
- Use `pub use` to re-export commonly used items
- Use wildcard imports (`use crate::priv_prelude::*;`) within internal modules when a prelude exists
Example:
```rust
use std::sync::Arc;
use reqwest::{Method, header::InvalidHeaderValue};
use serde::{Deserialize, Serialize};
use crate::errors::*;
```
### Naming Conventions
- **Types/Structs/Enums**: PascalCase (e.g., `JellyfinClient`, `Error`, `AppSink`)
- **Functions/Methods**: snake_case (e.g., `request_builder`, `stream_url`)
- **Variables**: snake_case (e.g., `access_token`, `device_id`)
- **Constants**: SCREAMING_SNAKE_CASE (e.g., `NEXT_ID`, `GST`)
- **Modules**: snake_case (e.g., `priv_prelude`, `error_stack`)
### Error Handling
- Use **`error-stack`** for error handling with context propagation
- Use **`thiserror`** for defining error types
- Standard error type pattern:
```rust
pub use error_stack::{Report, ResultExt};
#[derive(Debug, thiserror::Error)]
#[error("An error occurred")]
pub struct Error;
pub type Result<T, E = error_stack::Report<Error>> = core::result::Result<T, E>;
```
- Attach context to errors using `.change_context(Error)` and `.attach("description")`
- Use `#[track_caller]` on functions that may panic or error for better error messages
- Error handling example:
```rust
self.inner
.set_state(gstreamer::State::Playing)
.change_context(Error)
.attach("Failed to set pipeline to Playing state")?;
```
### Types
- Prefer explicit types over type inference when it improves clarity
- Use `impl Trait` for function parameters when appropriate (e.g., `impl AsRef<str>`)
- Use `Option<T>` and `Result<T, E>` idiomatically
- Use `Arc<T>` for shared ownership
- Use newtype patterns for semantic clarity (e.g., `ApiKey` wrapping `secrecy::SecretBox<String>`)
### Formatting
- Use 4 spaces for indentation
- Line length: aim for 100 characters, but not strictly enforced
- Use trailing commas in multi-line collections
- Follow standard Rust formatting conventions (enforced by `cargo fmt`)
### Documentation
- Add doc comments (`///`) for public APIs
- Use inline comments (`//`) sparingly, prefer self-documenting code
- Include examples in doc comments when helpful
### Async/Await
- Use `tokio` as the async runtime
- Mark async functions with `async` keyword
- Use `.await` for async operations
- Common pattern: `tokio::fs` for file operations
### Module Structure
- Use `mod.rs` or inline modules as appropriate
- Keep related functionality together
- Use `pub(crate)` for internal APIs
- Re-export commonly used items at crate root
### Macros
- Custom macros used: `wrap_gst!`, `parent_child!`
- Use macros for reducing boilerplate, only in the `gst` crate
### Testing
- Place tests in the same file with `#[test]` or `#[cfg(test)]`
- Use descriptive test function names (e.g., `test_appsink`, `unique_generates_different_ids`)
- Initialize tracing in tests when needed for debugging
### Dependencies
- Prefer well-maintained crates from crates.io
- Use `workspace.dependencies` for shared dependencies across workspace members
- Pin versions when stability is important
### Workspace Structure
The project uses a Cargo workspace with multiple members:
- `.` - Main jello binary
- `api` - Jellyfin API client
- `gst` - GStreamer wrapper
- `ui-iced` - Iced UI implementation
- `ui-gpui` - GPUI UI implementation (optional)
- `store` - Secret/data/storage management
- `jello-types` - Shared type definitions
- `typegen` - Jellyfin type generator
- `crates/iced-video` - Custom iced video widget
- `examples/hdr-gstreamer-wgpu` - HDR example
### Project-Specific Patterns
- Use `LazyLock` for global initialization (e.g., GStreamer init)
- Use the builder pattern with method chaining (e.g., `request_builder()`)
- Use `tap` crate's `.pipe()` for functional transformations
- Prefer `BTreeMap`/`BTreeSet` over `HashMap`/`HashSet` when order matters
- Prefer a functional programming style instead of an imperative one.
- When building UIs keep the handler and view code in the same module (eg. settings view and settings handle in the same file)
## License
All code in this project is MIT licensed.

8
Cargo.lock generated
View File

@@ -4127,14 +4127,6 @@ dependencies = [
"ui-iced", "ui-iced",
] ]
[[package]]
name = "jello-types"
version = "0.1.0"
dependencies = [
"serde",
"uuid",
]
[[package]] [[package]]
name = "jiff" name = "jiff"
version = "0.2.18" version = "0.2.18"

View File

@@ -1,21 +1,20 @@
[workspace] [workspace]
members = [ members = [
".", ".",
"api",
"typegen", "typegen",
"ui-gpui", "ui-gpui",
"ui-iced", "ui-iced",
"store", "crates/api",
"jello-types", "crates/gst",
"gst",
"examples/hdr-gstreamer-wgpu",
"crates/iced-video", "crates/iced-video",
"crates/store",
"examples/hdr-gstreamer-wgpu",
] ]
[workspace.dependencies] [workspace.dependencies]
iced = { version = "0.14.0" } iced = { version = "0.14.0" }
gst = { version = "0.1.0", path = "gst" } gst = { version = "0.1.0", path = "crates/gst" }
iced_wgpu = { version = "0.14.0" }
iced-video = { version = "0.1.0", path = "crates/iced-video" } iced-video = { version = "0.1.0", path = "crates/iced-video" }
api = { version = "0.1.0", path = "crates/api" }
[patch.crates-io] [patch.crates-io]
iced_wgpu = { git = "https://github.com/uttarayan21/iced", branch = "0.14" } iced_wgpu = { git = "https://github.com/uttarayan21/iced", branch = "0.14" }
@@ -31,7 +30,7 @@ edition = "2024"
license = "MIT" license = "MIT"
[dependencies] [dependencies]
api = { version = "0.1.0", path = "api" } api = { version = "0.1.0", path = "crates/api" }
bytemuck = { version = "1.24.0", features = ["derive"] } bytemuck = { version = "1.24.0", features = ["derive"] }
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
clap-verbosity-flag = { version = "3.0.4", features = ["tracing"] } clap-verbosity-flag = { version = "3.0.4", features = ["tracing"] }

View File

@@ -19,11 +19,3 @@ wgpu = { version = "27.0.1", features = ["vulkan"] }
[dev-dependencies] [dev-dependencies]
iced.workspace = true iced.workspace = true
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
[profile.dev]
debug = true
[profile.release]
debug = true
# [patch.crates-io]
# iced_wgpu = { git = "https://github.com/uttarayan21/iced", branch = "0.14" }

View File

@@ -1,12 +1,11 @@
use crate::{Error, Result, ResultExt}; use crate::{Error, Result, ResultExt};
use gst::{ use gst::{
Bus, Gst, MessageType, MessageView, Sink, Source, Bus, Gst, Sink,
app::AppSink, app::AppSink,
caps::{Caps, CapsType}, caps::{Caps, CapsType},
element::ElementExt, element::ElementExt,
pipeline::PipelineExt, pipeline::PipelineExt,
playback::{PlayFlags, Playbin3}, playback::{PlayFlags, Playbin3},
videoconvertscale::VideoConvert,
}; };
use std::sync::{Arc, Mutex, atomic::AtomicBool}; use std::sync::{Arc, Mutex, atomic::AtomicBool};

View File

@@ -15,6 +15,3 @@ anyhow = "*"
pollster = "0.4.0" pollster = "0.4.0"
tracing = { version = "0.1.43", features = ["log"] } tracing = { version = "0.1.43", features = ["log"] }
tracing-subscriber = "0.3.22" tracing-subscriber = "0.3.22"
[profile.release]
debug = true

View File

@@ -1,62 +0,0 @@
name: build
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
env:
CARGO_TERM_COLOR: always
jobs:
checks-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- id: set-matrix
name: Generate Nix Matrix
run: |
set -Eeu
matrix="$(nix eval --json '.#githubActions.matrix')"
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
checks-build:
needs: checks-matrix
runs-on: ${{ matrix.os }}
strategy:
matrix: ${{fromJSON(needs.checks-matrix.outputs.matrix)}}
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- run: nix build -L '.#${{ matrix.attr }}'
codecov:
runs-on: ubuntu-latest
permissions:
id-token: "write"
contents: "read"
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Run codecov
run: nix build .#checks.x86_64-linux.hello-llvm-cov
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4.0.1
with:
flags: unittests
name: codecov-hello
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
files: ./result
verbose: true

View File

@@ -1,38 +0,0 @@
name: docs
on:
push:
branches: [ master ]
env:
CARGO_TERM_COLOR: always
jobs:
docs:
runs-on: ubuntu-latest
permissions:
id-token: "write"
contents: "read"
pages: "write"
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: DeterminateSystems/flake-checker-action@main
- name: Generate docs
run: nix build .#checks.x86_64-linux.hello-docs
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: result/share/doc
- name: Deploy to gh-pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -1,8 +0,0 @@
[package]
name = "jello-types"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1.0.228", features = ["derive"] }
uuid = { version = "1.18.1", features = ["serde"] }

View File

@@ -1,6 +0,0 @@
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct User {
id: uuid::Uuid,
name: Option<String>,
primary_image_tag: Option<String>,
}

View File

@@ -1,4 +1,4 @@
pub use error_stack::{Report, ResultExt}; pub use error_stack::ResultExt;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
#[error("An error occurred")] #[error("An error occurred")]
pub struct Error; pub struct Error;

View File

@@ -1,6 +1,5 @@
mod cli; mod cli;
mod errors; mod errors;
use api::JellyfinConfig;
use errors::*; use errors::*;
fn main() -> Result<()> { fn main() -> Result<()> {

View File

@@ -5,7 +5,7 @@ edition = "2024"
license = "MIT" license = "MIT"
[dependencies] [dependencies]
api = { version = "0.1.0", path = "../api" } api = { workspace = true }
blurhash = "0.2.3" blurhash = "0.2.3"
bytes = "1.11.0" bytes = "1.11.0"
gpui_util = "0.2.2" gpui_util = "0.2.2"

View File

@@ -270,7 +270,6 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
} }
} }
Message::Video(msg) => video::update(state, msg), Message::Video(msg) => video::update(state, msg),
_ => todo!(),
} }
} }
@@ -442,51 +441,7 @@ fn card(item: &Item) -> Element<'_, Message> {
} }
fn init() -> (State, Task<Message>) { fn init() -> (State, Task<Message>) {
// Create a default config for initial state (State::new(), Task::done(Message::Refresh))
// let default_config = api::JellyfinConfig {
// server_url: "http://localhost:8096".parse().expect("Valid URL"),
// device_id: "jello-iced".to_string(),
// device_name: "Jello Iced".to_string(),
// client_name: "Jello".to_string(),
// version: "0.1.0".to_string(),
// };
// let default_client = api::JellyfinClient::new_with_config(default_config);
(
State::new(),
Task::perform(
async move {
let config_str = std::fs::read_to_string("config.toml")
.map_err(|e| api::JellyfinApiError::IoError(e))?;
let config: api::JellyfinConfig = toml::from_str(&config_str).map_err(|e| {
api::JellyfinApiError::IoError(std::io::Error::new(
std::io::ErrorKind::InvalidData,
e,
))
})?;
// Try to load cached token and authenticate
match std::fs::read_to_string(".session") {
Ok(token) => {
let client = api::JellyfinClient::pre_authenticated(token.trim(), config)?;
Ok((client, true))
}
Err(_) => {
// No cached token, create unauthenticated client
let client = api::JellyfinClient::new_with_config(config);
Ok((client, false))
}
}
},
|result: Result<_, api::JellyfinApiError>| match result {
// Ok((client, is_authenticated)) => Message::LoadedClient(client, is_authenticated),
Err(e) => Message::Error(format!("Initialization failed: {}", e)),
_ => Message::Error("Login Unimplemented".to_string()),
},
)
.chain(Task::done(Message::Refresh)),
)
} }
pub fn ui() -> iced::Result { pub fn ui() -> iced::Result {

View File

@@ -18,10 +18,10 @@ pub fn update(state: &mut State, message: SettingsMessage) -> Task<Message> {
SettingsMessage::Select(screen) => { SettingsMessage::Select(screen) => {
tracing::trace!("Switching settings screen to {:?}", screen); tracing::trace!("Switching settings screen to {:?}", screen);
state.settings.screen = screen; state.settings.screen = screen;
} } //
SettingsMessage::User(user) => state.settings.login_form.update(user), // SettingsMessage::User(user) => state.settings.login_form.update(user),
//
SettingsMessage::Server(server) => state.settings.server_form.update(server), // SettingsMessage::Server(server) => state.settings.server_page.update(server),
} }
Task::none() Task::none()
} }
@@ -30,40 +30,13 @@ pub fn empty() -> Element<'static, Message> {
column([]).into() column([]).into()
} }
#[derive(Debug, Clone, Default)]
pub struct SettingsState {
login_form: LoginForm,
server_form: ServerForm,
screen: SettingsScreen,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum SettingsMessage { pub enum SettingsMessage {
Open, Open,
Close, Close,
Select(SettingsScreen), Select(SettingsScreen),
User(UserMessage), // User(UserMessage),
Server(ServerMessage), // Server(ServerMessage),
}
#[derive(Debug, Clone)]
pub enum UserMessage {
Add,
UsernameChanged(String),
PasswordChanged(String),
// Edit(uuid::Uuid),
// Delete(uuid::Uuid),
Clear,
}
#[derive(Debug, Clone)]
pub enum ServerMessage {
Add,
NameChanged(String),
UrlChanged(String),
// Edit(uuid::Uuid),
// Delete(uuid::Uuid),
Clear,
} }
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -74,124 +47,9 @@ pub enum SettingsScreen {
Servers, Servers,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ServerItem { pub struct SettingsState {
pub id: uuid::Uuid, pub screen: SettingsScreen,
pub name: SharedString,
pub url: SharedString,
pub users: Vec<uuid::Uuid>,
}
#[derive(Debug, Clone)]
pub struct UserItem {
pub id: uuid::Uuid,
pub name: SharedString,
}
#[derive(Debug, Clone, Default)]
pub struct LoginForm {
username: String,
password: String,
}
impl LoginForm {
pub fn update(&mut self, message: UserMessage) {
match message {
UserMessage::UsernameChanged(data) => {
self.username = data;
}
UserMessage::PasswordChanged(data) => {
self.password = data;
}
UserMessage::Add => {
// Handle adding user
}
UserMessage::Clear => {
self.username.clear();
self.password.clear();
}
}
}
pub fn view(&self) -> Element<'_, Message> {
iced::widget::column![
text("Login Form"),
text_input("Enter Username", &self.username).on_input(|data| {
Message::Settings(SettingsMessage::User(UserMessage::UsernameChanged(data)))
}),
text_input("Enter Password", &self.password)
.secure(true)
.on_input(|data| {
Message::Settings(SettingsMessage::User(UserMessage::PasswordChanged(data)))
}),
row![
button(text("Add User")).on_press_maybe(self.validate()),
button(text("Cancel"))
.on_press(Message::Settings(SettingsMessage::User(UserMessage::Clear))),
]
.spacing(10),
]
.spacing(10)
.padding([10, 0])
.into()
}
pub fn validate(&self) -> Option<Message> {
(!self.username.is_empty() && !self.password.is_empty())
.then(|| Message::Settings(SettingsMessage::User(UserMessage::Add)))
}
}
#[derive(Debug, Clone, Default)]
pub struct ServerForm {
name: String,
url: String,
}
impl ServerForm {
pub fn update(&mut self, message: ServerMessage) {
match message {
ServerMessage::NameChanged(data) => {
self.name = data;
}
ServerMessage::UrlChanged(data) => {
self.url = data;
}
ServerMessage::Add => {
// Handle adding server
}
ServerMessage::Clear => {
self.name.clear();
self.url.clear();
}
_ => {}
}
}
pub fn view(&self) -> Element<'_, Message> {
iced::widget::column![
text("Add New Server"),
text_input("Enter server name", &self.name).on_input(|data| {
Message::Settings(SettingsMessage::Server(ServerMessage::NameChanged(data)))
}),
text_input("Enter server URL", &self.url).on_input(|data| {
Message::Settings(SettingsMessage::Server(ServerMessage::UrlChanged(data)))
}),
row![
button(text("Add Server")).on_press_maybe(self.validate()),
button(text("Cancel")).on_press(Message::Settings(SettingsMessage::Server(
ServerMessage::Clear
))),
]
.spacing(10),
]
.spacing(10)
.padding([10, 0])
.into()
}
pub fn validate(&self) -> Option<Message> {
(!self.name.is_empty() && !self.url.is_empty())
.then(|| Message::Settings(SettingsMessage::Server(ServerMessage::Add)))
}
} }
mod screens { mod screens {
@@ -261,6 +119,7 @@ mod screens {
Column::new() Column::new()
.push(text("Main Settings")) .push(text("Main Settings"))
.push(toggler(true).label("HDR")) .push(toggler(true).label("HDR"))
.push(toggler(true).label("Enable Notifications"))
.spacing(20) .spacing(20)
.padding(20) .padding(20)
.pipe(container) .pipe(container)
@@ -270,7 +129,7 @@ mod screens {
pub fn server(state: &State) -> Element<'_, Message> { pub fn server(state: &State) -> Element<'_, Message> {
Column::new() Column::new()
.push(text("Server Settings")) .push(text("Server Settings"))
.push(state.settings.server_form.view()) // .push(ServerPage::view(state))
.spacing(20) .spacing(20)
.padding(20) .padding(20)
.pipe(container) .pipe(container)
@@ -280,7 +139,7 @@ mod screens {
pub fn user(state: &State) -> Element<'_, Message> { pub fn user(state: &State) -> Element<'_, Message> {
Column::new() Column::new()
.push(text("User Settings")) .push(text("User Settings"))
.push(state.settings.login_form.view()) // .push(LoginForm::view(&state.settings.login_form))
.spacing(20) .spacing(20)
.padding(20) .padding(20)
.pipe(container) .pipe(container)