Ctrl K
ComunidadGuías…

src/renderer/store/play-history.store.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

import { touchSamoPlaylistLastPlayed } from '/@/renderer/services/samo-playlist-playback';
import { identityPersistMigrate, PERSIST_VERSION_INITIAL } from '/@/renderer/store/persist-migrate';
import { LongFormLibraryItem } from '/@/shared/api/long-form-types';
import {
    Album,
    AlbumArtist,
    InternetRadioStation,
    LibraryItem,
    Playlist,
    RelatedAlbumArtist,
    RelatedArtist,
    Song,
} from '/@/shared/types/domain-types';

const RECENT_ITEM_LIMIT = 80;

/** Bumped when artwork URLs stopped carrying a stream token. */
const PERSIST_VERSION_STREAM_TOKEN_STRIPPED = PERSIST_VERSION_INITIAL + 1;

/**
 * Recents persist an artwork URL snapshot, so anything embedded in that URL
 * outlives the session that produced it. A stream token must not be: the server
 * keeps tokens in a process-local map with a 30 minute TTL and drops them on
 * restart, so a persisted one is dead long before the entry stops being shown —
 * and Continue Listening is the first thing on Home. Stripping it also keeps
 * the URL identical to the one the rest of the app builds, so all of them share
 * a single cache entry. The bearer authenticates these requests instead.
 */
const withoutStreamToken = (url: null | string | undefined): null | string | undefined => {
    if (!url || !url.includes('stream_token=')) {
        return url;
    }

    const [base, query] = url.split('?');
    const kept = (query ?? '')
        .split('&')
        .filter((part) => part && !part.startsWith('stream_token='))
        .join('&');

    return kept ? `${base}?${kept}` : base;
};

const withCleanArtwork = <T extends RecentItem>(item: T): T =>
    item.artwork && 'imageUrl' in item.artwork && item.artwork.imageUrl?.includes('stream_token=')
        ? {
              ...item,
              artwork: { ...item.artwork, imageUrl: withoutStreamToken(item.artwork.imageUrl) },
          }
        : item;

export type PlayHistoryEntryType = RecentItemType;

export interface PlayHistoryRef {
    itemId: string;
    serverId: string;
    type: PlayHistoryEntryType;
}

export type RecentArtwork =
    | {
          fallbackIcon: 'metadata' | 'microphone';
          imageUrl?: string;
          itemId: string;
          kind: 'abs';
      }
    | {
          fallbackIconKey: string;
          kind: 'icon';
      }
    | {
          imageId?: null | string;
          imageItemType: LibraryItem;
          imageUrl?: null | string;
          kind: 'music';
          serverId?: null | string;
          shape?: 'circle' | 'square';
      };

export interface RecentItem {
    artwork: RecentArtwork;
    itemId: string;
    key: string;
    mediaType: RecentItemType;
    radioStreamUrl?: string;
    rawAbsItem?: LongFormLibraryItem;
    selectedAt: number;
    serverId: string;
    song?: Song;
    subtitle: string;
    title: string;
}

export type RecentItemInput = Omit<RecentItem, 'key' | 'selectedAt'> & {
    selectedAt?: number;
};

export type RecentItemType =
    | 'album'
    | 'artist'
    | 'audiobook'
    | 'playlist'
    | 'podcast'
    | 'radio'
    | 'song';

export const playHistoryKey = ({ itemId, serverId, type }: PlayHistoryRef) =>
    `${type}:${serverId}:${itemId}`;

const getRecentItemKey = (entry: Pick<RecentItemInput, 'itemId' | 'mediaType' | 'serverId'>) =>
    playHistoryKey({
        itemId: entry.itemId,
        serverId: entry.serverId,
        type: entry.mediaType,
    });

const compactItems = (items: RecentItem[]) =>
    items
        .slice()
        .sort((a, b) => b.selectedAt - a.selectedAt)
        .slice(0, RECENT_ITEM_LIMIT);

interface PlayHistoryState {
    actions: {
        clear: () => void;
        pruneStale: (args: {
            knownItemIds: Set<string>;
            mediaType: RecentItemType;
            serverId: string;
        }) => void;
        record: (entry: RecentItemInput) => void;
        remove: (key: string) => void;
    };
    items: RecentItem[];
}

export const usePlayHistoryStore = create<PlayHistoryState>()(
    persist(
        (set) => ({
            actions: {
                clear: () => set({ items: [] }),
                pruneStale: ({ knownItemIds, mediaType, serverId }) =>
                    set((state) => {
                        const nextItems = state.items.filter((item) => {
                            if (item.mediaType !== mediaType) return true;
                            if (item.serverId !== serverId) return true;
                            return knownItemIds.has(item.itemId);
                        });

                        if (nextItems.length === state.items.length) {
                            return state;
                        }

                        return { items: nextItems };
                    }),
                record: (entry) => {
                    if (!entry.itemId || !entry.serverId) return;

                    const selectedAt = entry.selectedAt ?? Date.now();
                    const key = getRecentItemKey(entry);
                    const nextEntry: RecentItem = withCleanArtwork({
                        ...entry,
                        key,
                        selectedAt,
                    });

                    set((state) => ({
                        items: compactItems([
                            nextEntry,
                            // Dedup on the stable mediaType+itemId, not the full
                            // `key`: if serverId drifts across a restart the same
                            // item would otherwise accumulate a second entry.
                            ...state.items.filter(
                                (item) =>
                                    item.mediaType !== entry.mediaType ||
                                    item.itemId !== entry.itemId,
                            ),
                        ]),
                    }));
                },
                remove: (key) =>
                    set((state) => ({
                        items: state.items.filter((item) => item.key !== key),
                    })),
            },
            items: [],
        }),
        {
            // Entries written before artwork went token-free still carry a dead
            // token, and they are shown until 80 newer items push them out —
            // so they are rewritten on load rather than left to expire.
            migrate: (persisted, version) => {
                const state = identityPersistMigrate<Pick<PlayHistoryState, 'items'>>(
                    persisted,
                    version,
                );

                return { items: (state?.items ?? []).map(withCleanArtwork) };
            },
            name: 'recent-items-store',
            partialize: (state) => ({ items: state.items }),
            version: PERSIST_VERSION_STREAM_TOKEN_STRIPPED,
        },
    ),
);

export const recordRecentItem = (entry: RecentItemInput) => {
    usePlayHistoryStore.getState().actions.record(entry);
};

export const useRecentItems = () => usePlayHistoryStore((state) => state.items);

const countText = (count: null | number | undefined, singular: string) => {
    if (typeof count !== 'number') return undefined;
    return `${count} ${singular}${count === 1 ? '' : 's'}`;
};

const withDetail = (type: string, detail?: null | number | string) =>
    detail ? `${type} • ${detail}` : type;

export const recordRecentAlbum = (album: Album) => {
    if (!album._serverId) return;
    recordRecentItem({
        artwork: {
            imageId: album.imageId,
            imageItemType: LibraryItem.ALBUM,
            imageUrl: album.imageUrl,
            kind: 'music',
            serverId: album._serverId,
        },
        itemId: album.id,
        mediaType: 'album',
        serverId: album._serverId,
        subtitle: withDetail('Album', album.albumArtistName || countText(album.songCount, 'song')),
        title: album.name,
    });
};

export const recordRecentArtist = (
    artist: AlbumArtist | RelatedAlbumArtist | RelatedArtist,
    fallback?: { serverId?: string },
) => {
    const serverId = '_serverId' in artist ? artist._serverId : fallback?.serverId;
    if (!serverId) return;

    const imageId = 'imageId' in artist && artist.imageId ? artist.imageId : artist.id;

    recordRecentItem({
        artwork: {
            imageId,
            imageItemType: LibraryItem.ALBUM_ARTIST,
            imageUrl: 'imageUrl' in artist ? artist.imageUrl : null,
            kind: 'music',
            serverId,
            shape: 'circle',
        },
        itemId: artist.id,
        mediaType: 'artist',
        serverId,
        subtitle: withDetail(
            'Artist',
            'albumCount' in artist ? countText(artist.albumCount, 'album') : undefined,
        ),
        title: artist.name,
    });
};

export const recordRecentPlaylist = (playlist: Playlist) => {
    if (!playlist._serverId) return;
    recordRecentItem({
        artwork: {
            imageId: playlist.imageId,
            imageItemType: LibraryItem.PLAYLIST,
            imageUrl: playlist.imageUrl,
            kind: 'music',
            serverId: playlist._serverId,
        },
        itemId: playlist.id,
        mediaType: 'playlist',
        serverId: playlist._serverId,
        subtitle: withDetail('Playlist', countText(playlist.songCount, 'song')),
        title: playlist.name,
    });

    touchSamoPlaylistLastPlayed(playlist);
};

export const recordRecentSong = (song: Song) => {
    if (!song._serverId) return;
    recordRecentItem({
        artwork: {
            imageId: song.imageId,
            imageItemType: LibraryItem.SONG,
            imageUrl: song.imageUrl,
            kind: 'music',
            serverId: song._serverId,
        },
        itemId: song.id,
        mediaType: 'song',
        serverId: song._serverId,
        song,
        subtitle: withDetail('Song', song.artistName || song.album),
        title: song.name,
    });
};

export const recordRecentRadioStation = (station: InternetRadioStation, serverId: string) => {
    if (!serverId) return;
    recordRecentItem({
        artwork: {
            imageId: station.imageId,
            imageItemType: LibraryItem.RADIO_STATION,
            imageUrl: station.imageUrl,
            kind: 'music',
            serverId,
        },
        itemId: station.id,
        mediaType: 'radio',
        radioStreamUrl: station.streamUrl,
        serverId,
        subtitle: withDetail('Radio', station.homepageUrl || 'Internet station'),
        title: station.name,
    });
};

const getAbsTitle = (item: LongFormLibraryItem) =>
    item.media?.metadata?.title ?? item.name ?? 'Untitled';

const getAbsAuthor = (item: LongFormLibraryItem) => {
    const meta = item.media?.metadata;
    return (
        meta?.author ??
        meta?.authorName ??
        item.media?.authorName ??
        meta?.authors?.map((author) => author.name).join(', ') ??
        item.media?.authors?.map((author) => author.name).join(', ') ??
        ''
    );
};

export const recordRecentAudiobook = (item: LongFormLibraryItem, serverId: string) => {
    if (!serverId) return;
    const publishedYear =
        item.media?.metadata?.publishedYear ?? item.media?.publishedYear ?? undefined;

    recordRecentItem({
        artwork: {
            fallbackIcon: 'metadata',
            imageUrl: item.media?.metadata?.imageUrl,
            itemId: item.id,
            kind: 'abs',
        },
        itemId: item.id,
        mediaType: 'audiobook',
        rawAbsItem: item,
        serverId,
        subtitle: withDetail('Audiobook', getAbsAuthor(item) || publishedYear),
        title: getAbsTitle(item),
    });
};

export const recordRecentPodcast = (item: LongFormLibraryItem, serverId: string) => {
    if (!serverId) return;
    recordRecentItem({
        artwork: {
            fallbackIcon: 'microphone',
            imageUrl: item.media?.metadata?.imageUrl,
            itemId: item.id,
            kind: 'abs',
        },
        itemId: item.id,
        mediaType: 'podcast',
        rawAbsItem: item,
        serverId,
        subtitle: withDetail(
            'Podcast',
            countText(item.numEpisodes, 'episode') || getAbsAuthor(item),
        ),
        title: getAbsTitle(item),
    });
};