Share an episode as a page, on ipx-play (#447)
Share handed over the raw file address or the item's link: no preview, no time, nothing a person without iPX could do much with. Now, as Overcast does, an episode shares as a short link to its own page, play.ipxreader.app/e/<id>, which anyone can open: the show's art and name, the episode's title, date, length and notes, a player on the publisher's own file starting at ?t= if a time was shared, buttons to subscribe in Apple Podcasts, Overcast, Pocket Casts or Castro, and Open Graph tags for a preview where the link is pasted. ipx-play is a service of its own (crates/play), built like the directory: ipx sends it the episode with its token (POST /v1/shares) and gets the link back. The id comes from the feed's address and the guid, so the same episode is one link whoever shares it. Only episodes of feeds the directory would be sent go there, by the directory's own rule (snapshot_entries), so the two cannot disagree on what is private; ipx-play also refuses an address that looks private. Anything else, and any server without ipx-play set up, shares as before. In the page, Share on an episode opens a menu once the link is back: Share link, Share from 12:34 (the player's clock, or where you left off), Copy link. A menu because the share sheet needs a press of its own, and the request for the link used up the one that opened it. The page runs no script: the start time is the media fragment #t=, and its CSP loads nothing but its stylesheet, the art and the file. Admin, Directory, Sharing episodes sets the address and token. The browser suite starts its own ipx-play; it was not run here (no Caddy on this Mac). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2233,17 +2233,31 @@ test('words in Settings hide the items that mention them', async ({ page }) => {
|
||||
await page.evaluate(() => api('/api/me', { method: 'PATCH', body: JSON.stringify({ blocked: [] }) }));
|
||||
});
|
||||
|
||||
test('Share hands the feed, the item and the file to the share sheet', async ({ page }) => {
|
||||
test('Share hands the feed, and an episode as a link to its page on ipx-play (#447)', async ({ page }) => {
|
||||
await page.evaluate(() => { window.shared = []; navigator.share = async (d?: ShareData) => { window.shared.push(d!); }; });
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||
await page.locator('#content .acts [data-a="share"]').click();
|
||||
await page.locator('.ep', { hasText: 'Second Episode' }).click();
|
||||
await page.locator('.encbox [data-a="share"]').first().click();
|
||||
// A menu, whose press opens the share sheet: the request for the link used up the first.
|
||||
await page.locator('#shlink').click();
|
||||
await expect.poll(() => page.evaluate(() => window.shared)).toEqual([
|
||||
{ title: 'Test Show', url: expect.stringContaining('/show.xml') },
|
||||
{ title: 'Second Episode', url: expect.stringContaining('/ep1.mp3?2') },
|
||||
{ title: 'Second Episode', url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:8798\/e\/[a-z2-7]{10}$/) },
|
||||
]);
|
||||
|
||||
// Anyone can open it: the episode, its show, and its file to play.
|
||||
const url = await page.evaluate(() => window.shared[1].url!);
|
||||
const html = await (await page.request.get(url)).text();
|
||||
expect(html).toContain('Second Episode');
|
||||
expect(html).toContain('Test Show');
|
||||
expect(html).toContain('/ep1.mp3');
|
||||
expect(await (await page.request.get(`${url}?t=90`)).text()).toContain('Starts at 1:30');
|
||||
|
||||
// A paid feed's episode never goes there: the page shares its own address instead.
|
||||
const paid = await page.evaluate(() => api('/api/entries/paid-show/paid-1/share', { method: 'POST', body: '{}' }));
|
||||
expect(paid).toEqual({ url: null, why: 'private' });
|
||||
});
|
||||
|
||||
test('a refresh button turns from the press to the end of the scan it asked for', async ({ page }) => {
|
||||
|
||||
@@ -10,6 +10,9 @@ const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not
|
||||
// The suite's own directory service (#205), and this daemon's token for it, which may do anything.
|
||||
const DIRECTORY = 'http://127.0.0.1:8795';
|
||||
const DIRECTORY_TOKEN = 'ipxd_testdirectorytoken';
|
||||
// The suite's own ipx-play (#447), which shared episodes' links go to.
|
||||
const PLAY = 'http://127.0.0.1:8798';
|
||||
const PLAY_TOKEN = 'ipxd_testplaytoken';
|
||||
|
||||
// Called from playwright.config.ts at load time, NOT as globalSetup: Playwright starts
|
||||
// webServer *before* globalSetup, so a config written there does not exist yet when the
|
||||
@@ -38,6 +41,8 @@ fetch_internal = ["127.0.0.1"]
|
||||
directory_url = "${DIRECTORY}"
|
||||
directory_token = "${DIRECTORY_TOKEN}"
|
||||
directory_share = true
|
||||
play_url = "${PLAY}"
|
||||
play_token = "${PLAY_TOKEN}"
|
||||
|
||||
[torrent]
|
||||
enabled = false
|
||||
@@ -85,6 +90,14 @@ fetch_internal = ["127.0.0.1"]
|
||||
name = "test"
|
||||
access = "rwa"
|
||||
token_sha256 = "${crypto.createHash('sha256').update(DIRECTORY_TOKEN).digest('hex')}"
|
||||
`);
|
||||
fs.writeFileSync(path.join(root, 'play.toml'), `
|
||||
bind = "127.0.0.1:8798"
|
||||
public_url = "${PLAY}"
|
||||
|
||||
[[tokens]]
|
||||
name = "test"
|
||||
token_sha256 = "${crypto.createHash('sha256').update(PLAY_TOKEN).digest('hex')}"
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -94,5 +107,6 @@ token_sha256 = "${crypto.createHash('sha256').update(DIRECTORY_TOKEN).digest('he
|
||||
const API_COVERAGE = process.env.IPX_API_COVERAGE ?? '';
|
||||
const IPX_BIN = API_COVERAGE ? path.join(API_COVERAGE, 'debug', 'ipxd') : './target/debug/ipxd';
|
||||
const DIRECTORY_BIN = API_COVERAGE ? path.join(API_COVERAGE, 'debug', 'ipx-directory') : './target/debug/ipx-directory';
|
||||
const PLAY_BIN = API_COVERAGE ? path.join(API_COVERAGE, 'debug', 'ipx-play') : './target/debug/ipx-play';
|
||||
|
||||
export { prepare, root, TOKEN, DIRECTORY, DIRECTORY_TOKEN, API_COVERAGE, IPX_BIN, DIRECTORY_BIN };
|
||||
export { prepare, root, TOKEN, DIRECTORY, DIRECTORY_TOKEN, PLAY, API_COVERAGE, IPX_BIN, DIRECTORY_BIN, PLAY_BIN };
|
||||
|
||||
Reference in New Issue
Block a user