Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/binaries.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ jobs:
tool: cross

- name: Build
env:
MAL_CLIENT_ID: ${{ secrets.MAL_CLIENT_ID }}
run: cross build --release --target ${{ matrix.target }}

- name: Prepare artifacts
Expand Down
5 changes: 5 additions & 0 deletions Cross.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# `cross` runs the build inside a Docker container; host env vars are not
# visible there unless explicitly passed through. Forward the OAuth client id
# (set from a GitHub Actions secret in CI) so build.rs can read it.
[build.env]
passthrough = ["MAL_CLIENT_ID"]
38 changes: 38 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
use std::path::Path;

// Injects the MAL OAuth client id into the binary at compile time so it is not
// hard-coded in the source. Resolution order:
// 1. the `MAL_CLIENT_ID` environment variable (CI release builds set this
// from a GitHub Actions secret),
// 2. a `MAL_CLIENT_ID=...` line in a local, gitignored `.env` file.
//
// Note: a PKCE/native-app client id is a public identifier, not a true secret
// (it appears in the browser auth URL and can be extracted from any binary).
// This only keeps it out of the committed source, it does not make it private.
fn main() {
println!("cargo:rerun-if-env-changed=MAL_CLIENT_ID");
println!("cargo:rerun-if-changed=.env");

let client_id = std::env::var("MAL_CLIENT_ID")
.ok()
.filter(|s| !s.trim().is_empty())
.or_else(read_from_env_file)
.expect(
"MAL_CLIENT_ID is not set.\n\
Set the MAL_CLIENT_ID environment variable (release builds use a \
GitHub Actions secret), or add a line `MAL_CLIENT_ID=<your id>` to \
a local .env file (which is gitignored).",
);

println!("cargo:rustc-env=MAL_CLIENT_ID={}", client_id.trim());
}

fn read_from_env_file() -> Option<String> {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?;
let contents = std::fs::read_to_string(Path::new(&manifest_dir).join(".env")).ok()?;
contents.lines().find_map(|line| {
let value = line.trim().strip_prefix("MAL_CLIENT_ID=")?;
let value = value.trim().trim_matches('"').trim_matches('\'');
(!value.is_empty()).then(|| value.to_string())
})
}
230 changes: 230 additions & 0 deletions docs/superpowers/specs/2026-06-01-related-timeline-screen-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
# Related Timeline Screen — Design

**Date:** 2026-06-01
**Status:** Approved (design), pending implementation plan

## Summary

Add a new top-level screen, **"Related"**, to the mal-tui app. The user types an
anime title, picks from the search matches, and the screen builds a horizontal
**watch-order timeline**: prequels and sequels laid out left-to-right as the
main line, with other relation types (side story, alternative version, spin-off,
summary, etc.) shown as branches off the selected node. Selecting any node opens
the existing anime detail popup.

## Goals

- Let a user explore how an anime relates to its prequels/sequels/side stories.
- Present the prequel→sequel chain as an intuitive left-to-right watch order.
- Reuse existing infrastructure (search, anime store, popup overlay, navbar,
screen caching, image manager) rather than building parallel systems.

## Non-goals

- Manga relations (`related_manga`) — out of scope for this screen.
- Editing/managing relations (data is read-only from MAL).
- Re-rooting the timeline by selecting a node (selected nodes open the popup
instead). Could be a future enhancement.

## User flow

1. User opens the **Related** screen from the navbar.
2. **Search mode:** user types a title into a search input and submits.
3. **Picking mode:** the screen shows matching anime from MAL search; user
selects one as the timeline root.
4. **Timeline mode:** the screen builds and displays the watch-order timeline
for the chosen anime.
5. Selecting a node (on the line or in a branch) opens the existing anime popup
(full details, synopsis, score, play, add-to-list).

## Architecture

### New file

- `src/screens/related.rs` — `RelatedScreen` struct implementing the `Screen`
trait.
- Registered in the `define_screens!` macro in `src/screens/mod.rs`:
`RELATED => "Related" => related::RelatedScreen`.
- Added to the navbar via `NavBar::add_screen(screens::RELATED)` wherever the
other screens are registered.
- Participates in screen caching via the `add_screen_caching!` macro, matching
the other screens.

### Reused components

- `crate::utils::input::Input` — the search text box.
- Shared `anime_store` (keyed by `AnimeId`) from `ExtraInfo` — every fetched
anime (root, chain nodes, branch nodes) is inserted here so the popup overlay
can find it.
- `Action::ShowOverlay(AnimeId)` — emitted on node selection to open the shared
anime popup (handled in `app.rs`).
- Background-thread pattern from `search.rs` / `popup.rs` — a worker thread with
a `Sender<LocalEvent>` and results delivered back through the app event
channel.
- `ImageManager` — optional small thumbnails on timeline nodes (may be deferred
if it complicates the strip layout; not required for v1).

### New MAL client method

`MalClient::get_anime_by_id(&self, id: u64) -> Option<Anime>`

- Fetches a single anime with the full field set: `GET {BASE_URL}/anime/{id}?fields=…`.
- The single-anime endpoint returns one anime **object**, not the
`{ data: [...], paging }` list shape that `AnimeResponse` expects. This needs a
fetch/deserialize path that decodes `Anime` directly (a small addition next to
the existing `send_request` / `fetch_anime` plumbing).
- Used to follow the relation chain: each `related_anime` entry only carries
`{ id, title, main_picture }`, so to learn a node's own relations the node must
be re-fetched by id.

## Screen modes

A `Mode` enum drives rendering and input:

- `Search` — typing a query.
- `Picking` — choosing from search results.
- `Timeline` — viewing the built timeline (with a "building" sub-state while the
chain is still being fetched).

Focus interacts with the navbar following the existing pattern (`NavbarSelect`).

## Background work

A worker thread owns a `Receiver<LocalEvent>`; the screen holds the `Sender`.

- `LocalEvent::Search(query)`:
- Calls `mal_client.search_anime(query, 0, limit)`.
- Inserts results into the shared store; delivers the list of `AnimeId`s back
to the screen (via the app channel / `BackgroundUpdate`).
- Screen transitions to `Picking`.

- `LocalEvent::Build(root_id)`:
- Builds the timeline (algorithm below).
- Inserts every fetched main-line anime into the shared store.
- Delivers the assembled `Timeline` structure back to the screen.
- Screen transitions to `Timeline`.

- `LocalEvent::FetchBranch(id)`:
- Just-in-time `get_anime_by_id(id)` for a selected branch node not yet in the
store; inserts the full anime into the shared store and signals the screen so
it can emit `Action::ShowOverlay(id)`.

### Timeline-building algorithm

```
visited: HashSet<u64>
CAP = 25 nodes

fetch root (already have it from search; ensure full fields)
mark root visited

# walk backward (prequels) -> prepend to line
node = root
loop:
next = first relation of node where relation_type == "prequel"
if none or next.id in visited or line.len() >= CAP: break
fetch next by id; mark visited; prepend to line; node = next

# walk forward (sequels) -> append to line
node = root
loop:
next = first relation of node where relation_type == "sequel"
if none or next.id in visited or line.len() >= CAP: break
fetch next by id; mark visited; append to line; node = next

# branches: for each node on the line, collect its non-prequel/sequel relations
for each line_node:
branches[line_node.id] = [
(relation_type_formatted, related) # `related` is the related_anime Node
for rel in line_node.related_anime
if rel.relation_type not in {"prequel", "sequel"}
OR rel is an extra prequel/sequel beyond the first (the chosen line)
]
# branches are NOT fetched or recursed during build.
```

Notes:
- Multiple prequels or sequels on one node: the **first** continues the main
line; the rest become branches on that node.
- `visited` prevents infinite loops from bidirectional/cyclic relations.
- `CAP` (25) bounds API calls and screen width; if hit, the UI shows a
"…more not shown" marker.

### Fetch policy (which nodes get full data, when)

- **Main-line nodes** are fully fetched during build (required to follow the
chain), so they are in the shared store with complete details. Selecting one
emits `Action::ShowOverlay(id)` directly.
- **Branch nodes** are *not* fetched during build — only the `related_anime`
Node data (`id`, `title`, `main_picture`) is kept, which is enough to display
the branch label and title. This avoids paying for branches the user never
opens. When the user selects a branch, the worker performs a just-in-time
`get_anime_by_id(id)`, inserts the full anime into the shared store, and then
the screen emits `Action::ShowOverlay(id)`. The detail panel therefore shows a
score for main-line nodes and title-only for not-yet-opened branches.

## Data model

```rust
struct Timeline {
root: u64,
line: Vec<AnimeId>, // chronological: prequel..root..sequel (full anime in store)
branches: HashMap<u64, Vec<BranchEntry>>, // per line-node id -> its branch relations
}

struct BranchEntry {
relation_label: String, // relation_type_formatted, e.g. "Side story"
id: u64, // related anime id (fetched just-in-time on select)
title: String, // from the related_anime Node (for display)
}
```

The screen also tracks: current `Mode`, the search `Input`, the picking result
list, the selected line index, the selected branch index (when focus is in the
branch panel), and a horizontal scroll offset for the strip.

## Layout (Timeline mode)

- **Top:** search bar showing the current query (re-searchable).
- **Middle:** horizontal, scrollable strip of node boxes joined by `──`
connectors. The selected node is highlighted; `◀`/`▶` indicate off-screen
nodes. Each box shows the (truncated) title and score.
- **Bottom:** a detail panel for the currently selected node — title, type,
episodes, status, score — plus a list of that node's branch relations, each
selectable.

**Picking mode** reuses the top search bar and shows a simple selectable list of
matches (title + year/type/episodes).

### Input

- `←` / `→` — move selection along the main line (scrolls the strip).
- `↑` / `↓` — move focus into / out of the branch list of the selected node.
- `Enter` — open the popup for the selected node or branch entry
(`Action::ShowOverlay(id)`).
- Search bar focus follows the existing input + navbar conventions.

## Error handling & edge cases

- **No search results:** show an inline message; stay in Search/Picking.
- **Anime with no relations:** timeline is a single node (just the root).
- **Cycles / bidirectional links:** guarded by the `visited` set.
- **Cap reached:** show a "…more not shown" marker at the truncated end.
- **Fetch failure mid-build:** keep the partial timeline already assembled and
surface a soft error (e.g. via `Action::ShowError` or an inline notice) rather
than discarding everything.
- **Building state:** while the chain is still fetching, show a "Building…"
indicator; the strip can fill in progressively or appear once complete
(progressive is preferred if cheap, otherwise deliver once complete).

## Testing

- Unit-test the timeline-building algorithm with synthetic `Anime` graphs:
- linear prequel/sequel chain → correct ordered `line`.
- branching (multiple sequels) → first on line, extras in `branches`.
- cyclic relations → terminates, no duplicates.
- cap enforcement → stops at `CAP`, marks truncation.
- non-sequel/prequel relations → land in `branches`, not the line.
- Manual/integration: run the app, search a multi-season franchise (e.g. Attack
on Titan), verify the ordered line, branch listing, scrolling, and that
selecting nodes opens the popup with correct details.
4 changes: 4 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub enum Action {
PlayEpisode(AnimeId, u32),
SwitchScreen(&'static str),
ShowOverlay(AnimeId),
ShowRelated(AnimeId),
NavbarSelect(bool),
ShowError(String),
SyncAnimes(Vec<(bool, Anime)>),
Expand Down Expand Up @@ -324,6 +325,9 @@ impl App {
Action::ShowOverlay(anime_id) => {
self.screen_manager.toggle_overlay(anime_id);
}
Action::ShowRelated(anime_id) => {
self.screen_manager.show_related(anime_id);
}
Action::NavbarSelect(selected) => {
self.screen_manager.toggle_navbar(selected);
}
Expand Down
2 changes: 2 additions & 0 deletions src/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ To reset to default settings, simply delete the config file. mal-tui will automa

```toml
allow_nsfw = false
# show titles in romaji (MAL's main title) instead of English
prefer_romaji = false

[navigation]
nav_up = ["Up", { Char = "k" }]
Expand Down
9 changes: 9 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ pub struct Config {
pub theme: Theme,

pub allow_nsfw: Option<bool>,

/// Show titles in romaji (MAL's main title) instead of English.
pub prefer_romaji: Option<bool>,
}

impl Config {
Expand All @@ -51,9 +54,15 @@ impl Config {
player: Player::default(),
theme: Theme::default(),
allow_nsfw: Some(true),
prefer_romaji: Some(false),
}
}

/// Whether titles should be shown in romaji instead of English.
pub fn prefer_romaji(&self) -> bool {
self.prefer_romaji.unwrap_or(false)
}


// where configs are stored (default)
pub fn config_dir() -> PathBuf {
Expand Down
40 changes: 40 additions & 0 deletions src/mal/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,46 @@ impl MalClient {
)
}

// Fetch a single anime by id with the full field set. Unlike the list
// endpoints this returns one anime object, so it goes through
// `fetch_single_anime` rather than the `Fetchable`/list path.
pub fn get_anime_by_id(&self, id: u64) -> Option<Anime> {
let identity = self.identity.read().unwrap();
let identifier = match identity.as_ref() {
Some(token) => Identifier::new(Some(token.access_token.clone()), None),
None => Identifier::new(None, self.get_client_id()),
};

// The timeline walks many anime sequentially, so keep each response
// small: drop the bulkiest fields it never shows (recommendations embeds
// whole anime objects; pictures is a gallery; statistics/background are
// unused here). Everything the boxes and the popup read is kept.
let heavy = [
fields::STATISTICS,
fields::RECOMMENDATIONS,
fields::PICTURES,
fields::BACKGROUND,
];
let trimmed_fields = fields::ALL
.iter()
.filter(|f| !heavy.contains(f))
.copied()
.collect::<Vec<_>>()
.join(",");

match network::fetch_single_anime(
identifier,
format!("{}/anime/{}", BASE_URL, id),
params!["fields" => trimmed_fields],
) {
Ok(anime) => Some(anime),
Err(e) => {
send_error!("Error fetching anime {}: {:?}", id, e);
None
}
}
}

pub fn get_user(&self) -> Option<User> {
self.send_request::<User>(
format!("{}/users/@me", BASE_URL),
Expand Down
Loading