domain: delete dead jellyfinFieldMapping; scope playbackUnits to session boundary (phase 4e)
jellyfinFieldMapping.ts (SORT_FIELD_MAP friendly->Jellyfin sort names) had
zero consumers — sort code passes raw Jellyfin field names directly — so it
and its test are deleted.
playbackUnits.ts can't be removed: its tick<->seconds helpers are still the
correct converters for the remote Jellyfin *session* boundary
(SessionInfo.playState.positionTicks, NowPlayingItem.runTimeTicks), which
legitimately arrives in ticks. Documented that narrowed role; formatTime/
calculateProgress remain neutral seconds-based presentation helpers.
Note (out of scope): sortBy still passes raw Jellyfin field names
("SortName", "CommunityRating") — a separate sort-taxonomy leak that would
need its own Rust SortKey, like the search-scope work.
Frontend 626 tests (jellyfinFieldMapping's 18 removed with it), check clean.
This commit is contained in:
@@ -1,138 +0,0 @@
|
|||||||
/**
|
|
||||||
* Jellyfin Field Mapping Tests
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import {
|
|
||||||
SORT_FIELD_MAP,
|
|
||||||
getJellyfinSortField,
|
|
||||||
normalizeSortOrder,
|
|
||||||
ITEM_TYPES,
|
|
||||||
ITEM_TYPE_GROUPS,
|
|
||||||
} from "./jellyfinFieldMapping";
|
|
||||||
|
|
||||||
describe("Jellyfin Field Mapping", () => {
|
|
||||||
describe("SORT_FIELD_MAP", () => {
|
|
||||||
it("should map frontend sort keys to Jellyfin fields", () => {
|
|
||||||
expect(SORT_FIELD_MAP.title).toBe("SortName");
|
|
||||||
expect(SORT_FIELD_MAP.artist).toBe("Artist");
|
|
||||||
expect(SORT_FIELD_MAP.album).toBe("Album");
|
|
||||||
expect(SORT_FIELD_MAP.year).toBe("ProductionYear");
|
|
||||||
expect(SORT_FIELD_MAP.recent).toBe("DatePlayed");
|
|
||||||
expect(SORT_FIELD_MAP.added).toBe("DateCreated");
|
|
||||||
expect(SORT_FIELD_MAP.rating).toBe("CommunityRating");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have all common audio sorts", () => {
|
|
||||||
expect(SORT_FIELD_MAP).toHaveProperty("title");
|
|
||||||
expect(SORT_FIELD_MAP).toHaveProperty("artist");
|
|
||||||
expect(SORT_FIELD_MAP).toHaveProperty("album");
|
|
||||||
expect(SORT_FIELD_MAP).toHaveProperty("year");
|
|
||||||
expect(SORT_FIELD_MAP).toHaveProperty("recent");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have fallback sort names", () => {
|
|
||||||
expect(SORT_FIELD_MAP.name).toBe("SortName");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should map aliases to same fields", () => {
|
|
||||||
expect(SORT_FIELD_MAP.title).toBe(SORT_FIELD_MAP.name);
|
|
||||||
expect(SORT_FIELD_MAP.recent).toBe("DatePlayed");
|
|
||||||
expect(SORT_FIELD_MAP.dateAdded).toBe("DateCreated");
|
|
||||||
expect(SORT_FIELD_MAP.datePlayed).toBe("DatePlayed");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getJellyfinSortField()", () => {
|
|
||||||
it("should return mapped field for known keys", () => {
|
|
||||||
expect(getJellyfinSortField("artist")).toBe("Artist");
|
|
||||||
expect(getJellyfinSortField("album")).toBe("Album");
|
|
||||||
expect(getJellyfinSortField("year")).toBe("ProductionYear");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fallback to SortName for unknown keys", () => {
|
|
||||||
expect(getJellyfinSortField("unknown")).toBe("SortName");
|
|
||||||
expect(getJellyfinSortField("")).toBe("SortName");
|
|
||||||
expect(getJellyfinSortField("invalidKey")).toBe("SortName");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be case-sensitive", () => {
|
|
||||||
// Should work with exact case
|
|
||||||
expect(getJellyfinSortField("title")).toBe("SortName");
|
|
||||||
// Unknown case variations fallback to default
|
|
||||||
expect(getJellyfinSortField("Title")).toBe("SortName");
|
|
||||||
expect(getJellyfinSortField("TITLE")).toBe("SortName");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("normalizeSortOrder()", () => {
|
|
||||||
it("should accept valid ascending orders", () => {
|
|
||||||
expect(normalizeSortOrder("Ascending")).toBe("Ascending");
|
|
||||||
expect(normalizeSortOrder("ascending")).toBe("Ascending");
|
|
||||||
expect(normalizeSortOrder("asc")).toBe("Ascending");
|
|
||||||
expect(normalizeSortOrder(undefined)).toBe("Ascending");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept valid descending orders", () => {
|
|
||||||
expect(normalizeSortOrder("Descending")).toBe("Descending");
|
|
||||||
expect(normalizeSortOrder("descending")).toBe("Descending");
|
|
||||||
expect(normalizeSortOrder("desc")).toBe("Descending");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should default to Ascending for unknown values", () => {
|
|
||||||
expect(normalizeSortOrder("invalid")).toBe("Ascending");
|
|
||||||
expect(normalizeSortOrder("random")).toBe("Ascending");
|
|
||||||
expect(normalizeSortOrder("")).toBe("Ascending");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ITEM_TYPES", () => {
|
|
||||||
it("should define audio types", () => {
|
|
||||||
expect(ITEM_TYPES.AUDIO).toBe("Audio");
|
|
||||||
expect(ITEM_TYPES.MUSIC_ALBUM).toBe("MusicAlbum");
|
|
||||||
expect(ITEM_TYPES.MUSIC_ARTIST).toBe("MusicArtist");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should define video types", () => {
|
|
||||||
expect(ITEM_TYPES.MOVIE).toBe("Movie");
|
|
||||||
expect(ITEM_TYPES.SERIES).toBe("Series");
|
|
||||||
expect(ITEM_TYPES.EPISODE).toBe("Episode");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have consistent case", () => {
|
|
||||||
// Jellyfin API uses CamelCase
|
|
||||||
expect(ITEM_TYPES.MUSIC_ALBUM).toBe("MusicAlbum");
|
|
||||||
expect(ITEM_TYPES.MUSIC_ARTIST).toBe("MusicArtist");
|
|
||||||
expect(ITEM_TYPES.MUSIC_VIDEO).toBe("MusicVideo");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ITEM_TYPE_GROUPS", () => {
|
|
||||||
it("should group audio types correctly", () => {
|
|
||||||
expect(ITEM_TYPE_GROUPS.audio).toContain(ITEM_TYPES.AUDIO);
|
|
||||||
expect(ITEM_TYPE_GROUPS.audio).toContain(ITEM_TYPES.MUSIC_ALBUM);
|
|
||||||
expect(ITEM_TYPE_GROUPS.audio).toContain(ITEM_TYPES.MUSIC_ARTIST);
|
|
||||||
expect(ITEM_TYPE_GROUPS.audio.length).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should group video types correctly", () => {
|
|
||||||
expect(ITEM_TYPE_GROUPS.video).toContain(ITEM_TYPES.MOVIE);
|
|
||||||
expect(ITEM_TYPE_GROUPS.video).toContain(ITEM_TYPES.SERIES);
|
|
||||||
expect(ITEM_TYPE_GROUPS.video).toContain(ITEM_TYPES.EPISODE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should provide movie and TV show subgroups", () => {
|
|
||||||
expect(ITEM_TYPE_GROUPS.movies).toEqual([ITEM_TYPES.MOVIE]);
|
|
||||||
expect(ITEM_TYPE_GROUPS.tvshows).toContain(ITEM_TYPES.SERIES);
|
|
||||||
expect(ITEM_TYPE_GROUPS.tvshows).toContain(ITEM_TYPES.EPISODE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have music alias for audio", () => {
|
|
||||||
expect(ITEM_TYPE_GROUPS.music).toEqual(ITEM_TYPE_GROUPS.audio);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should provide episodes filter", () => {
|
|
||||||
expect(ITEM_TYPE_GROUPS.episodes).toEqual([ITEM_TYPES.EPISODE]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
/**
|
|
||||||
* Jellyfin Field Mapping
|
|
||||||
*
|
|
||||||
* Maps frontend sort option keys to Jellyfin API field names.
|
|
||||||
* This provides the single source of truth for how different UI sort options
|
|
||||||
* translate to backend database queries.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps friendly sort names to Jellyfin API field names
|
|
||||||
* Used by all library views for consistent sorting
|
|
||||||
*/
|
|
||||||
export const SORT_FIELD_MAP = {
|
|
||||||
// Default/fallback sorts
|
|
||||||
title: "SortName",
|
|
||||||
name: "SortName",
|
|
||||||
|
|
||||||
// Audio-specific sorts
|
|
||||||
artist: "Artist",
|
|
||||||
album: "Album",
|
|
||||||
year: "ProductionYear",
|
|
||||||
recent: "DatePlayed",
|
|
||||||
added: "DateCreated",
|
|
||||||
rating: "CommunityRating",
|
|
||||||
duration: "RunTimeTicks",
|
|
||||||
|
|
||||||
// Video-specific sorts
|
|
||||||
dateAdded: "DateCreated",
|
|
||||||
datePlayed: "DatePlayed",
|
|
||||||
IMDBRating: "CommunityRating",
|
|
||||||
|
|
||||||
// Video series sorts
|
|
||||||
premiered: "PremiereDate",
|
|
||||||
episodeCount: "ChildCount",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type-safe sort field names
|
|
||||||
*/
|
|
||||||
export type SortField = keyof typeof SORT_FIELD_MAP;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Jellyfin API field name for a frontend sort key
|
|
||||||
* @param key Frontend sort key (e.g., "artist")
|
|
||||||
* @returns Jellyfin field name (e.g., "Artist")
|
|
||||||
*/
|
|
||||||
export function getJellyfinSortField(key: string): string {
|
|
||||||
const field = SORT_FIELD_MAP[key as SortField];
|
|
||||||
return field || "SortName"; // Fallback to title sort
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate sort order string
|
|
||||||
* @param order Sort order value
|
|
||||||
* @returns Valid sort order for Jellyfin API
|
|
||||||
*/
|
|
||||||
export function normalizeSortOrder(order: string | undefined): "Ascending" | "Descending" {
|
|
||||||
if (order === "Descending" || order === "desc" || order === "descending") {
|
|
||||||
return "Descending";
|
|
||||||
}
|
|
||||||
return "Ascending";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Jellyfin ItemType constants for filtering
|
|
||||||
* Used in getItems() and search() calls
|
|
||||||
*/
|
|
||||||
export const ITEM_TYPES = {
|
|
||||||
// Audio types
|
|
||||||
AUDIO: "Audio",
|
|
||||||
MUSIC_ALBUM: "MusicAlbum",
|
|
||||||
MUSIC_ARTIST: "MusicArtist",
|
|
||||||
MUSIC_VIDEO: "MusicVideo",
|
|
||||||
|
|
||||||
// Video types
|
|
||||||
MOVIE: "Movie",
|
|
||||||
SERIES: "Series",
|
|
||||||
SEASON: "Season",
|
|
||||||
EPISODE: "Episode",
|
|
||||||
|
|
||||||
// Playlist
|
|
||||||
PLAYLIST: "Playlist",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Predefined item type groups for easy filtering
|
|
||||||
*/
|
|
||||||
export const ITEM_TYPE_GROUPS = {
|
|
||||||
audio: [ITEM_TYPES.AUDIO, ITEM_TYPES.MUSIC_ALBUM, ITEM_TYPES.MUSIC_ARTIST],
|
|
||||||
music: [ITEM_TYPES.AUDIO, ITEM_TYPES.MUSIC_ALBUM, ITEM_TYPES.MUSIC_ARTIST],
|
|
||||||
video: [ITEM_TYPES.MOVIE, ITEM_TYPES.SERIES, ITEM_TYPES.EPISODE],
|
|
||||||
movies: [ITEM_TYPES.MOVIE],
|
|
||||||
tvshows: [ITEM_TYPES.SERIES, ITEM_TYPES.SEASON, ITEM_TYPES.EPISODE],
|
|
||||||
episodes: [ITEM_TYPES.EPISODE],
|
|
||||||
} as const;
|
|
||||||
@@ -1,29 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* Playback unit conversion utilities
|
* Playback unit utilities.
|
||||||
*
|
*
|
||||||
* Jellyfin uses "ticks" for time values where 10 million ticks = 1 second.
|
* The app's own media model speaks milliseconds (see the domain migration in
|
||||||
* This module provides type-safe conversion functions to eliminate magic numbers
|
* docs/specs/frontend-domain-model.md), so catalog code does NOT use the tick
|
||||||
* and prevent conversion bugs across the codebase.
|
* helpers here. Ticks survive only at the **remote Jellyfin session boundary** —
|
||||||
|
* `SessionInfo.playState.positionTicks` and `NowPlayingItem.runTimeTicks` arrive
|
||||||
|
* straight from a live Jellyfin session and are converted here for display.
|
||||||
|
* `formatTime`/`calculateProgress` are plain seconds-based presentation helpers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of Jellyfin ticks per second (10 million)
|
* Number of Jellyfin ticks per second (10 million).
|
||||||
|
*
|
||||||
|
* Only for the remote-session boundary (see module doc); catalog durations are
|
||||||
|
* milliseconds and never touch this.
|
||||||
*/
|
*/
|
||||||
export const TICKS_PER_SECOND = 10_000_000;
|
export const TICKS_PER_SECOND = 10_000_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert seconds to Jellyfin ticks
|
* Convert seconds to Jellyfin ticks. Remote-session boundary only.
|
||||||
* @param seconds - Time in seconds (e.g., 90.5 for 1 minute 30.5 seconds)
|
|
||||||
* @returns Time in Jellyfin ticks
|
|
||||||
*/
|
*/
|
||||||
export function secondsToTicks(seconds: number): number {
|
export function secondsToTicks(seconds: number): number {
|
||||||
return Math.floor(seconds * TICKS_PER_SECOND);
|
return Math.floor(seconds * TICKS_PER_SECOND);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Jellyfin ticks to seconds
|
* Convert Jellyfin session ticks to seconds. Remote-session boundary only.
|
||||||
* @param ticks - Time in Jellyfin ticks
|
|
||||||
* @returns Time in seconds
|
|
||||||
*/
|
*/
|
||||||
export function ticksToSeconds(ticks: number | null | undefined): number {
|
export function ticksToSeconds(ticks: number | null | undefined): number {
|
||||||
return (ticks ?? 0) / TICKS_PER_SECOND;
|
return (ticks ?? 0) / TICKS_PER_SECOND;
|
||||||
|
|||||||
Reference in New Issue
Block a user