diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e2a1b6ad..f8a84317 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -24,6 +24,7 @@ } }, "common": { + "explicitStatus": "explicit status", "action_one": "action", "action_other": "actions", "add": "add", @@ -120,7 +121,9 @@ "unknown": "unknown", "version": "version", "year": "year", - "yes": "yes" + "yes": "yes", + "explicit": "explicit", + "clean": "clean" }, "entity": { "album_one": "album", @@ -227,7 +230,8 @@ "songCount": "song count", "title": "title", "toYear": "to year", - "trackNumber": "track" + "trackNumber": "track", + "explicitStatus": "$t(common.explicitStatus)" }, "form": { "addServer": { diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index a2583670..f7121cf9 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -34,6 +34,7 @@ const ALBUM_LIST_SORT_MAPPING: Record { + const { t } = useTranslation(); + return ( <> {rows.map((row, index: number) => { @@ -66,7 +76,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => { > {row.arrayProperty && (row.format - ? row.format(item) + ? row.format(item, t) : item[row.arrayProperty])} @@ -92,7 +102,9 @@ export const CardRows = ({ data, rows }: CardRowsProps) => { size={index > 0 ? 'sm' : 'md'} > {row.arrayProperty && - (row.format ? row.format(item) : item[row.arrayProperty])} + (row.format + ? row.format(item, t) + : item[row.arrayProperty])} ))} @@ -123,7 +135,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => { }, {}), )} > - {data && (row.format ? row.format(data) : data[row.property])} + {data && (row.format ? row.format(data, t) : data[row.property])} ) : ( { overflow="hidden" size={index > 0 ? 'sm' : 'md'} > - {data && (row.format ? row.format(data) : data[row.property])} + {data && (row.format ? row.format(data, t) : data[row.property])} )} @@ -167,6 +179,15 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow } = { format: (album) => (album.duration === null ? null : formatDuration(album.duration)), property: 'duration', }, + explicitStatus: { + format: (album, t) => + album.explicitStatus === ExplicitStatus.EXPLICIT + ? t('common.explicit', { postProcess: 'sentenceCase' }) + : album.explicitStatus === ExplicitStatus.CLEAN + ? t('common.clean', { postProcess: 'sentenceCase' }) + : null, + property: 'explicitStatus', + }, lastPlayedAt: { format: (album) => formatDateRelative(album.lastPlayedAt), property: 'lastPlayedAt', @@ -228,6 +249,15 @@ export const SONG_CARD_ROWS: { [key: string]: CardRow } = { format: (song) => (song.duration === null ? null : formatDuration(song.duration)), property: 'duration', }, + explicitStatus: { + format: (song, t) => + song.explicitStatus === ExplicitStatus.EXPLICIT + ? t('common.explicit', { postProcess: 'sentenceCase' }) + : song.explicitStatus === ExplicitStatus.CLEAN + ? t('common.clean', { postProcess: 'sentenceCase' }) + : null, + property: 'explicitStatus', + }, lastPlayedAt: { format: (song) => formatDateRelative(song.lastPlayedAt), property: 'lastPlayedAt', diff --git a/src/renderer/features/albums/components/album-list-grid-view.tsx b/src/renderer/features/albums/components/album-list-grid-view.tsx index d7b53a43..c8e98198 100644 --- a/src/renderer/features/albums/components/album-list-grid-view.tsx +++ b/src/renderer/features/albums/components/album-list-grid-view.tsx @@ -58,6 +58,10 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => { rows.push(ALBUM_CARD_ROWS.albumArtists); rows.push(ALBUM_CARD_ROWS.duration); break; + case AlbumListSort.EXPLICIT_STATUS: + rows.push(ALBUM_CARD_ROWS.albumArtists); + rows.push(ALBUM_CARD_ROWS.explicitStatus); + break; case AlbumListSort.FAVORITED: rows.push(ALBUM_CARD_ROWS.albumArtists); rows.push(ALBUM_CARD_ROWS.releaseYear); diff --git a/src/renderer/features/albums/components/album-list-header-filters.tsx b/src/renderer/features/albums/components/album-list-header-filters.tsx index 0fcdeea6..4a5ddef3 100644 --- a/src/renderer/features/albums/components/album-list-header-filters.tsx +++ b/src/renderer/features/albums/components/album-list-header-filters.tsx @@ -102,6 +102,11 @@ const FILTERS = { name: i18n.t('filter.duration', { postProcess: 'titleCase' }), value: AlbumListSort.DURATION, }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.explicitStatus', { postProcess: 'titleCase' }), + value: AlbumListSort.EXPLICIT_STATUS, + }, { defaultOrder: SortOrder.DESC, name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }), diff --git a/src/renderer/features/item-details/components/item-details-modal.tsx b/src/renderer/features/item-details/components/item-details-modal.tsx index aa96d959..0e8b791e 100644 --- a/src/renderer/features/item-details/components/item-details-modal.tsx +++ b/src/renderer/features/item-details/components/item-details-modal.tsx @@ -16,6 +16,7 @@ import { Separator } from '/@/shared/components/separator/separator'; import { Spoiler } from '/@/shared/components/spoiler/spoiler'; import { Table } from '/@/shared/components/table/table'; import { Text } from '/@/shared/components/text/text'; +import { ExplicitStatus } from '/@/shared/types/domain-types'; import { Album, AlbumArtist, @@ -34,14 +35,14 @@ type ItemDetailRow = { key?: keyof T; label: string; postprocess?: string[]; - render?: (item: T) => ReactNode; + render?: (item: T, t: TFunction) => ReactNode; }; const handleRow = (t: TFunction, item: T, rule: ItemDetailRow) => { let value: ReactNode; if (rule.render) { - value = rule.render(item); + value = rule.render(item, t); } else { const prop = item[rule.key!]; value = prop !== undefined && prop !== null ? String(prop) : null; @@ -128,6 +129,15 @@ const AlbumPropertyMapping: ItemDetailRow[] = [ }, { key: 'releaseYear', label: 'filter.releaseYear' }, { key: 'songCount', label: 'filter.songCount' }, + { + label: 'filter.explicitStatus', + render: (album, t) => + album.explicitStatus === ExplicitStatus.EXPLICIT + ? t('common.explicit', { postProcess: 'sentenceCase' }) + : album.explicitStatus === ExplicitStatus.CLEAN + ? t('common.clean', { postProcess: 'sentenceCase' }) + : null, + }, { label: 'filter.isCompilation', render: (album) => BoolField(album.isCompilation || false) }, { key: 'size', @@ -266,6 +276,15 @@ const SongPropertyMapping: ItemDetailRow[] = [ { key: 'discNumber', label: 'common.disc' }, { key: 'trackNumber', label: 'common.trackNumber' }, { key: 'releaseYear', label: 'filter.releaseYear' }, + { + label: 'filter.explicitStatus', + render: (song, t) => + song.explicitStatus === ExplicitStatus.EXPLICIT + ? t('common.explicit', { postProcess: 'sentenceCase' }) + : song.explicitStatus === ExplicitStatus.CLEAN + ? t('common.clean', { postProcess: 'sentenceCase' }) + : null, + }, { label: 'entity.genre_other', render: FormatGenre }, { label: 'common.duration', diff --git a/src/renderer/features/songs/components/song-list-grid-view.tsx b/src/renderer/features/songs/components/song-list-grid-view.tsx index c19a51f3..b2cc941b 100644 --- a/src/renderer/features/songs/components/song-list-grid-view.tsx +++ b/src/renderer/features/songs/components/song-list-grid-view.tsx @@ -85,6 +85,9 @@ export const SongListGridView = ({ gridRef, itemCount }: SongListGridViewProps) case SongListSort.DURATION: rows.push(SONG_CARD_ROWS.duration); break; + case SongListSort.EXPLICIT_STATUS: + rows.push(SONG_CARD_ROWS.explicitStatus); + break; case SongListSort.FAVORITED: break; case SongListSort.NAME: diff --git a/src/renderer/features/songs/components/song-list-header-filters.tsx b/src/renderer/features/songs/components/song-list-header-filters.tsx index d019fa01..734d6625 100644 --- a/src/renderer/features/songs/components/song-list-header-filters.tsx +++ b/src/renderer/features/songs/components/song-list-header-filters.tsx @@ -133,6 +133,11 @@ const FILTERS = { name: i18n.t('filter.duration', { postProcess: 'titleCase' }), value: SongListSort.DURATION, }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.explicitStatus', { postProcess: 'titleCase' }), + value: SongListSort.EXPLICIT_STATUS, + }, { defaultOrder: SortOrder.DESC, name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }), diff --git a/src/shared/api/jellyfin/jellyfin-normalize.ts b/src/shared/api/jellyfin/jellyfin-normalize.ts index 7e692ba7..5e6cb469 100644 --- a/src/shared/api/jellyfin/jellyfin-normalize.ts +++ b/src/shared/api/jellyfin/jellyfin-normalize.ts @@ -245,6 +245,7 @@ const normalizeSong = ( discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1, discSubtitle: null, duration: item.RunTimeTicks / 10000, + explicitStatus: null, gain: item.NormalizationGain !== undefined ? { @@ -317,6 +318,7 @@ const normalizeAlbum = ( comment: null, createdAt: item.DateCreated, duration: item.RunTimeTicks / 10000, + explicitStatus: null, genres: item.GenreItems?.map((entry) => ({ id: entry.Id, imageUrl: null, diff --git a/src/shared/api/navidrome.types.ts b/src/shared/api/navidrome.types.ts index c4721278..2cfb83be 100644 --- a/src/shared/api/navidrome.types.ts +++ b/src/shared/api/navidrome.types.ts @@ -13,6 +13,7 @@ export enum NDAlbumListSort { ALBUM_ARTIST = 'album_artist', ARTIST = 'artist', DURATION = 'duration', + EXPLICIT_STATUS = 'explicitStatus', NAME = 'name', PLAY_COUNT = 'play_count', PLAY_DATE = 'play_date', @@ -46,6 +47,7 @@ export enum NDSongListSort { CHANNELS = 'channels', COMMENT = 'comment', DURATION = 'duration', + EXPLICIT_STATUS = 'explicitStatus', FAVORITED = 'starred_at', GENRE = 'genre', ID = 'id', diff --git a/src/shared/api/navidrome/navidrome-normalize.ts b/src/shared/api/navidrome/navidrome-normalize.ts index 58b24070..92a4056a 100644 --- a/src/shared/api/navidrome/navidrome-normalize.ts +++ b/src/shared/api/navidrome/navidrome-normalize.ts @@ -7,6 +7,7 @@ import { ssType } from '/@/shared/api/subsonic/subsonic-types'; import { Album, AlbumArtist, + ExplicitStatus, Genre, LibraryItem, Playlist, @@ -164,6 +165,12 @@ const normalizeSong = ( discNumber: item.discNumber, discSubtitle: item.discSubtitle ? item.discSubtitle : null, duration: item.duration * 1000, + explicitStatus: + item.explicitStatus === 'e' + ? ExplicitStatus.EXPLICIT + : item.explicitStatus === 'c' + ? ExplicitStatus.CLEAN + : null, gain: item.rgAlbumGain || item.rgTrackGain ? { album: item.rgAlbumGain, track: item.rgTrackGain } @@ -237,6 +244,12 @@ const normalizeAlbum = ( comment: item.comment || null, createdAt: item.createdAt.split('T')[0], duration: item.duration !== undefined ? item.duration * 1000 : null, + explicitStatus: + item.explicitStatus === 'e' + ? ExplicitStatus.EXPLICIT + : item.explicitStatus === 'c' + ? ExplicitStatus.CLEAN + : null, genres: (item.genres || []).map((genre) => ({ id: genre.id, imageUrl: null, diff --git a/src/shared/api/navidrome/navidrome-types.ts b/src/shared/api/navidrome/navidrome-types.ts index e0d217e3..914903d2 100644 --- a/src/shared/api/navidrome/navidrome-types.ts +++ b/src/shared/api/navidrome/navidrome-types.ts @@ -134,6 +134,7 @@ const album = z.object({ coverArtPath: z.string().optional(), // Removed after v0.48.0 createdAt: z.string(), duration: z.number().optional(), + explicitStatus: z.string().optional(), fullText: z.string(), genre: z.string(), genres: z.array(genre).nullable(), @@ -200,6 +201,7 @@ const song = z.object({ discSubtitle: z.string().optional(), duration: z.number(), embedArtPath: z.string().optional(), + explicitStatus: z.string().optional(), externalInfoUpdatedAt: z.string().optional(), externalUrl: z.string().optional(), fullText: z.string(), diff --git a/src/shared/api/subsonic/subsonic-normalize.ts b/src/shared/api/subsonic/subsonic-normalize.ts index c989ed59..2721bc40 100644 --- a/src/shared/api/subsonic/subsonic-normalize.ts +++ b/src/shared/api/subsonic/subsonic-normalize.ts @@ -5,6 +5,7 @@ import { ssType } from '/@/shared/api/subsonic/subsonic-types'; import { Album, AlbumArtist, + ExplicitStatus, Genre, LibraryItem, Playlist, @@ -146,6 +147,12 @@ const normalizeSong = ( discNumber: item.discNumber || 1, discSubtitle: null, duration: item.duration ? item.duration * 1000 : 0, + explicitStatus: + item.explicitStatus === 'explicit' + ? ExplicitStatus.EXPLICIT + : item.explicitStatus === 'clean' + ? ExplicitStatus.CLEAN + : null, gain: item.replayGain && (item.replayGain.albumGain || item.replayGain.trackGain) ? { @@ -247,6 +254,12 @@ const normalizeAlbum = ( comment: null, createdAt: item.created, duration: item.duration * 1000, + explicitStatus: + item.explicitStatus === 'explicit' + ? ExplicitStatus.EXPLICIT + : item.explicitStatus === 'clean' + ? ExplicitStatus.CLEAN + : null, genres: getGenres(item), id: item.id.toString(), imagePlaceholderUrl: null, diff --git a/src/shared/api/subsonic/subsonic-types.ts b/src/shared/api/subsonic/subsonic-types.ts index 82a182e0..973385a6 100644 --- a/src/shared/api/subsonic/subsonic-types.ts +++ b/src/shared/api/subsonic/subsonic-types.ts @@ -95,6 +95,7 @@ const song = z.object({ created: z.string(), discNumber: z.number(), duration: z.number().optional(), + explicitStatus: z.string().optional(), genre: z.string().optional(), genres: z.array(genreItem).optional(), id, @@ -125,6 +126,7 @@ const album = z.object({ coverArt: z.string(), created: z.string(), duration: z.number(), + explicitStatus: z.string().optional(), genre: z.string().optional(), genres: z.array(genreItem).optional(), id, diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 315b4328..23655ffb 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -129,6 +129,11 @@ export const sortOrderMap: SortOrderMap = { }, }; +export enum ExplicitStatus { + CLEAN = 'CLEAN', + EXPLICIT = 'EXPLICIT', +} + export enum ExternalSource { LASTFM = 'LASTFM', MUSICBRAINZ = 'MUSICBRAINZ', @@ -160,6 +165,7 @@ export type Album = { comment: null | string; createdAt: string; duration: null | number; + explicitStatus: ExplicitStatus | null; genres: Genre[]; id: string; imagePlaceholderUrl: null | string; @@ -332,6 +338,7 @@ export type Song = { discNumber: number; discSubtitle: null | string; duration: number; + explicitStatus: ExplicitStatus | null; gain: GainInfo | null; genres: Genre[]; id: string; @@ -394,6 +401,7 @@ export enum AlbumListSort { COMMUNITY_RATING = 'communityRating', CRITIC_RATING = 'criticRating', DURATION = 'duration', + EXPLICIT_STATUS = 'explicitStatus', FAVORITED = 'favorited', NAME = 'name', PLAY_COUNT = 'playCount', @@ -441,6 +449,7 @@ export const albumListSortMap: AlbumListSortMap = { communityRating: JFAlbumListSort.COMMUNITY_RATING, criticRating: JFAlbumListSort.CRITIC_RATING, duration: undefined, + explicitStatus: undefined, favorited: undefined, name: JFAlbumListSort.NAME, playCount: JFAlbumListSort.PLAY_COUNT, @@ -458,6 +467,7 @@ export const albumListSortMap: AlbumListSortMap = { communityRating: undefined, criticRating: undefined, duration: NDAlbumListSort.DURATION, + explicitStatus: NDAlbumListSort.EXPLICIT_STATUS, favorited: NDAlbumListSort.STARRED, name: NDAlbumListSort.NAME, playCount: NDAlbumListSort.PLAY_COUNT, @@ -476,6 +486,7 @@ export const albumListSortMap: AlbumListSortMap = { communityRating: undefined, criticRating: undefined, duration: undefined, + explicitStatus: undefined, favorited: undefined, name: undefined, playCount: undefined, @@ -497,6 +508,7 @@ export enum SongListSort { CHANNELS = 'channels', COMMENT = 'comment', DURATION = 'duration', + EXPLICIT_STATUS = 'explicitStatus', FAVORITED = 'favorited', GENRE = 'genre', ID = 'id', @@ -562,6 +574,7 @@ export const songListSortMap: SongListSortMap = { channels: undefined, comment: undefined, duration: JFSongListSort.DURATION, + explicitStatus: undefined, favorited: undefined, genre: undefined, id: undefined, @@ -582,6 +595,7 @@ export const songListSortMap: SongListSortMap = { channels: NDSongListSort.CHANNELS, comment: NDSongListSort.COMMENT, duration: NDSongListSort.DURATION, + explicitStatus: NDSongListSort.EXPLICIT_STATUS, favorited: NDSongListSort.FAVORITED, genre: NDSongListSort.GENRE, id: NDSongListSort.ID, @@ -602,6 +616,7 @@ export const songListSortMap: SongListSortMap = { channels: undefined, comment: undefined, duration: undefined, + explicitStatus: undefined, favorited: undefined, genre: undefined, id: undefined, diff --git a/src/shared/types/types.ts b/src/shared/types/types.ts index b3704528..9cd0f13d 100644 --- a/src/shared/types/types.ts +++ b/src/shared/types/types.ts @@ -1,5 +1,6 @@ import { AppRoute } from '@ts-rest/core'; import { ReactNode } from 'react'; +import { TFunction } from 'react-i18next'; import { Song } from 'src/main/features/core/lyrics/netease'; import { @@ -40,7 +41,7 @@ export type CardRoute = { export type CardRow = { arrayProperty?: string; - format?: (value: T) => ReactNode; + format?: (value: T, t: TFunction) => ReactNode; property: keyof T; route?: CardRoute; };