9 Commits
21 changed files with 195 additions and 38 deletions

No files matched your search

+4 -2
View File
@@ -1,8 +1,10 @@
# Continuous integration. Manual only while the repository is private (docs/release.md, step 4: add push and
# pull_request when it becomes public).
# Continuous integration: the Linux tests (debug and ASan), a short fuzz run and the PS4 package.
name: ci
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
+4
View File
@@ -1,5 +1,9 @@
# Changelog
## 1.0.1 (2026-10-17)
- Home screen icon: the halo above "Playfin." in Jellyfin's purple-to-blue gradient.
## 1.0.0 (2026-10-17)
- Servers: several servers and several users per server, found on the network or added by address; sign-in with
+1 -1
View File
@@ -8,7 +8,7 @@ if(PLATFORM_PS4 AND NOT CMAKE_TOOLCHAIN_FILE)
set(CMAKE_TOOLCHAIN_FILE "$ENV{OPENORBIS}/cmake/ps4.cmake" CACHE FILEPATH "")
endif()
project(Playfin VERSION 1.0.0 LANGUAGES C CXX)
project(Playfin VERSION 1.0.1 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
+13 -3
View File
@@ -1,7 +1,17 @@
# Playfin
<h1>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/images/logo-on-dark.png">
<img alt="Playfin" src="docs/images/logo-on-light.png" width="300">
</picture>
</h1>
A Jellyfin client for the PlayStation 4, for consoles running homebrew (GoldHEN).
<a href="https://github.com/LowganDev/PlayfinPS4/releases/latest/download/IV0001-PLFN00000_00-PLFN000000100000.pkg"><img alt="Download for PS4" src="docs/images/download.png" width="281"></a>
The latest version's package; earlier versions are on the [Releases](https://github.com/LowganDev/PlayfinPS4/releases)
page.
## What it does
@@ -30,8 +40,8 @@ A Jellyfin client for the PlayStation 4, for consoles running homebrew (GoldHEN)
## Installing
1. Copy `IV0001-PLFN00000_00-PLFN000000100000.pkg` to the console (USB drive or FTP) and install it from
GoldHEN → Debug Settings → Package Installer.
1. Download `IV0001-PLFN00000_00-PLFN000000100000.pkg` with the button at the top, copy it to the console (USB
drive or FTP) and install it from GoldHEN → Debug Settings → Package Installer.
2. Start Playfin from the home screen. Servers on your network are found automatically; others are added by
address (for example `192.168.1.20:8096` or `https://media.example.com`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

+6 -7
View File
@@ -31,14 +31,13 @@ The checklist for a release; release tags are the version number without "v" (`1
1. Commit "build: Playfin x.y.z", tag `x.y.z`, push the tag.
2. GitHub release "Playfin x.y.z" on the tag, with:
- `IV0001-PLFN00000_00-PLFN000000100000.pkg`;
- the dependencies' source: an archive of the upstream tarballs named in `scripts/ps4/*/PKGBUILD` (and the
pacbrew image's libraries: FriBidi, zlib, libsamplerate) with the patches, and the OpenOrbis toolchain's
source at the version in the pacbrew image (GPL-3.0; its `crt1.o`, `crti.o` and `crtn.o` are linked into
`eboot.bin`), so that the GPL and LGPL components' corresponding source is offered with the binary;
- `IV0001-PLFN00000_00-PLFN000000100000.pkg` (this exact name: the README's download button links to
`releases/latest/download/` with it, and "latest" skips pre-releases);
- the corresponding source, `dist/playfin-x.y.z-sources.tar`, made by `scripts/ps4/source-archive.sh` (run at
the release's commit): Playfin, every recipe of `scripts/ps4` with its patches and upstream sources, and the
build image's FriBidi, zlib, libsamplerate and OpenOrbis toolchain (GPL-3.0: crt and `link.x`) with their
PacBrew recipes, all checked against pinned checksums;
- the changelog section as notes.
3. For the first public release: make the repository public, then switch CI on (add `push` and `pull_request` to
`on:` in `.github/workflows/ci.yml`; it runs on `workflow_dispatch` only until then).
## Dependency packages
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 58 KiB

+4 -1
View File
@@ -5,7 +5,10 @@
set -euo pipefail
elf=$1 eboot=$2
bad=$(readelf -d "$elf" | grep NEEDED | grep '/' || true)
if [ -n "$bad" ] || strings "$eboot" | grep -q '^/opt/'; then
# a library path left in eboot.bin (whole output read: grep -q would stop strings early, and pipefail would hide the
# match); other /opt/ strings, such as mpv's default configuration folder, are not imports
bad+=$(strings "$eboot" | grep -E '^/opt/.*\.(so|prx|sprx)$' || true)
if [ -n "$bad" ]; then
echo "PS4 imports must be bare module names:" >&2
echo "$bad" >&2
exit 1
+61 -12
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env python3
"""Makes the PS4 home screen art of Playfin from the shipped Sora font (needs Pillow).
"""Makes the PS4 home screen art and the README logo of Playfin from the shipped Sora font (needs Pillow).
python3 scripts/make-ps4-art.py
icon0.png (512x512): "Playfin." under a blue halo. pic1.png (1920x1080): the app's background with the halo and the
logo, shown by the PS4 behind the selected app. Colours from the theme (src/ui/theme.cpp).
icon0.png (512x512): "Playfin." under a halo in Jellyfin's purple-to-blue gradient. pic1.png (1920x1080): the same
halo and logo, shown by the PS4 behind the selected app. Jellyfin's branding guide forbids its logo inside another
logo without permission and allows its gradient on a shape of our own: only the gradient is used. docs/images/logo-on-dark.png and logo-on-light.png: the "Playfin."
wordmark on a transparent background for the README (GitHub's dark and light themes). docs/images/download.png: the
README's download button. Colours from the theme (src/ui/theme.cpp).
"""
import math
import os
@@ -16,11 +19,13 @@ BACKGROUND = (0x0E, 0x0E, 0x11)
ACCENT = (0x00, 0x70, 0xD1)
TEXT = (0xF2, 0xF2, 0xF5)
DOT = (0x2F, 0x8C, 0xFF) # lighter than the accent: legible at icon size
GRADIENT = ((0xAA, 0x5C, 0xC3), (0x00, 0xA4, 0xDC)) # Jellyfin's, left to right, for the home screen art
FONT = os.path.join(ROOT, "resources", "fonts", "Sora-Bold.ttf")
def halo(width, height, centre_y, radius_x, radius_y, strength):
"""Background with an elliptical light of the accent (strength at the centre, gone at 70 % of the radii)."""
def halo(width, height, centre_y, radius_x, radius_y, strength, colours=(ACCENT, ACCENT)):
"""Background with an elliptical light (strength at the centre, gone at 70 % of the radii) whose colour goes from
colours[0] on the left edge to colours[1] on the right."""
image = Image.new("RGB", (width, height))
pixels = image.load()
cx, cy = width / 2, centre_y * height
@@ -30,28 +35,72 @@ def halo(width, height, centre_y, radius_x, radius_y, strength):
dy = (y + 0.5 - cy) / (radius_y * height)
d = math.sqrt(dx * dx + dy * dy)
light = 0.0 if d >= 0.7 else strength * (1 - d / 0.7)
pixels[x, y] = tuple(round(b + (a - b) * light) for a, b in zip(ACCENT, BACKGROUND))
t = (x + 0.5) / width
colour = tuple(l + (r - l) * t for l, r in zip(*colours))
pixels[x, y] = tuple(round(b + (a - b) * light) for a, b in zip(colour, BACKGROUND))
return image
def logo(image, size, centre_y):
def logo(image, size, centre_y, dot=DOT):
draw = ImageDraw.Draw(image)
font = ImageFont.truetype(FONT, size)
name_width = draw.textlength("Playfin", font=font)
dot_width = draw.textlength(".", font=font)
x = (image.width - name_width - dot_width) / 2
draw.text((x, centre_y), "Playfin", font=font, fill=TEXT, anchor="lm")
draw.text((x + name_width, centre_y), ".", font=font, fill=DOT, anchor="lm")
draw.text((x + name_width, centre_y), ".", font=font, fill=dot, anchor="lm")
def wordmark(text_colour, dot_colour, size=160, margin=16):
""""Playfin." alone, cropped to its ink plus a margin, on a transparent background."""
font = ImageFont.truetype(FONT, size)
probe = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
name_width = probe.textlength("Playfin", font=font)
left, top, right, bottom = probe.textbbox((0, 0), "Playfin.", font=font)
image = Image.new("RGBA", (right - left + 2 * margin, bottom - top + 2 * margin), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
origin = (margin - left, margin - top)
draw.text(origin, "Playfin", font=font, fill=text_colour)
draw.text((origin[0] + name_width, origin[1]), ".", font=font, fill=dot_colour)
return image
def download_button(scale=2):
""""Download for PS4" with an arrow into a tray, white on the accent, drawn at `scale` for sharp screens."""
font = ImageFont.truetype(FONT, 22 * scale)
label = "Download for PS4"
probe = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
text_width = probe.textlength(label, font=font)
pad, icon, gap, height = 22 * scale, 22 * scale, 12 * scale, 56 * scale
image = Image.new("RGBA", (round(pad + icon + gap + text_width + pad), height), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
draw.rounded_rectangle((0, 0, image.width - 1, height - 1), radius=height // 2, fill=ACCENT)
stroke = round(2.6 * scale)
left, top = pad, (height - icon) / 2
middle = left + icon / 2
draw.line((middle, top, middle, top + icon * 0.62), fill=TEXT, width=stroke)
head = icon * 0.3
draw.line((middle - head, top + icon * 0.62 - head, middle, top + icon * 0.62), fill=TEXT, width=stroke, joint="curve")
draw.line((middle + head, top + icon * 0.62 - head, middle, top + icon * 0.62), fill=TEXT, width=stroke, joint="curve")
draw.line((left, top + icon * 0.72, left, top + icon, left + icon, top + icon, left + icon, top + icon * 0.72),
fill=TEXT, width=stroke, joint="curve")
draw.text((left + icon + gap, height / 2), label, font=font, fill=TEXT, anchor="lm")
return image
def main():
out = os.path.join(ROOT, "resources", "ps4", "sce_sys")
icon = halo(512, 512, 0.0, 0.9, 0.9, 0.6)
logo(icon, 104, 270)
icon = halo(512, 512, 0.0, 0.9, 0.9, 0.6, GRADIENT)
logo(icon, 104, 270, GRADIENT[1])
icon.save(os.path.join(out, "icon0.png"))
picture = halo(1920, 1080, -0.1, 0.7, 0.55, 0.3)
logo(picture, 160, 330)
picture = halo(1920, 1080, -0.1, 0.7, 0.55, 0.3, GRADIENT)
logo(picture, 160, 330, GRADIENT[1])
picture.save(os.path.join(out, "pic1.png"))
images = os.path.join(ROOT, "docs", "images")
os.makedirs(images, exist_ok=True)
wordmark(TEXT, DOT).save(os.path.join(images, "logo-on-dark.png"))
wordmark(BACKGROUND, ACCENT).save(os.path.join(images, "logo-on-light.png"))
download_button().save(os.path.join(images, "download.png"))
if __name__ == "__main__":
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# The corresponding source of the PS4 package, to attach to a release next to it:
# - Playfin's own source at HEAD;
# - for every recipe of scripts/ps4: the recipe, its patches and the upstream sources it downloads;
# - the build image's libraries and toolchain files that end up in the package (FriBidi, zlib, libsamplerate,
# the OpenOrbis toolchain's crt and link.x) with the PacBrew recipes they were built with.
# Every download with a known checksum is checked; SHA256SUMS lists every file of the archive.
# Usage: scripts/ps4/source-archive.sh [output.tar] (default: dist/playfin-<version>-sources.tar)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
VERSION="$(sed -n 's/^project(Playfin VERSION \([0-9.]*\).*/\1/p' "$ROOT/CMakeLists.txt")"
NAME="playfin-$VERSION-sources"
OUT="${1:-$ROOT/dist/$NAME.tar}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
DEST="$WORK/$NAME"
mkdir -p "$DEST/recipes" "$DEST/image" "$(dirname "$OUT")"
# fetch URL FILE SHA256 (SKIP: no published checksum)
fetch() {
echo " $(basename "$2")"
curl -fsSL --retry 3 -o "$2" "$1"
if [ "$3" != SKIP ]; then
echo "$3 $2" | sha256sum -c --quiet -
fi
}
echo "Playfin $VERSION"
git -C "$ROOT" archive --prefix="playfin-$VERSION/" -o "$DEST/playfin-$VERSION.tar.gz" HEAD
echo "Recipes of scripts/ps4"
for recipe in "$ROOT"/scripts/ps4/*/PKGBUILD; do
dir="$(dirname "$recipe")"
name="$(basename "$dir")"
mkdir -p "$DEST/recipes/$name"
cp "$dir"/* "$DEST/recipes/$name/"
# the recipe's source and sha256sums arrays, one "url<TAB>sum" per line
entries="$(cd "$dir" && set +u && source ./PKGBUILD && for i in "${!source[@]}"; do
printf '%s\t%s\n' "${source[$i]}" "${sha256sums[$i]:-SKIP}"; done)"
while IFS=$'\t' read -r src sum; do
case "$src" in
*::*) file="${src%%::*}" url="${src#*::}" ;;
*://*) file="$(basename "$src")" url="$src" ;;
*) continue ;; # a local file (patch, cross file): copied with the recipe
esac
fetch "$url" "$DEST/recipes/$name/$file" "$sum"
done <<< "$entries"
done
echo "Build image (xfangfang/pacbrew:250221): libraries and toolchain files in the package"
PORTLIBS=d6a523a5a4a7b86846d91134d40a34357721a273
TOOLCHAIN_RECIPES=16f27e38760ea21eaf1187d0edbf4342c4a44021
TOOLCHAIN=cd480230b946239f758333ef0dff40d67c5f0c20
while read -r url file sum; do
fetch "$url" "$DEST/image/$file" "$sum"
done <<EOF
https://github.com/fribidi/fribidi/releases/download/v1.0.4/fribidi-1.0.4.tar.bz2 fribidi-1.0.4.tar.bz2 94bdfe553e004d8bd095b109e182682311dd511740d5083326d1582f1df237be
https://zlib.net/fossils/zlib-1.3.1.tar.gz zlib-1.3.1.tar.gz 9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23
https://github.com/libsndfile/libsamplerate/releases/download/0.1.9/libsamplerate-0.1.9.tar.gz libsamplerate-0.1.9.tar.gz 0a7eb168e2f21353fb6d84da152e4512126f7dc48ccb0be80578c565413444c1
https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/archive/$TOOLCHAIN.zip OpenOrbis-PS4-Toolchain-$TOOLCHAIN.zip b77f9ba633cfe1cc829220321ea808b8990f4a625ccbad75b4f8f7686a9d8161
https://github.com/PacBrew/ps4-openorbis-portlibs/archive/$PORTLIBS.tar.gz pacbrew-ps4-openorbis-portlibs-$PORTLIBS.tar.gz 662559c4771c674e99ae37895a1c6ee64ff1aabf15ad363412f166d44a25b0f8
https://github.com/PacBrew/ps4-openorbis/archive/$TOOLCHAIN_RECIPES.tar.gz pacbrew-ps4-openorbis-$TOOLCHAIN_RECIPES.tar.gz f2b8d148def25ea75ea01c8e2a38af498e4d89f9a0e10c5fb8a956620f194c96
EOF
cat > "$DEST/README.txt" <<EOF
Corresponding source of Playfin $VERSION for the PS4 (IV0001-PLFN00000_00-PLFN000000100000.pkg).
playfin-$VERSION.tar.gz Playfin itself (GPL-3.0-or-later).
recipes/<name>/ How each library linked into the package was built (pacbrew PKGBUILD, patches,
cross files) and the upstream sources it downloads. Built with
scripts/ps4/build-deps.sh in the Docker image xfangfang/pacbrew:250221.
image/ Libraries and toolchain files taken from that image: FriBidi 1.0.4 (LGPL-2.1),
zlib 1.3.1, libsamplerate 0.1.9, the OpenOrbis PS4 Toolchain at $TOOLCHAIN
(GPL-3.0: crt and link.x), and the PacBrew recipes they were built with
(ps4-openorbis-portlibs, ps4-openorbis).
The licences are listed in THIRD_PARTY_LICENSES and resources/licenses/ of Playfin's source.
SHA256SUMS lists every file of this archive.
EOF
(cd "$DEST" && find . -type f ! -name SHA256SUMS -printf '%P\n' | sort | xargs sha256sum > SHA256SUMS)
tar -cf "$OUT" -C "$WORK" "$NAME"
echo "$OUT ($(du -h "$OUT" | cut -f1))"
+1 -1
View File
@@ -29,7 +29,7 @@ for script in tests/ui/*.script; do
sleep 0.1
done
fi
SDL_VIDEODRIVER=offscreen MESA_LOADER_DRIVER_OVERRIDE=llvmpipe timeout 120 \
LC_ALL=fr_FR.UTF-8 SDL_VIDEODRIVER=offscreen MESA_LOADER_DRIVER_OVERRIDE=llvmpipe timeout 120 \
build/linux-debug/playfin --hidden --no-discovery --screen "$screen" --data-dir "$out/data" \
--test-script "$script" --test-output "$out" $appOptions
if [ -n "$server" ]; then
+4 -3
View File
@@ -178,13 +178,13 @@ void ItemCard::draw(NVGcontext* vg) const {
nvgRestore(vg);
}
void ItemCard::show(const jellyfin::Item& item, const std::string& imageUrl) {
void ItemCard::show(const jellyfin::Item& item, const std::string& imageUrl, int utcOffsetMinutes) {
// a programme without its own picture shows its channel's logo whole
image_->fit = item.type == jellyfin::ItemType::Program && item.images.primary.empty() ? ui::ImageView::Fit::Contain
: ui::ImageView::Fit::Cover;
image_->setUrl(imageUrl);
title_->setText(cardTitle(item));
subtitle_->setText(cardSubtitle(item));
subtitle_->setText(cardSubtitle(item, utcOffsetMinutes));
const double percent = item.userData.playedPercentage;
progress_->visible = percent > 0 && percent < 100;
progress_->value = static_cast<float>(percent / 100.0);
@@ -284,7 +284,8 @@ void ItemsSource::bind(ui::View& view, int index) {
const auto client = session_.client();
card.show(item, client ? imageUrlFor(*client, item, shape_ == CardShape::Wide, static_cast<int>(size.width),
static_cast<int>(size.height))
: std::string());
: std::string(),
session_.utcOffsetMinutes(item.startUtc));
card.onPress = [this, index] {
if (onPress_ && index < count()) {
onPress_(items_[static_cast<std::size_t>(index)]);
+1 -1
View File
@@ -29,7 +29,7 @@ public:
explicit ItemCard(CardShape shape);
explicit ItemCard(bool wide) : ItemCard(shapeOf(wide)) {}
~ItemCard() override;
void show(const jellyfin::Item& item, const std::string& imageUrl);
void show(const jellyfin::Item& item, const std::string& imageUrl, int utcOffsetMinutes);
std::function<void()> onPress;
float focusProgress() const { return focus_; }
void onFocusChanged(bool focused) override;
+2 -3
View File
@@ -4,7 +4,6 @@
#include <vector>
#include "app/shell/guide.hpp"
#include "platform/platform.hpp"
#include "ui/i18n.hpp"
namespace playfin::app {
@@ -76,7 +75,7 @@ std::string cardTitle(const jellyfin::Item& item) {
return item.name;
}
std::string cardSubtitle(const jellyfin::Item& item) {
std::string cardSubtitle(const jellyfin::Item& item, int utcOffsetMinutes) {
if (item.type == jellyfin::ItemType::Episode) {
return ui::tr("shell.details.season_episode",
{std::to_string(item.parentIndexNumber), std::to_string(item.indexNumber)}) +
@@ -86,7 +85,7 @@ std::string cardSubtitle(const jellyfin::Item& item) {
return !item.albumArtist.empty() ? item.albumArtist : !item.artists.empty() ? item.artists.front() : "";
}
if (item.type == jellyfin::ItemType::Program && item.startUtc > 0) {
return timeRange(item, platform::utcOffsetMinutes(item.startUtc));
return timeRange(item, utcOffsetMinutes);
}
if (item.type == jellyfin::ItemType::Playlist && item.childCount > 0) {
return ui::translator().plural("music.track_count", item.childCount);
+3 -2
View File
@@ -15,8 +15,9 @@ std::string clockText(double seconds);
std::string metaLine(const jellyfin::Item& item);
// Episodes show their series; everything else its own name.
std::string cardTitle(const jellyfin::Item& item);
// Episodes: "S1 · E2 Name"; albums and tracks: the artist; playlists: the count; movies and series: the year.
std::string cardSubtitle(const jellyfin::Item& item);
// Episodes: "S1 · E2 Name"; albums and tracks: the artist; playlists: the count; programmes: their times at
// `utcOffsetMinutes` (AppSession::utcOffsetMinutes); movies and series: the year.
std::string cardSubtitle(const jellyfin::Item& item, int utcOffsetMinutes = 0);
// Wide: episode image, thumb, series thumb, backdrop, series backdrop, primary. Poster: series primary for
// episodes, else primary. Empty when the item has no usable image.
std::string imageUrlFor(const jellyfin::Client& client, const jellyfin::Item& item, bool wide, int width, int height);
+1 -1
View File
@@ -6,7 +6,7 @@ playfin=$1 imgdiff=$2 script=$3 refs=$4 out=$5
shift 5
rm -rf "$out"
mkdir -p "$out"
SDL_VIDEODRIVER=offscreen MESA_LOADER_DRIVER_OVERRIDE=llvmpipe timeout 120 \
LC_ALL=fr_FR.UTF-8 SDL_VIDEODRIVER=offscreen MESA_LOADER_DRIVER_OVERRIDE=llvmpipe timeout 120 \
"$playfin" --hidden "$@" --data-dir "$out/data" --test-script "$script" --test-output "$out"
shopt -s nullglob
references=("$refs"/*.png)
+1 -1
View File
@@ -16,7 +16,7 @@ for _ in $(seq 1 50); do
[ -s "$out/url" ] && break
sleep 0.1
done
SDL_VIDEODRIVER=offscreen MESA_LOADER_DRIVER_OVERRIDE=llvmpipe timeout 120 \
LC_ALL=fr_FR.UTF-8 SDL_VIDEODRIVER=offscreen MESA_LOADER_DRIVER_OVERRIDE=llvmpipe timeout 120 \
"$playfin" --hidden --no-discovery --screen shell --data-dir "$out/data" --test-script "$script" --test-output "$out" \
$appOptions
if grep -q "tok-demo" "$out/data/playfin.log"; then
+5
View File
@@ -65,6 +65,11 @@ TEST_CASE("item texts") {
const auto episode = fixtureItem("jellyfin/item_episode.json");
CHECK(app::cardTitle(episode) == "Test Show");
CHECK(app::cardSubtitle(episode) == "S1 · E1 Pilot");
jellyfin::Item program;
program.type = jellyfin::ItemType::Program;
program.startUtc = jellyfin::parseUtc("2026-10-16T19:30:00Z");
program.endUtc = jellyfin::parseUtc("2026-10-16T20:30:00Z");
CHECK(app::cardSubtitle(program, 120) == "21:30 \xE2\x80\x93 22:30"); // the app's clock (Paris), not the machine's
}
TEST_CASE("image choices: thumbnails for wide cards, series posters for episodes") {