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