Files
gridpilot.gg/core/ports/media/MediaResolverPort.ts
2025-12-31 15:39:28 +01:00

148 lines
4.2 KiB
TypeScript

/**
* Port: MediaResolverPort
*
* Interface for resolving MediaReference objects to actual URLs.
* Part of the clean architecture ports layer.
*
* Implementations:
* - InMemoryMediaResolverAdapter (for tests/stubs)
* - HttpMediaResolverAdapter (for production HTTP resolution)
* - FileSystemMediaResolverAdapter (for local file resolution)
*/
import { MediaReference } from '@core/domain/media/MediaReference';
/**
* MediaResolverPort interface
*
* Resolves a MediaReference to a concrete path string or null if no media exists.
* Returns path-only URLs (e.g., /media/teams/123/logo) without baseUrl.
*
* @param ref - The media reference to resolve
* @returns Promise resolving to a path string or null
*
* @example
* ```typescript
* const resolver: MediaResolverPort = new MediaResolverAdapter();
* const ref = MediaReference.createSystemDefault('avatar');
* const path = await resolver.resolve(ref);
* // Returns: '/media/default/male-default-avatar.png'
* ```
*/
export interface MediaResolverPort {
/**
* Resolve a media reference to a path-only URL
*
* @param ref - The media reference to resolve
* @returns Promise resolving to path string or null (for 'none' type or resolution failures)
*
* @throws Should not throw for valid inputs - returns null instead
* @throws May throw for invalid inputs (null ref)
*/
resolve(ref: MediaReference): Promise<string | null>;
}
/**
* Type guard to check if an object implements MediaResolverPort
*/
export function isMediaResolverPort(obj: unknown): obj is MediaResolverPort {
const typedObj = obj as MediaResolverPort;
return (
obj !== null &&
typeof obj === 'object' &&
typeof typedObj.resolve === 'function'
);
}
/**
* Default resolution strategies for different reference types
* Returns path-only URLs
*/
export const ResolutionStrategies = {
/**
* Resolve system-default references
* Format: /media/default/{variant}
*/
systemDefault: (ref: MediaReference): string | null => {
if (ref.type !== 'system-default') return null;
let filename: string;
if (ref.variant === 'avatar' && ref.avatarVariant) {
filename = `${ref.avatarVariant}-default-avatar.png`;
} else if (ref.variant === 'avatar') {
filename = `neutral-default-avatar.png`;
} else {
filename = `${ref.variant}.png`;
}
return `/media/default/${filename}`;
},
/**
* Resolve generated references
* Format: /media/teams/{id}/logo, /media/leagues/{id}/logo, /media/avatar/{id}
*/
generated: (ref: MediaReference): string | null => {
if (ref.type !== 'generated') return null;
if (!ref.generationRequestId) {
return null;
}
const firstHyphenIndex = ref.generationRequestId.indexOf('-');
if (firstHyphenIndex === -1) {
return null;
}
const type = ref.generationRequestId.substring(0, firstHyphenIndex);
const id = ref.generationRequestId.substring(firstHyphenIndex + 1);
if (type === 'team') {
return `/media/teams/${id}/logo`;
} else if (type === 'league') {
return `/media/leagues/${id}/logo`;
} else if (type === 'driver') {
return `/media/avatar/${id}`;
}
return `/media/generated/${type}/${id}`;
},
/**
* Resolve uploaded references
* Format: /media/uploaded/{mediaId}
*/
uploaded: (ref: MediaReference): string | null => {
if (ref.type !== 'uploaded') return null;
if (!ref.mediaId) return null;
return `/media/uploaded/${ref.mediaId}`;
},
/**
* Resolve none references
* Returns: null
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
none: (_ref: MediaReference): string | null => {
return null;
},
} as const;
/**
* Helper function to resolve using default strategies
*/
export function resolveWithDefaults(ref: MediaReference): string | null {
switch (ref.type) {
case 'system-default':
return ResolutionStrategies.systemDefault(ref);
case 'generated':
return ResolutionStrategies.generated(ref);
case 'uploaded':
return ResolutionStrategies.uploaded(ref);
case 'none':
return ResolutionStrategies.none(ref);
default:
// Exhaustive check - TypeScript will error if we miss a case
return null;
}
}