From 308140517c79faea31ed10e75c9982000dd46266 Mon Sep 17 00:00:00 2001 From: Nathan Gathright Date: Mon, 18 May 2026 14:53:10 -0500 Subject: [PATCH] feat(parser): support phase 8 podcast image Add parsing for the formalized Phase 8 tag at channel, item, and liveItem level. The tag can appear multiple times, so parsed values are exposed as a podcastImage array with href plus optional alt, aspectRatio, width, height, type, and purpose attributes. Regression test feeds: phase-8 tests splice channel-level, item-level, and liveItem-level examples into the sample feed, including multiple channel images and video/mp4 media. Verified with: npm test -- --runTestsByPath src/parser/phase/__test__/phase-8.test.ts --runInBand; npm run lint:typecheck --- src/parser/phase/__test__/phase-8.test.ts | 127 ++++++++++++++++++++++ src/parser/phase/index.ts | 5 + src/parser/phase/phase-4.ts | 1 + src/parser/phase/phase-8.ts | 49 +++++++++ src/parser/types.ts | 5 + 5 files changed, 187 insertions(+) create mode 100644 src/parser/phase/__test__/phase-8.test.ts create mode 100644 src/parser/phase/phase-8.ts diff --git a/src/parser/phase/__test__/phase-8.test.ts b/src/parser/phase/__test__/phase-8.test.ts new file mode 100644 index 0000000..9e144bc --- /dev/null +++ b/src/parser/phase/__test__/phase-8.test.ts @@ -0,0 +1,127 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import * as helpers from "../../__test__/helpers"; + +const phase = 8; + +describe("phase 8", () => { + let feed; + beforeAll(async () => { + feed = await helpers.loadSimple(); + }); + + describe("podcast:image", () => { + const supportedName = "image"; + + it("skips missing tag", () => { + const result = helpers.parseValidFeed(feed); + + expect(result).not.toHaveProperty("podcastImage"); + expect(result.items[0]).not.toHaveProperty("podcastImage"); + expect(helpers.getPhaseSupport(result, phase)).not.toContain(supportedName); + }); + + it("ignores missing href", () => { + const xml = helpers.spliceFeed( + feed, + `` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).not.toHaveProperty("podcastImage"); + expect(helpers.getPhaseSupport(result, phase)).not.toContain(supportedName); + }); + + it("extracts multiple channel images", () => { + const xml = helpers.spliceFeed( + feed, + ` + ` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).toHaveProperty("podcastImage"); + expect(result.podcastImage).toHaveLength(2); + expect(result.podcastImage?.[0]).toEqual({ + href: "https://example.com/square.jpg", + alt: "Square art", + aspectRatio: "1/1", + type: "image/jpeg", + width: 1400, + height: 1400, + purpose: "artwork", + }); + expect(result.podcastImage?.[1]).toEqual({ + href: "https://example.com/banner.jpg", + aspectRatio: "16/9", + width: 1920, + purpose: "artwork social", + }); + expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName); + }); + + it("extracts item images", () => { + const xml = helpers.spliceFirstItem( + feed, + `` + ); + const result = helpers.parseValidFeed(xml); + + expect(result.items[0]).toHaveProperty("podcastImage"); + expect(result.items[0].podcastImage).toHaveLength(1); + expect(result.items[0].podcastImage?.[0]).toEqual({ + href: "https://example.com/episode.mp4", + type: "video/mp4", + aspectRatio: "9/16", + width: 1200, + purpose: "canvas", + }); + expect(result.items[1]).not.toHaveProperty("podcastImage"); + expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName); + }); + + it("filters item images without href while keeping valid images", () => { + const xml = helpers.spliceFirstItem( + feed, + ` + ` + ); + const result = helpers.parseValidFeed(xml); + + expect(result.items[0]).toHaveProperty("podcastImage"); + expect(result.items[0].podcastImage).toEqual([ + { + href: "https://example.com/item-valid.jpg", + type: "image/jpeg", + width: 1400, + height: 1400, + }, + ]); + expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName); + }); + + it("extracts liveItem images", () => { + const xml = helpers.spliceFeed( + feed, + ` + Live Episode + live-episode + + + ` + ); + const result = helpers.parseValidFeed(xml); + + expect(result.podcastLiveItems).toHaveLength(1); + expect(result.podcastLiveItems?.[0]).toHaveProperty("podcastImage"); + expect(result.podcastLiveItems?.[0].podcastImage).toEqual([ + { + href: "https://example.com/live.jpg", + alt: "Live art", + aspectRatio: "16/9", + }, + ]); + }); + }); +}); diff --git a/src/parser/phase/index.ts b/src/parser/phase/index.ts index 5432152..a6875a9 100644 --- a/src/parser/phase/index.ts +++ b/src/parser/phase/index.ts @@ -16,6 +16,7 @@ import * as phase4 from "./phase-4"; import * as phase5 from "./phase-5"; import * as phase6 from "./phase-6"; import * as phase7 from "./phase-7"; +import * as phase8 from "./phase-8"; import * as pending from "./phase-pending"; import { XmlNodeSource } from "./types"; @@ -99,6 +100,8 @@ const feeds: FeedUpdate[] = [ phase7.podcastChat, phase7.podcastPublisher, + phase8.podcastImage, + pending.metaBoost, pending.id, pending.social, @@ -128,6 +131,8 @@ const items: ItemUpdate[] = [ phase7.podcastChat, + phase8.podcastImage, + pending.podcastRecommendations, pending.podcastGateway, ]; diff --git a/src/parser/phase/phase-4.ts b/src/parser/phase/phase-4.ts index fec145b..9b6821d 100644 --- a/src/parser/phase/phase-4.ts +++ b/src/parser/phase/phase-4.ts @@ -272,6 +272,7 @@ export type Phase4PodcastLiveItemItem = Pick > & { diff --git a/src/parser/phase/phase-8.ts b/src/parser/phase/phase-8.ts new file mode 100644 index 0000000..6fbd8ee --- /dev/null +++ b/src/parser/phase/phase-8.ts @@ -0,0 +1,49 @@ +import { + ensureArray, + extractOptionalIntegerAttribute, + extractOptionalStringAttribute, + getAttribute, + getKnownAttribute, +} from "../shared"; +import type { XmlNode } from "../types"; + +import { addSubTag } from "./helpers"; + +export type Phase8PodcastImage = { + href: string; + alt?: string; + aspectRatio?: string; + width?: number; + height?: number; + type?: string; + purpose?: string; +}; + +function parseImage(node: XmlNode): Phase8PodcastImage { + return { + href: getKnownAttribute(node, "href"), + ...extractOptionalStringAttribute(node, "alt"), + ...extractOptionalStringAttribute(node, "aspect-ratio", "aspectRatio"), + ...extractOptionalIntegerAttribute(node, "width"), + ...extractOptionalIntegerAttribute(node, "height"), + ...extractOptionalStringAttribute(node, "type"), + ...extractOptionalStringAttribute(node, "purpose"), + }; +} + +export const podcastImage = { + phase: 8, + name: "image", + tag: "podcast:image", + nodeTransform: (node: XmlNode | XmlNode[]): XmlNode[] => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + ensureArray(node).filter((n) => getAttribute(n, "href")), + supportCheck: (node: XmlNode[]): boolean => node.length > 0, + fn(node: XmlNode[]): { podcastImage: Phase8PodcastImage[] } { + return { + podcastImage: node.map(parseImage), + }; + }, +}; + +addSubTag("liveItem", podcastImage); diff --git a/src/parser/types.ts b/src/parser/types.ts index 2134a65..6987a68 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -22,6 +22,7 @@ import type { import type { Phase5Blocked, Phase5BlockedPlatforms, Phase5SocialInteract } from "./phase/phase-5"; import type { Phase6RemoteItem, Phase6TxtEntry } from "./phase/phase-6"; import type { Phase7Chat, Phase7Publisher } from "./phase/phase-7"; +import type { Phase8PodcastImage } from "./phase/phase-8"; import { PhasePendingPodcastId, PhasePendingSocial, @@ -203,6 +204,8 @@ export interface FeedObject extends BasicFeed { /** PENDING AND LIKELY TO CHANGE This tag tells the an application what the content contained within the feed IS, as opposed to what the content is ABOUT in the case of a category. */ medium?: Phase4Medium; podcastImages?: Phase4PodcastImage[]; + /** Phase 8: media assets for channel, item, or liveItem presentation. */ + podcastImage?: Phase8PodcastImage[]; podcastRecommendations?: PhasePendingPodcastRecommendation[]; // #endregion } @@ -273,6 +276,8 @@ export interface Episode { // #region Pending Phase podcastImages?: Phase4PodcastImage[]; + /** Phase 8: media assets for channel, item, or liveItem presentation. */ + podcastImage?: Phase8PodcastImage[]; podcastRecommendations?: PhasePendingPodcastRecommendation[]; podcastGateway?: PhasePendingGateway; // #endregion