diff --git a/scripts/jellyfin/client.gd b/scripts/jellyfin/client.gd index 10f3f5e..937fb2d 100644 --- a/scripts/jellyfin/client.gd +++ b/scripts/jellyfin/client.gd @@ -94,6 +94,12 @@ func resume_items() -> Dictionary: func details(id: String) -> Dictionary: return await request("/Users/%s/Items/%s" % [user_id, id.uri_encode()]) +func seasons(series_id: String) -> Dictionary: + return await request("/Shows/%s/Seasons" % series_id.uri_encode() + query({"UserId": user_id, "Fields": "Overview,UserData", "EnableUserData": "true"})) + +func episodes(series_id: String, season_id: String, offset: int = 0) -> Dictionary: + return await request("/Shows/%s/Episodes" % series_id.uri_encode() + query({"UserId": user_id, "SeasonId": season_id, "StartIndex": offset, "Limit": 100, "Fields": "Overview,UserData", "EnableUserData": "true", "IsMissing": "false"})) + func image_url(item: Dictionary, backdrop: bool = false) -> String: var kind := "Backdrop/0" if backdrop else "Primary" var tags: Array = item.get("BackdropImageTags", []) if item.get("BackdropImageTags") else [] diff --git a/scripts/main.gd b/scripts/main.gd index 4262bad..f4add6b 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -240,11 +240,6 @@ func _details(item: Dictionary) -> void: view.back_requested.connect(_show_library) page.add_child(view) status.text = "Ready." - if detail.get("Type", "") == "Series": - var episodes := await api.request("/Shows/%s/Episodes" % detail.Id + api.query({"UserId": api.user_id, "Limit": 100, "Fields": "UserData"})) - if not is_instance_valid(view) or not view.is_inside_tree(): return - view.set_episodes(episodes.data.get("Items", []) if episodes.ok else [], "Could not load episodes. Return to the library and try again." if not episodes.ok else "") - if not episodes.ok: status.text = episodes.error func _start(item: Dictionary) -> void: if not video.available(): diff --git a/scripts/ui/details.gd b/scripts/ui/details.gd index fe610d6..b35651e 100644 --- a/scripts/ui/details.gd +++ b/scripts/ui/details.gd @@ -9,7 +9,7 @@ var api: JellyfinClient var artwork: ArtworkLoader var scroll: ScrollContainer var body: VBoxContainer -var episode_list: VBoxContainer +var series_browser: SeriesBrowser var play_button: Button var back_button: Button var title_label: Label @@ -38,11 +38,13 @@ func _ready() -> void: _build_header() _build_information() if item.get("Type", "") == "Series": - Widgets.label("Episodes", body, 28) - episode_list = VBoxContainer.new() - episode_list.add_theme_constant_override("separation", 12) - body.add_child(episode_list) - Widgets.label("Loading episodes...", episode_list, 22) + series_browser = SeriesBrowser.new() + series_browser.api = api + series_browser.artwork = artwork + series_browser.series_id = str(item.Id) + series_browser.play_requested.connect(func(episode: Dictionary): play_requested.emit(episode)) + series_browser.section_changed.connect(_scroll_to_series) + body.add_child(series_browser) _build_people() func _build_header() -> void: @@ -103,7 +105,6 @@ func _build_header() -> void: func _build_playback(parent: VBoxContainer) -> void: if item.get("Type", "") == "Series": - _muted("Choose an episode below to start watching.", parent) return var user: Dictionary = item.get("UserData", {}) if item.get("UserData") else {} var position := int(user.get("PlaybackPositionTicks", 0)) @@ -162,19 +163,6 @@ func _build_people() -> void: var role := str(person.get("Role") if person.get("Role") else person.get("Type", "")) _wrapped(str(person.Name) + (" / " + role if not role.is_empty() else ""), names, 22) -func set_episodes(items: Array, error: String = "") -> void: - for child in episode_list.get_children(): - episode_list.remove_child(child) - child.queue_free() - if not error.is_empty(): - _wrapped(error, episode_list) - return - if items.is_empty(): - _muted("No episodes available.", episode_list) - for episode in items: - var button := Widgets.button("S%s E%s %s" % [episode.get("ParentIndexNumber", "?"), episode.get("IndexNumber", "?"), episode.get("Name", "Episode")], func(): play_requested.emit(episode), episode_list) - button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - func _image(parent: Node) -> TextureRect: var image := TextureRect.new() image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE @@ -183,6 +171,11 @@ func _image(parent: Node) -> TextureRect: parent.add_child(image) return image +func _scroll_to_series() -> void: + await get_tree().process_frame + await get_tree().process_frame + if is_inside_tree(): scroll.scroll_vertical = int(series_browser.position.y) + func _wrapped(text: String, parent: Node, font_size: int = 22) -> Label: var label := Widgets.label(text, parent, font_size) label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART diff --git a/scripts/ui/episode_card.gd b/scripts/ui/episode_card.gd new file mode 100644 index 0000000..11bf595 --- /dev/null +++ b/scripts/ui/episode_card.gd @@ -0,0 +1,59 @@ +class_name EpisodeCard +extends PanelContainer + +signal chosen(item: Dictionary) + +var item: Dictionary +var artwork: ArtworkLoader +var play_button: Button +var thumbnail: TextureRect +var title_label: Label + +func _ready() -> void: + add_theme_stylebox_override("panel", Widgets.surface(Color("162133"), 14, 18)) + var row := BoxContainer.new() + row.add_theme_constant_override("separation", 22) + add_child(row) + row.vertical = size.x < 740 + resized.connect(func(): row.vertical = size.x < 740) + var image_column := VBoxContainer.new() + image_column.add_theme_constant_override("separation", 12) + image_column.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + image_column.size_flags_vertical = Control.SIZE_SHRINK_BEGIN + row.add_child(image_column) + var frame := PanelContainer.new() + frame.custom_minimum_size = Vector2(240, 135) + frame.size_flags_vertical = Control.SIZE_SHRINK_BEGIN + frame.add_theme_stylebox_override("panel", Widgets.surface(Color("25334a"), 10)) + image_column.add_child(frame) + var fallback := Widgets.label("No preview available", frame, 20) + fallback.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + fallback.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + thumbnail = TextureRect.new() + thumbnail.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + thumbnail.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + thumbnail.mouse_filter = Control.MOUSE_FILTER_IGNORE + frame.add_child(thumbnail) + artwork.fetch(thumbnail, item) + var text := VBoxContainer.new() + text.size_flags_horizontal = Control.SIZE_EXPAND_FILL + text.add_theme_constant_override("separation", 12) + row.add_child(text) + title_label = _label("%s. %s" % [item.get("IndexNumber", "?"), item.get("Name", "Episode")], text, 26) + var facts := DetailsMetadata.facts(item) + var user: Dictionary = item.get("UserData", {}) if item.get("UserData") else {} + var position := int(user.get("PlaybackPositionTicks", 0)) + if position > 0: facts.append("Continue from " + DetailsMetadata.duration(position)) + if not facts.is_empty(): + _label(" / ".join(facts), text, 20).add_theme_color_override("font_color", Color("b0bbce")) + _label(str(item.get("Overview") if item.get("Overview") else "No description available."), text, 22) + play_button = Widgets.button("Resume episode" if position > 0 else "Play episode", func(): chosen.emit(item), image_column) + play_button.custom_minimum_size = Vector2(190, 56) + play_button.accessibility_name = play_button.text + " " + str(item.get("Name", "Episode")) + +func _label(value: String, parent: Node, font_size: int) -> Label: + var label := Widgets.label(value, parent, font_size) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_constant_override("line_spacing", 5) + return label diff --git a/scripts/ui/episode_card.gd.uid b/scripts/ui/episode_card.gd.uid new file mode 100644 index 0000000..ca938c9 --- /dev/null +++ b/scripts/ui/episode_card.gd.uid @@ -0,0 +1 @@ +uid://oxidgmg6k3fd diff --git a/scripts/ui/series_browser.gd b/scripts/ui/series_browser.gd new file mode 100644 index 0000000..afaf057 --- /dev/null +++ b/scripts/ui/series_browser.gd @@ -0,0 +1,121 @@ +class_name SeriesBrowser +extends VBoxContainer + +signal play_requested(item: Dictionary) +signal section_changed + +var api: JellyfinClient +var artwork: ArtworkLoader +var series_id := "" +var season_grid: HFlowContainer +var season_section: VBoxContainer +var episode_section: VBoxContainer +var episode_list: VBoxContainer +var episode_heading: Label +var season_description: Label +var all_seasons_button: Button +var generation := 0 + +func _ready() -> void: + add_theme_constant_override("separation", 20) + season_section = VBoxContainer.new() + season_section.add_theme_constant_override("separation", 18) + add_child(season_section) + Widgets.label("Seasons", season_section, 28) + season_grid = HFlowContainer.new() + season_grid.add_theme_constant_override("h_separation", 18) + season_grid.add_theme_constant_override("v_separation", 18) + season_section.add_child(season_grid) + episode_section = VBoxContainer.new() + episode_section.add_theme_constant_override("separation", 18) + add_child(episode_section) + var navigation := HBoxContainer.new() + episode_section.add_child(navigation) + all_seasons_button = Widgets.button("All seasons", _show_seasons, navigation) + episode_heading = _label("", episode_section, 28) + season_description = _label("", episode_section, 22) + episode_list = VBoxContainer.new() + episode_list.add_theme_constant_override("separation", 16) + episode_section.add_child(episode_list) + episode_section.hide() + load_seasons() + +func load_seasons() -> void: + generation += 1 + var current := generation + _clear(season_grid) + _label("Loading seasons...", season_grid, 22) + var result := await api.seasons(series_id) + if not _current(current): return + _clear(season_grid) + if not result.ok: + _label("Could not load seasons.", season_grid, 22) + Widgets.button("Retry seasons", load_seasons, season_grid) + return + var seasons: Array = result.data.get("Items", []) + if seasons.is_empty(): + _label("No seasons available.", season_grid, 22) + for season in seasons: + var card := MediaCard.new() + card.item = season + card.artwork = artwork + card.chosen.connect(open_season) + season_grid.add_child(card) + +func open_season(season: Dictionary) -> void: + generation += 1 + var current := generation + season_section.hide() + episode_section.show() + all_seasons_button.grab_focus() + episode_heading.text = str(season.get("Name", "Season")) + " episodes" + season_description.text = str(season.get("Overview") if season.get("Overview") else "") + season_description.visible = not season_description.text.is_empty() + _clear(episode_list) + _label("Loading episodes...", episode_list, 22) + section_changed.emit() + var items: Array = [] + while true: + var result := await api.episodes(series_id, str(season.Id), items.size()) + if not _current(current): return + if not result.ok: + _clear(episode_list) + _label("Could not load episodes.", episode_list, 22) + Widgets.button("Retry episodes", open_season.bind(season), episode_list) + section_changed.emit() + return + var batch: Array = result.data.get("Items", []) + items.append_array(batch) + if batch.is_empty() or items.size() >= int(result.data.get("TotalRecordCount", items.size())): break + _clear(episode_list) + if items.is_empty(): _label("No episodes available in this season.", episode_list, 22) + for episode in items: + var card := EpisodeCard.new() + card.item = episode + card.artwork = artwork + card.chosen.connect(func(chosen: Dictionary): play_requested.emit(chosen)) + episode_list.add_child(card) + section_changed.emit() + +func _show_seasons() -> void: + generation += 1 + episode_section.hide() + season_section.show() + section_changed.emit() + if season_grid.get_child_count() > 0: + var first := season_grid.get_child(0) as Control + if first and first.focus_mode != Control.FOCUS_NONE: first.grab_focus() + +func _current(current: int) -> bool: + return current == generation and is_inside_tree() + +func _clear(parent: Node) -> void: + for child in parent.get_children(): + parent.remove_child(child) + child.queue_free() + +func _label(value: String, parent: Node, font_size: int) -> Label: + var label := Widgets.label(value, parent, font_size) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + return label diff --git a/scripts/ui/series_browser.gd.uid b/scripts/ui/series_browser.gd.uid new file mode 100644 index 0000000..36885a6 --- /dev/null +++ b/scripts/ui/series_browser.gd.uid @@ -0,0 +1 @@ +uid://cct22i6ylma4e diff --git a/tests/test_details.gd b/tests/test_details.gd index 22da878..cc81771 100644 --- a/tests/test_details.gd +++ b/tests/test_details.gd @@ -1,5 +1,9 @@ extends SceneTree +class FixtureApi extends JellyfinClient: + func seasons(_series_id: String) -> Dictionary: + return {"ok": true, "data": {"Items": []}} + var failures := 0 var selected: Array = [] var back_count := 0 @@ -33,7 +37,7 @@ func show_details(shell: UiShell, item: Dictionary) -> DetailsView: shell.page.remove_child(child) child.queue_free() var view := DetailsView.new() - view.api = JellyfinClient.new() + view.api = FixtureApi.new() view.add_child(view.api) view.item = item view.size_flags_vertical = Control.SIZE_EXPAND_FILL @@ -81,11 +85,7 @@ func run_checks() -> void: view = show_details(shell, {"Id": "series", "Name": "Example series", "Type": "Series"}) await settle() check(view.play_button == null, "Series must select episodes instead of playing the series itself.") - var episode := {"Id": "episode", "Name": "Pilot", "ParentIndexNumber": 1, "IndexNumber": 1} - view.set_episodes([episode]) - view.episode_list.get_child(0).pressed.emit() - check(selected.back() == episode, "Episode selection must open the chosen episode.") - view.set_episodes([], "Could not load episodes.") - check(view.episode_list.get_child(0).text == "Could not load episodes.", "Episode failures must remain visible.") + check(view.series_browser != null, "Series must show a season browser.") + check(view.series_browser.season_grid.get_child(0).text == "No seasons available.", "An empty series must have a readable fallback.") print("Details checks: ", "PASS" if failures == 0 else str(failures) + " failures") quit(1 if failures else 0) diff --git a/tests/test_http.gd b/tests/test_http.gd index 9e47c83..c3a26e3 100644 --- a/tests/test_http.gd +++ b/tests/test_http.gd @@ -58,6 +58,11 @@ func run_checks() -> void: check("ParentId=movies-id" in last_request and "StartIndex=48" in last_request and "SearchTerm=space" in last_request, "Search and pagination stay inside the selected library") await api.views() check(last_request.begins_with("GET /jellyfin/UserViews?") and "UserId=fixture-user" in last_request, "Sidebar loads the signed-in user's libraries") + await api.seasons("show id") + check(last_request.begins_with("GET /jellyfin/Shows/show%20id/Seasons?"), "Seasons load from the selected series with an escaped ID") + await api.episodes("show id", "season & id", 100) + check("SeasonId=season%20%26%20id" in last_request and "StartIndex=100" in last_request, "Episode pagination stays inside the selected season") + check("Fields=Overview%2CUserData" in last_request and "IsMissing=false" in last_request, "Episode cards request summaries and exclude missing episodes") code = 401 payload = "" result = await api.request("/Users/Me") diff --git a/tests/test_series.gd b/tests/test_series.gd new file mode 100644 index 0000000..2ab3f0b --- /dev/null +++ b/tests/test_series.gd @@ -0,0 +1,114 @@ +extends SceneTree + +class FixtureApi extends JellyfinClient: + var calls: Array = [] + var fail_seasons := false + var fail_episodes := false + var empty_seasons := false + var empty_episodes := false + var season_items := [{"Id": "one", "Name": "Season 1", "IndexNumber": 1}, {"Id": "two", "Name": "Season 2", "IndexNumber": 2}] + + func seasons(_series_id: String) -> Dictionary: + await get_tree().process_frame + if fail_seasons: return {"ok": false, "error": "Offline"} + return {"ok": true, "data": {"Items": [] if empty_seasons else season_items}} + + func episodes(series_id: String, season_id: String, offset: int = 0) -> Dictionary: + calls.append([series_id, season_id, offset]) + await get_tree().process_frame + await get_tree().process_frame + if fail_episodes: return {"ok": false, "error": "Offline"} + var items := [] + if not empty_episodes: + for index in range(offset, mini(offset + 2, 4)): + items.append({"Id": season_id + str(index), "Name": ["Seekest Thou the Road", "Circle Sewn with Fate", "Through the trials", "The final chapter"][index], "IndexNumber": index + 1, "RunTimeTicks": 24000000000, "CommunityRating": 7.0, "Overview": "Agatha and her companions face another trial on the Witches' Road.", "UserData": {"PlaybackPositionTicks": 3000000000 if index == 0 else 0, "Played": index == 1}}) + return {"ok": true, "data": {"Items": items, "TotalRecordCount": 0 if empty_episodes else 4}} + +var failures := 0 +var selected: Array = [] + +func _initialize() -> void: + call_deferred("run_checks") + +func settle() -> void: + for frame in range(12): await process_frame + +func check(condition: bool, message: String) -> void: + if not condition: + failures += 1 + push_error(message) + +func capture(path: String) -> void: + if "--capture" not in OS.get_cmdline_user_args(): return + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png(path) + +func run_checks() -> void: + root.size = Vector2i(1536, 960) + var shell := UiShell.new() + root.add_child(shell) + shell.set_signed_in(true) + shell.select("tv", "TV shows") + shell.set_libraries([{"Id": "movies", "Name": "Movies", "CollectionType": "movies"}, {"Id": "tv", "Name": "TV shows", "CollectionType": "tvshows"}]) + var api := FixtureApi.new() + root.add_child(api) + var view := DetailsView.new() + view.api = api + view.item = {"Id": "agatha", "Name": "Agatha All Along", "Type": "Series", "ProductionYear": 2024, "OfficialRating": "NL-12", "CommunityRating": 7.2, "Genres": ["Action", "Adventure", "Comedy"], "Overview": "Agatha sets out on the Witches' Road with a new coven to reclaim her powers.", "Studios": [{"Name": "Disney+"}]} + view.size_flags_vertical = Control.SIZE_EXPAND_FILL + view.play_requested.connect(func(item: Dictionary): selected.append(item)) + shell.page.add_child(view) + await settle() + var browser := view.series_browser + check(browser.season_grid.get_child_count() == 2, "Series must show each season as a poster card.") + view.scroll.ensure_control_visible(browser.season_section) + await settle() + await capture("res://dist/series-preview.png") + browser.season_grid.get_child(0).pressed.emit() + await settle() + check(api.calls == [["agatha", "one", 0], ["agatha", "one", 2]], "All episode pages must stay in the chosen season.") + check(browser.episode_list.get_child_count() == 4, "Every episode page must appear in the list.") + var first: EpisodeCard = browser.episode_list.get_child(0) + check(first.play_button.text == "Resume episode", "Started episodes must offer Resume.") + first.play_button.pressed.emit() + check(selected.back().Id == "one0", "Playback must receive the selected episode, not the series or season.") + check(browser.episode_list.get_child(1).play_button.text == "Play episode", "Unstarted episodes must offer Play.") + await capture("res://dist/episodes-preview.png") + for width in [1280, 960]: + root.size.x = width + await settle() + check(first.size.x <= view.scroll.size.x, "Episode cards must fit the available panel width.") + check(first.title_label.get_global_rect().end.x <= view.scroll.get_global_rect().end.x, "Episode text must remain inside the panel.") + root.size.x = 1536 + browser.open_season(api.season_items[0]) + browser.all_seasons_button.pressed.emit() + browser.open_season(api.season_items[1]) + await settle() + check(browser.episode_list.get_child(0).item.Id == "two0", "Changing season must ignore an older episode response.") + api.fail_episodes = true + browser.open_season(api.season_items[0]) + await settle() + check(browser.episode_list.get_child(0).text == "Could not load episodes.", "Episode errors must be visible.") + api.fail_episodes = false + browser.episode_list.get_child(1).pressed.emit() + await settle() + check(browser.episode_list.get_child_count() == 4, "Retry must restore the selected season's episodes.") + api.empty_episodes = true + browser.open_season(api.season_items[1]) + await settle() + check(browser.episode_list.get_child(0).text == "No episodes available in this season.", "Empty seasons must have a readable message.") + browser.all_seasons_button.pressed.emit() + api.fail_seasons = true + browser.load_seasons() + await settle() + check(browser.season_grid.get_child(0).text == "Could not load seasons.", "Season errors must offer recovery.") + api.fail_seasons = false + browser.season_grid.get_child(1).pressed.emit() + await settle() + check(browser.season_grid.get_child_count() == 2, "Retry must restore season selection.") + api.empty_seasons = true + browser.load_seasons() + await settle() + check(browser.season_grid.get_child(0).text == "No seasons available.", "Empty series must have a readable message.") + print("Series checks: ", "PASS" if failures == 0 else str(failures) + " failures") + quit(1 if failures else 0) diff --git a/tests/test_series.gd.uid b/tests/test_series.gd.uid new file mode 100644 index 0000000..a639d4d --- /dev/null +++ b/tests/test_series.gd.uid @@ -0,0 +1 @@ +uid://brq5jgygov6ld