diff --git a/README.md b/README.md index 5bb4f900..30b33db5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MediaTracker · [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/bonukai/MediaTracker/blob/main/LICENSE.md) [![Join the chat at https://gitter.im/bonukai/MediaTracker](https://badges.gitter.im/bonukai/MediaTracker.svg)](https://gitter.im/bonukai/MediaTracker?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Crowdin](https://badges.crowdin.net/mediatracker/localized.svg)](https://crowdin.com/project/mediatracker) [![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/bonukai/mediatracker)](https://hub.docker.com/r/bonukai/mediatracker) [![Docker Pulls](https://img.shields.io/docker/pulls/bonukai/mediatracker)](https://hub.docker.com/r/bonukai/mediatracker) [![CodeFactor](https://www.codefactor.io/repository/github/bonukai/mediatracker/badge)](https://www.codefactor.io/repository/github/bonukai/mediatracker) [![codecov](https://codecov.io/gh/bonukai/MediaTracker/branch/main/graph/badge.svg?token=CPMW6R7M1Z)](https://codecov.io/gh/bonukai/MediaTracker) -Self hosted platform for tracking movies, tv shows, video games, books and audiobooks, highly inspired by [flox](https://github.com/devfake/flox) +Self hosted platform for tracking movies, tv shows, video games, books, audiobooks and music albums, highly inspired by [flox](https://github.com/devfake/flox) # Demo @@ -150,6 +150,8 @@ docker run -p 7481:7481 mediatracker | [IGDB](https://www.igdb.com/)\* | video game | ✗ | | [Audible API](https://audible.readthedocs.io/en/latest/misc/external_api.html) | audiobooks | ✓ | | [Open Library](https://openlibrary.org/) | books | ✗ | +| [MusicBrainz](https://musicbrainz.org/) | music | ✗ | + \* IGDB has a limit of 4 requests per second. Because of that IGDB API key is not provided with MediaTracker, it can be acquired [here](https://api-docs.igdb.com/#account-creation) and set in [http://localhost:7481/#/settings/configuration](http://localhost:7481/#/settings/configuration) diff --git a/app.json b/app.json index a92439b1..5c1facb7 100644 --- a/app.json +++ b/app.json @@ -1,6 +1,6 @@ { "name": "MediaTracker", - "description": "Self hosted media tracker for movies, tv shows, video games, books and audiobooks", + "description": "Self hosted media tracker for movies, tv shows, video games, books, audiobooks, and music albums", "repository": "https://github.com/bonukai/MediaTracker", "stack": "container", "env": { diff --git a/client/src/Router.tsx b/client/src/Router.tsx index 4ee0d825..9b85683b 100644 --- a/client/src/Router.tsx +++ b/client/src/Router.tsx @@ -75,10 +75,14 @@ export const MyRouter: FunctionComponent = () => { path="/audiobooks" element={} /> + } + /> } + element={} /> = (props) => { - const { mediaItem, season, episode, useSeasonAndEpisodeNumber } = { - useSeasonAndEpisodeNumber: false, - ...props, - }; - - return ( - <> - ( -
- {isAudiobook(mediaItem) && Add to listened history} - {isBook(mediaItem) && Add to read history} - {isVideoGame(mediaItem) && Add to played history} - {isMovie(mediaItem) && Add to seen history} - {isTvShow(mediaItem) && - (useSeasonAndEpisodeNumber ? ( - episode ? ( - - Add {formatEpisodeNumber(episode)} to seen history - - ) : season ? ( - - Add {formatSeasonNumber(season)} to seen history - - ) : ( - Add to seen history - ) - ) : episode ? ( - Add episode to seen history - ) : season ? ( - Add season to seen history - ) : ( - Add to seen history - ))} -
- )} - > - {(closeModal) => ( - - )} -
- - ); -}; - -export const RemoveFromSeenHistoryButton: FunctionComponent<{ - mediaItem: MediaItemDetailsResponse; - season?: TvSeason; - episode?: TvEpisode; - seenId?: number; -}> = (props) => { - const { mediaItem, season, episode, seenId } = props; - - const seasonEpisodesIdSet = new Set( - season?.episodes?.map((episode) => episode.id) - ); - - const count = - seenId !== undefined - ? 1 - : episode - ? mediaItem.seenHistory?.filter( - (entry) => entry.episodeId === episode?.id - ).length - : season - ? mediaItem.seenHistory?.filter((entry) => - seasonEpisodesIdSet.has(entry.episodeId) - ).length - : mediaItem.seenHistory?.length; - - return ( -
- (await Confirm( - plural(count, { - one: 'Do you want to remove # seen history entry?', - other: 'Do you want to remove all # seen history entries?', - }) - )) && - markAsUnseen({ - mediaItem: mediaItem, - season: season, - episode: episode, - seenId: seenId, - }) - } - > - {isAudiobook(mediaItem) && Remove from listened history} - {isBook(mediaItem) && Remove from read history} - {isVideoGame(mediaItem) && Remove from played history} - {isMovie(mediaItem) && Remove from seen history} - {isTvShow(mediaItem) && - (episode ? ( - Remove episode from seen history - ) : season ? ( - Remove season from seen history - ) : ( - Remove from seen history - ))} -
- ); -}; +import React, { FunctionComponent } from 'react'; +import { plural, Trans } from '@lingui/macro'; + +import { + MediaItemDetailsResponse, + MediaItemItemsResponse, + TvEpisode, + TvSeason, +} from 'mediatracker-api'; +import { + formatEpisodeNumber, + formatSeasonNumber, + isAudiobook, + isBook, + isMovie, + isMusic, + isTvShow, + isVideoGame, +} from 'src/utils'; +import { Modal } from 'src/components/Modal'; +import { SelectSeenDate } from 'src/components/SelectSeenDate'; +import { markAsUnseen } from 'src/api/details'; +import { Confirm } from 'src/components/Confirm'; + +export const AddToSeenHistoryButton: FunctionComponent<{ + mediaItem: MediaItemItemsResponse; + season?: TvSeason; + episode?: TvEpisode; + useSeasonAndEpisodeNumber?: boolean; +}> = (props) => { + const { mediaItem, season, episode, useSeasonAndEpisodeNumber } = { + useSeasonAndEpisodeNumber: false, + ...props, + }; + + return ( + <> + ( +
+ {isAudiobook(mediaItem) && Add to listened history} + {isBook(mediaItem) && Add to read history} + {isMusic(mediaItem) && Add to listened history} + {isVideoGame(mediaItem) && Add to played history} + {isMovie(mediaItem) && Add to seen history} + {isTvShow(mediaItem) && + (useSeasonAndEpisodeNumber ? ( + episode ? ( + + Add {formatEpisodeNumber(episode)} to seen history + + ) : season ? ( + + Add {formatSeasonNumber(season)} to seen history + + ) : ( + Add to seen history + ) + ) : episode ? ( + Add episode to seen history + ) : season ? ( + Add season to seen history + ) : ( + Add to seen history + ))} +
+ )} + > + {(closeModal) => ( + + )} +
+ + ); +}; + +export const RemoveFromSeenHistoryButton: FunctionComponent<{ + mediaItem: MediaItemDetailsResponse; + season?: TvSeason; + episode?: TvEpisode; + seenId?: number; +}> = (props) => { + const { mediaItem, season, episode, seenId } = props; + + const seasonEpisodesIdSet = new Set( + season?.episodes?.map((episode) => episode.id) + ); + + const count = + seenId !== undefined + ? 1 + : episode + ? mediaItem.seenHistory?.filter( + (entry) => entry.episodeId === episode?.id + ).length + : season + ? mediaItem.seenHistory?.filter((entry) => + seasonEpisodesIdSet.has(entry.episodeId) + ).length + : mediaItem.seenHistory?.length; + + return ( +
+ (await Confirm( + plural(count, { + one: 'Do you want to remove # seen history entry?', + other: 'Do you want to remove all # seen history entries?', + }) + )) && + markAsUnseen({ + mediaItem: mediaItem, + season: season, + episode: episode, + seenId: seenId, + }) + } + > + {isAudiobook(mediaItem) && Remove from listened history} + {isBook(mediaItem) && Remove from read history} + {isMusic(mediaItem) && Remove from listened history} + {isVideoGame(mediaItem) && Remove from played history} + {isMovie(mediaItem) && Remove from seen history} + {isTvShow(mediaItem) && + (episode ? ( + Remove episode from seen history + ) : season ? ( + Remove season from seen history + ) : ( + Remove from seen history + ))} +
+ ); +}; diff --git a/client/src/components/FilterBy.tsx b/client/src/components/FilterBy.tsx index 5b492be8..5de5c3e4 100644 --- a/client/src/components/FilterBy.tsx +++ b/client/src/components/FilterBy.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { t } from '@lingui/macro'; import { MediaType } from 'mediatracker-api'; -import { isAudiobook, isBook, isVideoGame, reverseMap } from 'src/utils'; +import { isAudiobook, isBook, isMusic, isVideoGame, reverseMap } from 'src/utils'; import { useMenuComponent } from 'src/hooks/menu'; const useFilterTextMap = (mediaType: MediaType) => { @@ -11,7 +11,7 @@ const useFilterTextMap = (mediaType: MediaType) => { onlyWithUserRating: t`Rated`, onlyWithoutUserRating: t`Unrated`, onlyOnWatchlist: t`On watchlist`, - onlySeenItems: isAudiobook(mediaType) + onlySeenItems: isAudiobook(mediaType) || isMusic(mediaType) ? t`Listened` : isBook(mediaType) ? t`Read` diff --git a/client/src/components/GridItem.tsx b/client/src/components/GridItem.tsx index 4910af23..70fd1e36 100644 --- a/client/src/components/GridItem.tsx +++ b/client/src/components/GridItem.tsx @@ -81,6 +81,7 @@ export const GridItem: FunctionComponent<{ movie: t`Movie`, tv: t`Tv`, video_game: t`Video game`, + music: t`Music`, }; if (season && episode) { diff --git a/client/src/components/Nav.tsx b/client/src/components/Nav.tsx index f6d88a6a..8e28e3f1 100644 --- a/client/src/components/Nav.tsx +++ b/client/src/components/Nav.tsx @@ -15,6 +15,7 @@ export const useRouteNames = () => { { path: '/games', name: t`Games` }, { path: '/books', name: t`Books` }, { path: '/audiobooks', name: t`Audiobooks` }, + { path: '/music', name: t`Music` }, { path: '/upcoming', name: t`Upcoming` }, { path: '/in-progress', name: t`In progress` }, { path: '/watchlist', name: t`Watchlist` }, diff --git a/client/src/components/Poster.tsx b/client/src/components/Poster.tsx index 2326738e..216ef59c 100644 --- a/client/src/components/Poster.tsx +++ b/client/src/components/Poster.tsx @@ -209,7 +209,7 @@ const PosterSpring: FunctionComponent<{ }; const tailwindcssAspectRatioForMediaType = (mediaType?: MediaType) => { - if (mediaType === 'audiobook') { + if (mediaType === 'audiobook' || mediaType === 'music') { return 'aspect-[1/1]'; } @@ -220,16 +220,4 @@ const tailwindcssAspectRatioForMediaType = (mediaType?: MediaType) => { return 'aspect-[2/3]'; }; -const aspectRatioForMediaType = (mediaType?: MediaType) => { - if (mediaType === 'audiobook') { - return 1 / 1; - } - - if (mediaType === 'video_game') { - return 3 / 4; - } - - return 2 / 3; -}; - export { PosterSpring as Poster }; diff --git a/client/src/components/SelectSeenDate.tsx b/client/src/components/SelectSeenDate.tsx index dda05a9d..998162f4 100644 --- a/client/src/components/SelectSeenDate.tsx +++ b/client/src/components/SelectSeenDate.tsx @@ -17,6 +17,7 @@ import { isAudiobook, isBook, isMovie, + isMusic, isTvShow, isVideoGame, } from 'src/utils'; @@ -81,6 +82,10 @@ export const SelectSeenDateComponent: FunctionComponent<{ When did you read "{mediaItem.title}"? )} + {isMusic(mediaItem) && ( + When did you listen to "{mediaItem.title}"? + )} + {isMovie(mediaItem) && ( When did you see "{mediaItem.title}"? )} diff --git a/client/src/components/SetProgress.tsx b/client/src/components/SetProgress.tsx index 969553ec..a03d6f1e 100644 --- a/client/src/components/SetProgress.tsx +++ b/client/src/components/SetProgress.tsx @@ -3,7 +3,7 @@ import { Plural, Trans } from '@lingui/macro'; import { MediaItemItemsResponse, MediaType } from 'mediatracker-api'; import { addToProgress } from 'src/api/details'; -import { isAudiobook, isBook, isMovie, isVideoGame } from 'src/utils'; +import { isAudiobook, isBook, isMovie, isMusic, isVideoGame } from 'src/utils'; const InputComponent: FunctionComponent<{ max: number; @@ -38,7 +38,7 @@ const InputComponent: FunctionComponent<{ }} />{' '} {isBook(mediaType) && } - {(isAudiobook(mediaType) || isMovie(mediaType)) && ( + {(isAudiobook(mediaType) || isMovie(mediaType) || isMusic(mediaType)) && ( )} @@ -80,7 +80,7 @@ export const SetProgressComponent: FunctionComponent<{ /> )} - {(isAudiobook(mediaItem) || isMovie(mediaItem)) && + {(isAudiobook(mediaItem) || isMovie(mediaItem) || isMusic(mediaItem)) && mediaItem.runtime && ( { )} + {data.music?.plays > 0 && ( +
+
+ Music +
+ {data.music.duration > 0 && ( +
+ + + {' '} + + listening + +
+ )} +
+ + {data.music.items} albums ( + {data.music.plays} plays) + +
+
+ )} )} diff --git a/client/src/i18n/locales/da/translation.json b/client/src/i18n/locales/da/translation.json index b1f37860..3129e530 100644 --- a/client/src/i18n/locales/da/translation.json +++ b/client/src/i18n/locales/da/translation.json @@ -3,6 +3,7 @@ "<0><1/> playing": "<0><1/> afspiller", "<0><1/> reading": "<0><1/> læser", "<0><1/> watching": "<0><1/> afspiller", + "<0>{0} albums (<1>{1} plays)": "", "<0>{0} audiobooks (<1>{1} plays)": "<0>{0} lydbøger (<1>{1} afspilninger)", "<0>{0} books (<1>{1} reads)": "<0>{0} bøger (<1>{1} læste)", "<0>{0} episodes (<1>{1} plays of <2>{2} shows)": "<0>{0} episoder (<1>{1} afspilninger af <2>{2} serier)", @@ -71,13 +72,14 @@ "Games": "Spil", "General": "Generelt", "Go to <0>https://www.goodreads.com/review/list and copy RSS url from the bottom of the page": "Gå til <0>https://www.goodreads.com/review/list og kopier RSS-url'en fra bunden af siden", + "Go to <0>{0} and enter following code: <1>{1}": "", "Go to <0>{0} and enter following code: {1}": "Gå til <0>{0} og insæt følgende: {1}", "Gotify server url": "Gotify server url", "Hide overview of unseen seasons": "Skjul oversigt over uafspillede sæsoner", "Hide title of unseen episodes": "Skjul titel på uafspillede sæsoner", "Home": "Hjem", "Http": "Http", - "I am listening it": "Jeg lytter til den", + "I am listening to it": "Jeg lytter til den", "I am playing it": "Jeg spiller det", "I am reading it": "Jeg læser den", "I am watching it": "Jeg ser den", @@ -102,6 +104,10 @@ "Last read at {0}": "Sidst læst den {0}", "Last seen": "Sidst afspillet", "Last seen at {0}": "Sidst afspillet den {0}", + "List {0} - episodes": "", + "List {0} - movies": "", + "List {0} - seasons": "", + "List {0} - shows": "", "List: {0}": "Liste: {0}", "Listened": "Afspillet", "Listened at {0}": "Afspillet den {0}", @@ -121,6 +127,7 @@ "Movie": "Film", "Movies": "Film", "Movies, shows, seasons, episodes, books, audiobooks and video games I plan to watch/read/listen/play": "Film, Tv-shows, sæsoner, episoder, bøger, lydbøger og videspil jeg har planer om at se/læse/lytte til/spille", + "Music": "", "My rating": "Min bedømmelse", "Name": "Navn", "Network": "Netværk", @@ -132,6 +139,7 @@ "No": "Nej", "No date": "Ingen dato", "No more logs": "Der er ikke mere i loggen", + "Not imported items": "", "Notifications": "Notifikationer", "Now": "Nu", "Number of episodes": "Antal episoder", @@ -157,6 +165,10 @@ "Rated": "Bedømt", "Rating": "Bedømmelse", "Ratings": "Bedømmelser", + "Ratings - episodes": "", + "Ratings - movies": "", + "Ratings - seasons": "", + "Ratings - shows": "", "Read": "Læs", "Read at {0}": "Læst den {0}", "Read history": "Læsehistorik", @@ -196,6 +208,8 @@ "Seasons": "Sæsoner", "Seen at {0}": "Set den {0}", "Seen history": "Historik", + "Seen history - episodes": "", + "Seen history - movies": "", "Select": "Vælg", "Select date": "Vælg dato", "Send notification for episodes releases": "Send notifikation for nyudgivne episoder", @@ -242,6 +256,11 @@ "Warning": "Advarsel", "Watched": "Set", "Watchlist": "Overvågningsliste", + "Watchlist - episodes": "", + "Watchlist - movies": "", + "Watchlist - season": "", + "Watchlist - shows": "", + "Webhook URL": "", "What is the last episode of \"{0}\" you see?": "Hvilken episode af \"{0}\" har du sidst set?", "What is the last episode you see?": "Hvilken episode så du sidst?", "When did you listen it?": "Hvornår lyttede du til det?", @@ -252,6 +271,7 @@ "When did you read it?": "Hvornår læste du den?", "When did you see \"{0}\"?": "Hvornår så du \"{0}\"?", "When did you see it?": "Hvornår så du den?", + "Where to watch": "", "Yes": "Ja", "import": "importér", "{0, plural, one { Found # item for query \"<0>{searchQuery}\"} other { Found # items for query \"<1>{searchQuery}\"}}": "{0, plural, one { Fandt # punkt for forespørgslen \"<0>{searchQuery}\"} other { Fandt # punkt i forespørgslen \"<1>{searchQuery}\"}}", diff --git a/client/src/i18n/locales/de/translation.json b/client/src/i18n/locales/de/translation.json index 6ec169cc..ee2f8df3 100644 --- a/client/src/i18n/locales/de/translation.json +++ b/client/src/i18n/locales/de/translation.json @@ -3,6 +3,7 @@ "<0><1/> playing": "<0><1/> spiele gerade\n", "<0><1/> reading": "<0><1/> lese gerade", "<0><1/> watching": "<0><1/> schaue gerade", + "<0>{0} albums (<1>{1} plays)": "", "<0>{0} audiobooks (<1>{1} plays)": "<0>{0} Hörbücher (<1>{1} abgespielt)\n", "<0>{0} books (<1>{1} reads)": "<0>{0} Bücher (<1>{1} gelesen)", "<0>{0} episodes (<1>{1} plays of <2>{2} shows)": "<0>{0} Folgen (<1>{1} geschaut von <2>{2} Serien)", @@ -79,7 +80,7 @@ "Hide title of unseen episodes": "Titel der ungesehenen Folgen ausblenden", "Home": "Startseite", "Http": "Http", - "I am listening it": "Ich höre es gerade", + "I am listening to it": "Ich höre es gerade", "I am playing it": "Ich spiele es gerade", "I am reading it": "Ich lese es gerade", "I am watching it": "Ich schaue es gerade", @@ -127,6 +128,7 @@ "Movie": "Film", "Movies": "Filme", "Movies, shows, seasons, episodes, books, audiobooks and video games I plan to watch/read/listen/play": "Filme, Serien, Staffeln, Folgen, Bücher, Hörbücher und Videospiele, die ich plane zu sehen/lesen/hören oder spielen möchte", + "Music": "", "My rating": "Meine Bewertung", "Name": "Name", "Network": "Sender", diff --git a/client/src/i18n/locales/en/translation.json b/client/src/i18n/locales/en/translation.json index d07c322d..943be738 100644 --- a/client/src/i18n/locales/en/translation.json +++ b/client/src/i18n/locales/en/translation.json @@ -3,6 +3,7 @@ "<0><1/> playing": "<0><1/> playing", "<0><1/> reading": "<0><1/> reading", "<0><1/> watching": "<0><1/> watching", + "<0>{0} albums (<1>{1} plays)": "<0>{0} albums (<1>{1} plays)", "<0>{0} audiobooks (<1>{1} plays)": "<0>{0} audiobooks (<1>{1} plays)", "<0>{0} books (<1>{1} reads)": "<0>{0} books (<1>{1} reads)", "<0>{0} episodes (<1>{1} plays of <2>{2} shows)": "<0>{0} episodes (<1>{1} plays of <2>{2} shows)", @@ -79,7 +80,7 @@ "Hide title of unseen episodes": "Hide title of unseen episodes", "Home": "Home", "Http": "Http", - "I am listening it": "I am listening it", + "I am listening to it": "I am listening to it", "I am playing it": "I am playing it", "I am reading it": "I am reading it", "I am watching it": "I am watching it", @@ -127,6 +128,7 @@ "Movie": "Movie", "Movies": "Movies", "Movies, shows, seasons, episodes, books, audiobooks and video games I plan to watch/read/listen/play": "Movies, shows, seasons, episodes, books, audiobooks and video games I plan to watch/read/listen/play", + "Music": "Music", "My rating": "My rating", "Name": "Name", "Network": "Network", diff --git a/client/src/i18n/locales/es/translation.json b/client/src/i18n/locales/es/translation.json index 0b2bd78a..c1b904e4 100644 --- a/client/src/i18n/locales/es/translation.json +++ b/client/src/i18n/locales/es/translation.json @@ -3,6 +3,7 @@ "<0><1/> playing": "<0><1/> jugando", "<0><1/> reading": "<0><1/> leyendo", "<0><1/> watching": "<0><1/> viendo", + "<0>{0} albums (<1>{1} plays)": "", "<0>{0} audiobooks (<1>{1} plays)": "<0>{0} audiolibros (<1>{1} reproducciones)", "<0>{0} books (<1>{1} reads)": "<0>{0} libros (<1>{1} lecturas)", "<0>{0} episodes (<1>{1} plays of <2>{2} shows)": "<0>{0} episodios (<1>{1} reproducciones de <2>{2} series)", @@ -79,7 +80,7 @@ "Hide title of unseen episodes": "Ocultar el título de episodios no vistos", "Home": "Inicio", "Http": "Http", - "I am listening it": "Lo estoy escuchando", + "I am listening to it": "Lo estoy escuchando", "I am playing it": "Lo estoy jugando", "I am reading it": "Lo estoy leyendo", "I am watching it": "La estoy viendo", @@ -127,6 +128,7 @@ "Movie": "Película", "Movies": "Películas", "Movies, shows, seasons, episodes, books, audiobooks and video games I plan to watch/read/listen/play": "Películas, programas, temporadas, episodios, libros, audiolibros y videojuegos que tengo planeado ver/leer/escuchar/jugar", + "Music": "", "My rating": "Mi puntuación", "Name": "Nombre", "Network": "Canal", diff --git a/client/src/i18n/locales/fr/translation.json b/client/src/i18n/locales/fr/translation.json index f01689a9..55790238 100644 --- a/client/src/i18n/locales/fr/translation.json +++ b/client/src/i18n/locales/fr/translation.json @@ -3,10 +3,13 @@ "<0><1/> playing": "<0><1/> joué", "<0><1/> reading": "<0><1/> lu", "<0><1/> watching": "<0><1/> vu", + "<0>{0} albums (<1>{1} plays)": "", "<0>{0} audiobooks (<1>{1} plays)": "<0>{0} audiobooks (<1>{1} joués)", "<0>{0} books (<1>{1} reads)": "<0>{0} livres (<1>{1} lus)", + "<0>{0} episodes (<1>{1} plays of <2>{2} shows)": "", "<0>{0} movies (<1>{1} plays)": "<0>{0} films (<1>{1} vus)", "<0>{0} video games (<1>{1} plays)": "<0>{0} jeux vidéos (<1>{1} joués)", + "API keys can be acquired here": "", "About": "A propos", "Access Token": "Jeton d'accès", "Add \"{0}\" to list": "Ajouté \"{0}\" à la liste", @@ -16,12 +19,18 @@ "Add season to list": "Ajouter la saison à la liste", "Add season to seen history": "Ajouter la saison à l'historique", "Add to list": "Ajouter à la liste", + "Add to listened history": "", + "Add to played history": "", + "Add to read history": "", + "Add to seen history": "", + "Add to watchlist": "", "Add token": "Ajouter le jeton", "Add {0} to seen history": "Ajouter {0} à l'historique", "All": "Tous", "App token": "Jeton d'application", "Application tokens": "Jetons d'application", "Ascending": "Ascendant", + "At release date": "", "Audible language": "Audible langue", "Audiobook": "Audiobook", "Audiobooks": "Audiobooks", @@ -33,6 +42,7 @@ "Cancel": "Annuler", "Change password": "Changer le mot de passe", "Client ID": "Client ID", + "Client Secret": "", "Close": "Fermer", "Configuration": "Configuration", "Confirm new password": "Confirmer le nouveau mot de passe", @@ -55,16 +65,31 @@ "Episode": "Épisode", "Episode {0} {1}": "Épisode {0} {1}", "Episodes": "Épisodes", + "Episodes page": "", "Error": "Erreur", "Exporting": "En cours d'export", + "First unwatched episode": "", "Games": "Jeux vidéos", "General": "Général", + "Go to <0>https://www.goodreads.com/review/list and copy RSS url from the bottom of the page": "", + "Go to <0>{0} and enter following code: <1>{1}": "", + "Gotify server url": "", "Hide overview of unseen seasons": "Masquer l'aperçu des saisons non vues", "Hide title of unseen episodes": "Masquer les titres des épisodes non vus", "Home": "Accueil", "Http": "Http", + "I am listening to it": "", + "I am playing it": "", + "I am reading it": "", + "I am watching it": "", + "I do not remember": "", + "I finished listening it": "", + "I finished playing it": "", + "I finished reading it": "", + "I finished watching it": "", "IGDB credentials": "Identifiants IGDB", "Import": "Importer", + "Import from": "", "Imported": "Importé", "Importing": "En cours d'import", "In progress": "En cours", @@ -72,6 +97,20 @@ "Info": "Information", "Key": "Clé", "Language": "Langue", + "Last airing": "", + "Last listened at {0}": "", + "Last played at {0}": "", + "Last read at {0}": "", + "Last seen": "", + "Last seen at {0}": "", + "List {0} - episodes": "", + "List {0} - movies": "", + "List {0} - seasons": "", + "List {0} - shows": "", + "List: {0}": "", + "Listened": "", + "Listened at {0}": "", + "Listened history": "", "Lists": "Listes", "Loading": "Chargement", "Login": "Se connecter", @@ -85,25 +124,33 @@ "Media type": "Type de média", "Movie": "Film", "Movies": "Films", + "Movies, shows, seasons, episodes, books, audiobooks and video games I plan to watch/read/listen/play": "", + "Music": "Musique", "My rating": "Mon évaluation", "Name": "Nom", "Network": "Réseau", "New list": "Nouvelle liste", "New password": "Nouveau mot de passe", + "Next airing": "", "Next episode": "Prochain épisode", "Next episode to watch": "Prochain épisode à regarder", "No": "Non", "No date": "Aucune date", + "No more logs": "", + "Not imported items": "", "Notifications": "Notifications", "Now": "Maintenant", "Number of episodes": "Nombre d'épisodes", "Number of pages": "Nombre de pages", + "On watchlist": "", "Overview": "Aperçu", "Password": "Mot de passe", "Password has been changed": "Le mot de passe a été changé", "Passwords do not match": "Les mots de passe ne correspondent pas", + "Platform": "", "Played": "Joué", "Played at {0}": "Joué le {0}", + "Played history": "", "Preferences": "Préférences", "Priority": "Priorité", "Privacy": "Confidentialité", @@ -113,9 +160,23 @@ "Public reviews": "Avis publics", "RSS link": "Lien RSS", "Rank": "Classement", + "Rated": "", + "Rating": "", + "Ratings": "", + "Ratings - episodes": "", + "Ratings - movies": "", + "Ratings - seasons": "", + "Ratings - shows": "", "Read": "Lu", "Read at {0}": "Lu le {0}", + "Read history": "", + "Receive notification for all media items on your watchlist, when its release date changes": "", + "Receive notification for all media items on your watchlist, when its status changes": "", + "Receive notification for all media items on your watchlist, when they are released, including new seasons for tv shows": "", + "Receive notification for all tv shows on your watchlist, when its number of seasons changes": "", + "Receive notification for every episode for all tv shows on your watchlist, when it's released": "", "Recently added": "Récemment ajouté", + "Recently aired": "", "Recently released": "Publié récemment", "Recently watched": "Vu récemment", "Register": "Enregistrer", @@ -124,34 +185,61 @@ "Released <0/>": "Publié le <0/>", "Remove \"{0}\" from watchlist?": "Supprimer \"{0}\" de la liste de lecture ?", "Remove \"{0}{1}\" from watchlist?": "Supprimer \"{0}{1}\" de la liste de lecture?", + "Remove episode from seen history": "", + "Remove from listened history": "", + "Remove from played history": "", + "Remove from read history": "", + "Remove from seen history": "", + "Remove from watchlist": "", + "Remove season from seen history": "", "Remove token": "Supprimer le jeton", + "Review by <0><1>{author} at {date}": "", "Runtime": "Durée", "Save": "Sauvegarder", "Save list": "Enregistrer la liste", "Save review": "Enregistrer l'avis", "Search": "Rechercher", + "Search for items or <0>import": "", "Search list": "Liste de recherche", "Season": "Saison", "Season {0}": "Saison {0}", "Seasons": "Saisons", + "Seen at {0}": "", + "Seen history": "", + "Seen history - episodes": "", + "Seen history - movies": "", "Select": "Sélectionner", "Select date": "Sélectionner la date", + "Send notification for episodes releases": "", + "Send notification for releases": "", + "Send notification when number of seasons changes": "", + "Send notification when release date changes": "", + "Send notification when status changes": "", "Server error: {errorMessage}": "Erreur serveur : {errorMessage}", "Server language": "Langue du serveur", "Server url (only for self hosting)": "url du serveur (uniquement pour l'auto-hébergement)", + "Set": "", "Set progress": "Définir la progression", "Settings": "Paramètres", + "Show your reviews to other users": "", + "Shows": "", "Sort by": "Trier par", "Sort order": "Ordre de tri", "Source": "Source", + "Start over": "", "Status": "Statut", "Summary": "Résumé", "S{0}": "S{0}", "S{0}E{1}": "S{0}E{1}", "TMDB language": "TMDB langue", + "There already exists a list with name \"{name}\"": "", + "This component can only be used with tv shows": "", "Title": "Titre", + "To read": "", + "Topic": "", "Total runtime": "Durée totale", "Tv": "Tv", + "Uninitialized": "", "Unrated": "Pas de note", "Unseen episodes": "Episodes non vus", "Unseen episodes count": "Episodes non vus", @@ -166,6 +254,34 @@ "Warning": "Avertissement", "Watched": "Vu", "Watchlist": "Liste de lecture", + "Watchlist - episodes": "", + "Watchlist - movies": "", + "Watchlist - season": "", + "Watchlist - shows": "", + "Webhook URL": "", + "What is the last episode of \"{0}\" you see?": "", + "When did you listen to \"{0}\"?": "", + "When did you play \"{0}\"?": "", + "When did you read \"{0}\"?": "", + "When did you see \"{0}\"?": "", + "Where to watch": "", "Yes": "Oui", - "import": "importer" + "import": "importer", + "{0, plural, one { Found # item for query \"<0>{searchQuery}\"} other { Found # items for query \"<1>{searchQuery}\"}}": "", + "{0, plural, one {# item} other {# items}}": "", + "{0, plural, one {1 item} other {# items}}": "", + "{0, plural, one {Author} other {Authors}}": "", + "{0, plural, one {Genre} other {Genres}}": "", + "{0, plural, one {Listed on 1 list} other {Listed on # lists}}": "", + "{0, plural, one {Listened 1 time} other {Listened # times}}": "", + "{0, plural, one {Narrator} other {Narrators}}": "", + "{0, plural, one {Platform} other {platforms}}": "", + "{0, plural, one {Played 1 time} other {Played # times}}": "", + "{0, plural, one {Read 1 time} other {Read # times}}": "", + "{0, plural, one {Seen 1 time} other {Seen # times}}": "", + "{count, plural, one {Do you want to remove # seen history entry?} other {Do you want to remove all # seen history entries?}}": "", + "{duration, plural, one {minute} other {minutes}}": "", + "{numberOfItemsTotal, plural, one {1 item} other {# items}}": "", + "{value, plural, one {minute} other {minutes}}": "", + "{value, plural, one {page} other {pages}}": "" } \ No newline at end of file diff --git a/client/src/i18n/locales/ko/translation.json b/client/src/i18n/locales/ko/translation.json index d4509e9b..70768427 100644 --- a/client/src/i18n/locales/ko/translation.json +++ b/client/src/i18n/locales/ko/translation.json @@ -3,6 +3,7 @@ "<0><1/> playing": "<0><1/> 플레이", "<0><1/> reading": "<0><1/> 감상", "<0><1/> watching": "<0><1/> 시청", + "<0>{0} albums (<1>{1} plays)": "", "<0>{0} audiobooks (<1>{1} plays)": "<0>{0} 오디오북 (<1>{1}번 감상)", "<0>{0} books (<1>{1} reads)": "<0>{0} 권 (<1>{1}번 읽음)", "<0>{0} episodes (<1>{1} plays of <2>{2} shows)": "<0>{0} 에피소드 (<2>{2} 쇼, <1>{1}번 시청)", @@ -79,7 +80,7 @@ "Hide title of unseen episodes": "시청하지 않은 에피소드 제목 숨기기", "Home": "홈", "Http": "HTTP", - "I am listening it": "감상 중으로 설정", + "I am listening to it": "감상 중으로 설정", "I am playing it": "플레이 중으로 설정", "I am reading it": "감상 중으로 설정", "I am watching it": "시청 중으로 설정", @@ -127,6 +128,7 @@ "Movie": "영화", "Movies": "영화", "Movies, shows, seasons, episodes, books, audiobooks and video games I plan to watch/read/listen/play": "내가 보고/읽고/듣고/플레이하고자 하는 영화, TV 쇼, 시즌, 에피소드, 책, 오디오북 및 게임", + "Music": "", "My rating": "내 평점", "Name": "이름", "Network": "네트워크", diff --git a/client/src/i18n/locales/nl/translation.json b/client/src/i18n/locales/nl/translation.json index 4a78bc26..fbc4dafc 100644 --- a/client/src/i18n/locales/nl/translation.json +++ b/client/src/i18n/locales/nl/translation.json @@ -79,7 +79,7 @@ "Hide title of unseen episodes": "Titel van onbekeken afleveringen verbergen", "Home": "Startscherm", "Http": "Http", - "I am listening it": "Ik luister ernaar", + "I am listening to it": "Ik luister ernaar", "I am playing it": "Ik ben het aan het spelen", "I am reading it": "Ik ben het aan het lezen", "I am watching it": "Ik kijk ernaar", diff --git a/client/src/i18n/locales/pt/translation.json b/client/src/i18n/locales/pt/translation.json index f2ad14c2..9747d555 100644 --- a/client/src/i18n/locales/pt/translation.json +++ b/client/src/i18n/locales/pt/translation.json @@ -1,4 +1,14 @@ { + "<0><1/> listening": "", + "<0><1/> playing": "", + "<0><1/> reading": "", + "<0><1/> watching": "", + "<0>{0} albums (<1>{1} plays)": "", + "<0>{0} audiobooks (<1>{1} plays)": "", + "<0>{0} books (<1>{1} reads)": "", + "<0>{0} episodes (<1>{1} plays of <2>{2} shows)": "", + "<0>{0} movies (<1>{1} plays)": "", + "<0>{0} video games (<1>{1} plays)": "", "API keys can be acquired here": "Chaves de API podem ser obtidas aqui", "About": "Acerca", "Access Token": "Token de Acesso", @@ -13,6 +23,7 @@ "Add to played history": "Adicionar ao histórico de reproduções", "Add to read history": "Adicionar ao histórico de leituras", "Add to seen history": "Adicionar ao histórico de visualizações", + "Add to watchlist": "", "Add token": "Adicionar token", "Add {0} to seen history": "Adicionar {0} ao histórico de visualizações", "All": "Tudo", @@ -40,6 +51,7 @@ "Copy to clipboard": "Copiar para a área de transferência", "Current password": "Palavra-passe atual", "Current password is incorrect": "A palavra-passe atual está incorreta", + "Currently reading": "", "Custom date": "Data personalizada", "Debug": "Depurar", "Delete list": "Eliminar lista", @@ -60,13 +72,14 @@ "Games": "Jogos", "General": "Geral", "Go to <0>https://www.goodreads.com/review/list and copy RSS url from the bottom of the page": "Navegue para <0>https://www.goodreads.com/review/list e copie o URL RSS da parte inferior da página", + "Go to <0>{0} and enter following code: <1>{1}": "", "Go to <0>{0} and enter following code: {1}": "Vá a <0>{0} e introduza o seguinte código: {1}", "Gotify server url": "URL do servidor Gotify", "Hide overview of unseen seasons": "Ocultar visão geral de temporadas não vistas", "Hide title of unseen episodes": "Ocultar título de episódios não vistos", "Home": "Página inicial", "Http": "HTTP", - "I am listening it": "Estou a ouvir", + "I am listening to it": "Estou a ouvir", "I am playing it": "Estou a reproduzir", "I am reading it": "Estou a ler", "I am watching it": "Estou a assistir", @@ -90,6 +103,46 @@ "Last played at {0}": "Reproduzido pela última vez em {0}", "Last read at {0}": "Lido pela última vez em {0}", "Last seen": "Visto pela última vez", + "Last seen at {0}": "", + "List {0} - episodes": "", + "List {0} - movies": "", + "List {0} - seasons": "", + "List {0} - shows": "", + "List: {0}": "", + "Listened": "", + "Listened at {0}": "", + "Listened history": "", + "Lists": "", + "Loading": "", + "Login": "", + "Logout": "", + "Logs": "", + "Media type": "", + "Movie": "", + "Movies": "", + "Movies, shows, seasons, episodes, books, audiobooks and video games I plan to watch/read/listen/play": "", + "Music": "", + "My rating": "", + "Name": "", + "Network": "", + "New list": "", + "New password": "", + "Next airing": "", + "Next episode": "", + "Next episode to watch": "", + "No": "", + "No date": "", + "No more logs": "", + "Not imported items": "", + "Notifications": "", + "Now": "", + "Number of episodes": "", + "Number of pages": "", + "On watchlist": "", + "Overview": "", + "Password": "", + "Password has been changed": "", + "Passwords do not match": "", "Platform": "Plataforma", "Played": "Reproduzido", "Played at {0}": "Reproduzido em {0}", @@ -106,8 +159,124 @@ "Rated": "Classificado", "Rating": "Classificação", "Ratings": "Classificações", + "Ratings - episodes": "", + "Ratings - movies": "", + "Ratings - seasons": "", + "Ratings - shows": "", "Read": "Lido", "Read at {0}": "Lido em {0}", "Read history": "Histórico de leitura", - "Updating metadata": "A atualizar metadados" + "Receive notification for all media items on your watchlist, when its release date changes": "", + "Receive notification for all media items on your watchlist, when its status changes": "", + "Receive notification for all media items on your watchlist, when they are released, including new seasons for tv shows": "", + "Receive notification for all tv shows on your watchlist, when its number of seasons changes": "", + "Receive notification for every episode for all tv shows on your watchlist, when it's released": "", + "Recently added": "", + "Recently aired": "", + "Recently released": "", + "Recently watched": "", + "Register": "", + "Release <0/>": "", + "Release date": "", + "Released <0/>": "", + "Remove \"{0}{1}\" from watchlist?": "", + "Remove episode from seen history": "", + "Remove from listened history": "", + "Remove from played history": "", + "Remove from read history": "", + "Remove from seen history": "", + "Remove from watchlist": "", + "Remove season from seen history": "", + "Remove token": "", + "Review by <0><1>{author} at {date}": "", + "Runtime": "", + "Save": "", + "Save list": "", + "Save review": "", + "Search": "", + "Search for items or <0>import": "", + "Search list": "", + "Season": "", + "Season {0}": "", + "Seasons": "", + "Seen at {0}": "", + "Seen history": "", + "Seen history - episodes": "", + "Seen history - movies": "", + "Select": "", + "Select date": "", + "Send notification for episodes releases": "", + "Send notification for releases": "", + "Send notification when number of seasons changes": "", + "Send notification when release date changes": "", + "Send notification when status changes": "", + "Server error: {errorMessage}": "", + "Server language": "", + "Server url (only for self hosting)": "", + "Set": "", + "Set progress": "", + "Settings": "", + "Show your reviews to other users": "", + "Shows": "", + "Sort by": "", + "Sort order": "", + "Source": "", + "Start over": "", + "Status": "", + "Summary": "", + "S{0}": "", + "S{0}E{1}": "", + "TMDB language": "", + "There already exists a list with name \"{name}\"": "", + "This component can only be used with tv shows": "", + "Title": "", + "To read": "", + "Topic": "", + "Total runtime": "", + "Tv": "", + "Uninitialized": "", + "Unrated": "", + "Unseen episodes": "", + "Unseen episodes count": "", + "Upcoming": "", + "Update metadata": "", + "Updating metadata": "A atualizar metadados", + "User key": "", + "Username": "", + "Version": "", + "Video game": "", + "Waiting for authentication": "", + "Warning": "", + "Watched": "", + "Watchlist": "", + "Watchlist - episodes": "", + "Watchlist - movies": "", + "Watchlist - season": "", + "Watchlist - shows": "", + "Webhook URL": "", + "What is the last episode of \"{0}\" you see?": "", + "When did you listen to \"{0}\"?": "", + "When did you play \"{0}\"?": "", + "When did you read \"{0}\"?": "", + "When did you see \"{0}\"?": "", + "Where to watch": "", + "Yes": "", + "import": "", + "{0, plural, one { Found # item for query \"<0>{searchQuery}\"} other { Found # items for query \"<1>{searchQuery}\"}}": "", + "{0, plural, one {# item} other {# items}}": "", + "{0, plural, one {1 item} other {# items}}": "", + "{0, plural, one {Author} other {Authors}}": "", + "{0, plural, one {Genre} other {Genres}}": "", + "{0, plural, one {Listed on 1 list} other {Listed on # lists}}": "", + "{0, plural, one {Listened 1 time} other {Listened # times}}": "", + "{0, plural, one {Narrator} other {Narrators}}": "", + "{0, plural, one {Platform} other {platforms}}": "", + "{0, plural, one {Played 1 time} other {Played # times}}": "", + "{0, plural, one {Read 1 time} other {Read # times}}": "", + "{0, plural, one {Seen 1 time} other {Seen # times}}": "", + "{count, plural, one {Do you want to remove # seen history entry?} other {Do you want to remove all # seen history entries?}}": "", + "{duration, plural, one {minute} other {minutes}}": "", + "{numberOfItemsTotal, plural, one {1 item} other {# items}}": "", + "{value, plural, one {minute} other {minutes}}": "", + "{value, plural, one {page} other {pages}}": "" } \ No newline at end of file diff --git a/client/src/pages/Details.tsx b/client/src/pages/Details.tsx index 2b6877de..6254fb5e 100644 --- a/client/src/pages/Details.tsx +++ b/client/src/pages/Details.tsx @@ -23,6 +23,7 @@ import { isAudiobook, isBook, isMovie, + isMusic, isOnWatchlist, isTvShow, isVideoGame, @@ -184,7 +185,7 @@ const ExternalLinks: FunctionComponent<{ /> )} - {mediaItem.audibleId && ( + {mediaItem.audibleId && ( // TODO add music logo { > {isMovie(mediaItem) && I am watching it} {isBook(mediaItem) && I am reading it} - {isAudiobook(mediaItem) && I am listening it} + {isAudiobook(mediaItem) && I am listening to it} + {isMusic(mediaItem) && I am listening to it} {isVideoGame(mediaItem) && I am playing it} )} @@ -487,6 +489,9 @@ export const DetailsPage: FunctionComponent = () => { {isAudiobook(mediaItem) && ( I finished listening it )} + {isMusic(mediaItem) && ( + I finished listening it + )} {isVideoGame(mediaItem) && ( I finished playing it )} @@ -529,7 +534,7 @@ export const DetailsPage: FunctionComponent = () => { )} {mediaItem.lastSeenAt > 0 && (
- {isAudiobook(mediaItem) && ( + {(isAudiobook(mediaItem) || isMusic(mediaItem)) && ( Last listened at {new Date(mediaItem.lastSeenAt).toLocaleString()} @@ -557,7 +562,7 @@ export const DetailsPage: FunctionComponent = () => { {mediaItem.seenHistory?.length > 0 && (
- {isAudiobook(mediaItem) && ( + {(isAudiobook(mediaItem) || isMusic(mediaItem)) && ( { )}
- {isAudiobook(mediaItem) && Listened history} + {(isAudiobook(mediaItem) || isMusic(mediaItem)) && ( + Listened history + )} {isBook(mediaItem) && Read history} diff --git a/client/src/pages/SeenHistory.tsx b/client/src/pages/SeenHistory.tsx index 3793c36e..10ba69be 100644 --- a/client/src/pages/SeenHistory.tsx +++ b/client/src/pages/SeenHistory.tsx @@ -9,6 +9,7 @@ import { isAudiobook, isBook, isMovie, + isMusic, isTvShow, isVideoGame, } from 'src/utils'; @@ -39,7 +40,7 @@ export const SeenHistoryPage: FunctionComponent = () => { {mediaItem.seenHistory?.length > 0 && (
- {isAudiobook(mediaItem) && ( + {(isAudiobook(mediaItem) || isMusic(mediaItem)) && ( {
  • {seenEntry.date ? ( <> - {isAudiobook(mediaItem) && ( + {(isAudiobook(mediaItem) || isMusic(mediaItem)) && ( Listened at {seenEntry.dateStr} )} diff --git a/client/src/utils.ts b/client/src/utils.ts index 6d4581e8..7605117b 100644 --- a/client/src/utils.ts +++ b/client/src/utils.ts @@ -114,6 +114,12 @@ export const isVideoGame = (mediaItem?: MediaItemItemsResponse | MediaType) => { : mediaItem?.mediaType === 'video_game'; }; +export const isMusic = (mediaItem?: MediaItemItemsResponse | MediaType) => { + return typeof mediaItem === 'string' + ? mediaItem === 'music' + : mediaItem?.mediaType === 'music'; +}; + export const hasPoster = (mediaItem: MediaItemItemsResponse) => { return mediaItem.posterSmall != undefined; }; @@ -139,7 +145,7 @@ export const reverseMap = ( }; export const canMetadataBeUpdated = (mediaItem: MediaItemItemsResponse) => { - return ['igdb', 'tmdb', 'openlibrary', 'audible'].includes( + return ['igdb', 'tmdb', 'openlibrary', 'audible', 'musicbrainz'].includes( mediaItem.source?.toLowerCase() ); }; @@ -149,7 +155,7 @@ export const listDescription = (list?: { isWatchlist: boolean; }) => { return list?.isWatchlist - ? t`Movies, shows, seasons, episodes, books, audiobooks and video games I plan to watch/read/listen/play` + ? t`Movies, shows, seasons, episodes, books, audiobooks and video games I plan to watch/read/listen/play` // TODO update existing translations : list?.description; }; diff --git a/server/openapi.json b/server/openapi.json index cc5a4944..697aa2fb 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -2558,6 +2558,7 @@ "audiobook", "book", "movie", + "music", "tv", "video_game" ], @@ -2798,6 +2799,10 @@ "type": "number", "nullable": true }, + "musicBrainzId": { + "type": "string", + "nullable": true + }, "numberOfSeasons": { "type": "number", "nullable": true @@ -3359,6 +3364,10 @@ "type": "number", "nullable": true }, + "musicBrainzId": { + "type": "string", + "nullable": true + }, "numberOfSeasons": { "type": "number", "nullable": true @@ -3874,6 +3883,7 @@ "book", "episode", "movie", + "music", "season", "tv", "video_game" @@ -4354,12 +4364,40 @@ "items", "plays" ] + }, + "music": { + "type": "object", + "properties": { + "numberOfPages": { + "type": "number", + "nullable": true + }, + "duration": { + "type": "number" + }, + "episodes": { + "type": "number" + }, + "items": { + "type": "number" + }, + "plays": { + "type": "number" + } + }, + "required": [ + "duration", + "episodes", + "items", + "plays" + ] } }, "required": [ "audiobook", "book", "movie", + "music", "tv", "video_game" ] diff --git a/server/package.json b/server/package.json index 278ea587..eb967306 100644 --- a/server/package.json +++ b/server/package.json @@ -1,7 +1,7 @@ { "name": "mediatracker", "version": "0.2.3", - "description": "Self hosted media tracker for movies, tv shows, video games, books and audiobooks", + "description": "Self hosted media tracker for movies, tv shows, video games, books, audiobooks and music albums", "repository": { "type": "git", "url": "git+https://github.com/bonukai/MediaTracker.git" diff --git a/server/src/entity/mediaItem.ts b/server/src/entity/mediaItem.ts index 3630211f..9f1fab31 100644 --- a/server/src/entity/mediaItem.ts +++ b/server/src/entity/mediaItem.ts @@ -5,7 +5,7 @@ import { TvSeason } from 'src/entity/tvseason'; import { AudibleCountryCode } from 'src/entity/configuration'; import { List } from 'src/entity/list'; -export type MediaType = 'tv' | 'movie' | 'book' | 'video_game' | 'audiobook'; +export type MediaType = 'tv' | 'movie' | 'book' | 'video_game' | 'audiobook' | 'music'; export type ExternalIds = { tmdbId?: number; @@ -17,6 +17,7 @@ export type ExternalIds = { audibleId?: string; traktId?: number; goodreadsId?: number; + musicBrainzId?: string; }; export type MediaItemBase = ExternalIds & { @@ -160,6 +161,7 @@ export const mediaItemColumns = [ 'externalBackdropUrl', 'posterId', 'backdropId', + 'musicBrainzId', ]; export const mediaItemPosterPath = ( diff --git a/server/src/generated/routes/routes.ts b/server/src/generated/routes/routes.ts index 50513391..25885c94 100644 --- a/server/src/generated/routes/routes.ts +++ b/server/src/generated/routes/routes.ts @@ -420,7 +420,7 @@ router.get( }, SortOrder: { enum: ['asc', 'desc'], type: 'string' }, MediaType: { - enum: ['audiobook', 'book', 'movie', 'tv', 'video_game'], + enum: ['audiobook', 'book', 'movie', 'music', 'tv', 'video_game'], type: 'string', }, }, @@ -436,7 +436,7 @@ router.get( $schema: 'http://json-schema.org/draft-07/schema#', definitions: { MediaType: { - enum: ['audiobook', 'book', 'movie', 'tv', 'video_game'], + enum: ['audiobook', 'book', 'movie', 'music', 'tv', 'video_game'], type: 'string', }, MediaItemOrderBy: { @@ -707,7 +707,7 @@ router.put( $schema: 'http://json-schema.org/draft-07/schema#', definitions: { MediaType: { - enum: ['audiobook', 'book', 'movie', 'tv', 'video_game'], + enum: ['audiobook', 'book', 'movie', 'music', 'tv', 'video_game'], type: 'string', }, }, @@ -770,7 +770,7 @@ router.get( $schema: 'http://json-schema.org/draft-07/schema#', definitions: { MediaType: { - enum: ['audiobook', 'book', 'movie', 'tv', 'video_game'], + enum: ['audiobook', 'book', 'movie', 'music', 'tv', 'video_game'], type: 'string', }, }, @@ -819,7 +819,7 @@ router.put( $schema: 'http://json-schema.org/draft-07/schema#', definitions: { MediaType: { - enum: ['audiobook', 'book', 'movie', 'tv', 'video_game'], + enum: ['audiobook', 'book', 'movie', 'music', 'tv', 'video_game'], type: 'string', }, }, diff --git a/server/src/knex/queries/items.ts b/server/src/knex/queries/items.ts index 4493c852..62873339 100644 --- a/server/src/knex/queries/items.ts +++ b/server/src/knex/queries/items.ts @@ -510,6 +510,7 @@ const mapRawResult = (row: any): MediaItemItemsResponse => { traktId: row['mediaItem.traktId'], imdbId: row['mediaItem.imdbId'], audibleId: row['mediaItem.audibleId'], + musicBrainzId: row['mediaItem.musicBrainzId'], mediaType: row['mediaItem.mediaType'], numberOfSeasons: row['mediaItem.numberOfSeasons'], status: row['mediaItem.status'], diff --git a/server/src/metadata/findByExternalId.ts b/server/src/metadata/findByExternalId.ts index 10a426d6..5ad42189 100644 --- a/server/src/metadata/findByExternalId.ts +++ b/server/src/metadata/findByExternalId.ts @@ -1,264 +1,281 @@ -import _ from 'lodash'; -import { ExternalIds, MediaType } from 'src/entity/mediaItem'; -import { logger } from 'src/logger'; -import { Audible } from 'src/metadata/provider/audible'; -import { OpenLibrary } from 'src/metadata/provider/openlibrary'; -import { TMDbMovie, TMDbTv } from 'src/metadata/provider/tmdb'; -import { tvEpisodeRepository } from 'src/repository/episode'; -import { mediaItemRepository } from 'src/repository/mediaItem'; -import { updateMediaItem } from 'src/updateMetadata'; - -export const findEpisodeByExternalId = async (args: { - imdbId?: string; - tmdbId?: number; - tvdbId?: number; -}) => { - const { imdbId, tmdbId, tvdbId } = args; - - const episode = await tvEpisodeRepository.findOne({ - tmdbId: tmdbId || undefined, - tvdbId: tvdbId || undefined, - imdbId: imdbId || undefined, - }); - - if (episode) { - const mediaItem = await mediaItemRepository.findOne({ id: episode.tvdbId }); - - return { - mediaItem: mediaItem, - episode: episode, - }; - } - - if (imdbId) { - const res = await new TMDbTv().findByEpisodeImdbId(imdbId); - - if (res) { - return await findMediaItemOrEpisodeByExternalId({ - mediaType: 'tv', - id: { - tmdbId: res.tvShowTmdbId, - }, - episodeNumber: res.episode.episodeNumber, - seasonNumber: res.episode.seasonNumber, - }); - } - } else if (tvdbId) { - const res = await new TMDbTv().findByEpisodeTvdbId(tvdbId); - - if (res) { - return await findMediaItemOrEpisodeByExternalId({ - mediaType: 'tv', - id: { - tmdbId: res.tvShowTmdbId, - }, - episodeNumber: res.episode.episodeNumber, - seasonNumber: res.episode.seasonNumber, - }); - } - } - - throw `Unable to find episode with imdbId: ${imdbId}, tmdbId: ${tmdbId}, tvdbId: ${tvdbId}`; -}; - -export const findMediaItemOrEpisodeByExternalId = async (args: { - mediaType: MediaType; - id: { - imdbId?: string; - tmdbId?: number; - }; - seasonNumber?: number; - episodeNumber?: number; -}) => { - const { mediaType, id, seasonNumber, episodeNumber } = args; - - if ( - mediaType === 'tv' && - (typeof seasonNumber !== 'number' || typeof episodeNumber !== 'number') - ) { - return { - error: 'Season end episode number are required for mediaType "tv"', - }; - } - - if (!id.imdbId && !id.tmdbId) { - return { - error: 'At least one external id is required', - }; - } - - const mediaItem = await findMediaItemByExternalId({ - id: id, - mediaType: mediaType, - }); - - if (!mediaItem) { - return { - error: `Unable to find mediaItem with id: ${JSON.stringify(id)}`, - }; - } - - if (mediaType === 'tv') { - if (mediaItem.needsDetails) { - await updateMediaItem(mediaItem); - } - - const episode = await tvEpisodeRepository.findOne({ - tvShowId: mediaItem.id, - episodeNumber: episodeNumber, - seasonNumber: seasonNumber, - }); - - if (!episode) { - return { - error: `Unable to find episode S${seasonNumber}E${episodeNumber} for ${mediaItem.title}`, - }; - } - - return { - mediaItem: mediaItem, - episode: episode, - }; - } - - return { - mediaItem: mediaItem, - }; -}; - -export const findMediaItemByExternalId = async (args: { - id: ExternalIds; - mediaType: MediaType; -}) => { - const { id, mediaType } = args; - const existingItem = await mediaItemRepository.findByExternalId( - id, - mediaType - ); - - if (!existingItem) { - return await findMediaItemByExternalIdInExternalSources(args); - } - - return existingItem; -}; - -export const findMediaItemByExternalIdInExternalSources = async (args: { - id: ExternalIds; - mediaType: MediaType; -}) => { - const res = await searchMediaItem(args); - - if (res) { - const existingItem = await mediaItemRepository.findByExternalId( - res, - args.mediaType - ); - - if (!existingItem) { - return await mediaItemRepository.create(res); - } - - return existingItem; - } -}; - -const searchMediaItem = async (args: { - id: ExternalIds; - mediaType: MediaType; -}) => { - const { id, mediaType } = args; - - if (mediaType === 'tv') { - if (id.tmdbId) { - logger.debug(`searching tv show by tmdbId: ${id.tmdbId}`); - try { - const res = await new TMDbTv().findByTmdbId(id.tmdbId); - if (res) { - return res; - } - // eslint-disable-next-line no-empty - } catch (error) {} - logger.error(`unable to find tv show with tmdbId: ${id.tmdbId}`); - } - if (id.imdbId) { - logger.debug(`searching tv show by imdbId: ${id.imdbId}`); - try { - const res = await new TMDbTv().findByImdbId(id.imdbId); - if (res) { - return res; - } - // eslint-disable-next-line no-empty - } catch (error) {} - logger.error(`unable to find tv show with imdbId: ${id.imdbId}`); - } - if (id.tvdbId) { - logger.debug(`searching tv show by tvdbId: ${id.tvdbId}`); - try { - const res = await new TMDbTv().findByTvdbId(id.tvdbId); - if (res) { - return res; - } - // eslint-disable-next-line no-empty - } catch (error) {} - logger.error(`unable to find tv show with tvdbId: ${id.tvdbId}`); - } - return; - } else if (mediaType === 'movie') { - if (id.tmdbId) { - logger.debug(`searching movie by tmdbId: ${id.tmdbId}`); - try { - const res = await new TMDbMovie().findByTmdbId(id.tmdbId); - if (res) { - return res; - } - // eslint-disable-next-line no-empty - } catch (error) {} - logger.error(`unable to find movie with tmdbId: ${id.tmdbId}`); - } - if (id.imdbId) { - logger.debug(`searching movie by imdbId: ${id.imdbId}`); - try { - const res = await new TMDbMovie().findByImdbId(id.imdbId); - if (res) { - return res; - } - // eslint-disable-next-line no-empty - } catch (error) {} - logger.error(`unable to find movie with imdbId: ${id.imdbId}`); - } - return; - } else if (mediaType === 'audiobook') { - if (id.audibleId) { - try { - const res = await new Audible().findByAudibleId(id.audibleId); - if (res) { - return res; - } - // eslint-disable-next-line no-empty - } catch (error) {} - logger.error(`unable to find audiobook with audibleId: ${id.audibleId}`); - } - } else if (mediaType === 'book') { - if (id.openlibraryId) { - try { - const res = await new OpenLibrary().details({ - openlibraryId: id.openlibraryId, - }); - if (res) { - return res; - } - // eslint-disable-next-line no-empty - } catch (error) {} - logger.error( - `unable to find book with openlibraryId: ${id.openlibraryId}` - ); - } - } - - logger.error( - `no metadata provider for type ${mediaType} with external ids ${JSON.stringify( - _.omitBy(id, (value) => !value) - )}` - ); -}; +import _ from 'lodash'; +import { ExternalIds, MediaType } from 'src/entity/mediaItem'; +import { logger } from 'src/logger'; +import { Audible } from 'src/metadata/provider/audible'; +import { OpenLibrary } from 'src/metadata/provider/openlibrary'; +import { TMDbMovie, TMDbTv } from 'src/metadata/provider/tmdb'; +import { tvEpisodeRepository } from 'src/repository/episode'; +import { mediaItemRepository } from 'src/repository/mediaItem'; +import { updateMediaItem } from 'src/updateMetadata'; +import { MusicBrainz } from './provider/musicbrainz'; + +export const findEpisodeByExternalId = async (args: { + imdbId?: string; + tmdbId?: number; + tvdbId?: number; +}) => { + const { imdbId, tmdbId, tvdbId } = args; + + const episode = await tvEpisodeRepository.findOne({ + tmdbId: tmdbId || undefined, + tvdbId: tvdbId || undefined, + imdbId: imdbId || undefined, + }); + + if (episode) { + const mediaItem = await mediaItemRepository.findOne({ id: episode.tvdbId }); + + return { + mediaItem: mediaItem, + episode: episode, + }; + } + + if (imdbId) { + const res = await new TMDbTv().findByEpisodeImdbId(imdbId); + + if (res) { + return await findMediaItemOrEpisodeByExternalId({ + mediaType: 'tv', + id: { + tmdbId: res.tvShowTmdbId, + }, + episodeNumber: res.episode.episodeNumber, + seasonNumber: res.episode.seasonNumber, + }); + } + } else if (tvdbId) { + const res = await new TMDbTv().findByEpisodeTvdbId(tvdbId); + + if (res) { + return await findMediaItemOrEpisodeByExternalId({ + mediaType: 'tv', + id: { + tmdbId: res.tvShowTmdbId, + }, + episodeNumber: res.episode.episodeNumber, + seasonNumber: res.episode.seasonNumber, + }); + } + } + + throw `Unable to find episode with imdbId: ${imdbId}, tmdbId: ${tmdbId}, tvdbId: ${tvdbId}`; +}; + +export const findMediaItemOrEpisodeByExternalId = async (args: { + mediaType: MediaType; + id: { + imdbId?: string; + tmdbId?: number; + }; + seasonNumber?: number; + episodeNumber?: number; +}) => { + const { mediaType, id, seasonNumber, episodeNumber } = args; + + if ( + mediaType === 'tv' && + (typeof seasonNumber !== 'number' || typeof episodeNumber !== 'number') + ) { + return { + error: 'Season end episode number are required for mediaType "tv"', + }; + } + + if (!id.imdbId && !id.tmdbId) { + return { + error: 'At least one external id is required', + }; + } + + const mediaItem = await findMediaItemByExternalId({ + id: id, + mediaType: mediaType, + }); + + if (!mediaItem) { + return { + error: `Unable to find mediaItem with id: ${JSON.stringify(id)}`, + }; + } + + if (mediaType === 'tv') { + if (mediaItem.needsDetails) { + await updateMediaItem(mediaItem); + } + + const episode = await tvEpisodeRepository.findOne({ + tvShowId: mediaItem.id, + episodeNumber: episodeNumber, + seasonNumber: seasonNumber, + }); + + if (!episode) { + return { + error: `Unable to find episode S${seasonNumber}E${episodeNumber} for ${mediaItem.title}`, + }; + } + + return { + mediaItem: mediaItem, + episode: episode, + }; + } + + return { + mediaItem: mediaItem, + }; +}; + +export const findMediaItemByExternalId = async (args: { + id: ExternalIds; + mediaType: MediaType; +}) => { + const { id, mediaType } = args; + const existingItem = await mediaItemRepository.findByExternalId( + id, + mediaType + ); + + if (!existingItem) { + return await findMediaItemByExternalIdInExternalSources(args); + } + + return existingItem; +}; + +export const findMediaItemByExternalIdInExternalSources = async (args: { + id: ExternalIds; + mediaType: MediaType; +}) => { + const res = await searchMediaItem(args); + + if (res) { + const existingItem = await mediaItemRepository.findByExternalId( + res, + args.mediaType + ); + + if (!existingItem) { + return await mediaItemRepository.create(res); + } + + return existingItem; + } +}; + +const searchMediaItem = async (args: { + id: ExternalIds; + mediaType: MediaType; +}) => { + const { id, mediaType } = args; + + if (mediaType === 'tv') { + if (id.tmdbId) { + logger.debug(`searching tv show by tmdbId: ${id.tmdbId}`); + try { + const res = await new TMDbTv().findByTmdbId(id.tmdbId); + if (res) { + return res; + } + // eslint-disable-next-line no-empty + } catch (error) {} + logger.error(`unable to find tv show with tmdbId: ${id.tmdbId}`); + } + if (id.imdbId) { + logger.debug(`searching tv show by imdbId: ${id.imdbId}`); + try { + const res = await new TMDbTv().findByImdbId(id.imdbId); + if (res) { + return res; + } + // eslint-disable-next-line no-empty + } catch (error) {} + logger.error(`unable to find tv show with imdbId: ${id.imdbId}`); + } + if (id.tvdbId) { + logger.debug(`searching tv show by tvdbId: ${id.tvdbId}`); + try { + const res = await new TMDbTv().findByTvdbId(id.tvdbId); + if (res) { + return res; + } + // eslint-disable-next-line no-empty + } catch (error) {} + logger.error(`unable to find tv show with tvdbId: ${id.tvdbId}`); + } + return; + } else if (mediaType === 'movie') { + if (id.tmdbId) { + logger.debug(`searching movie by tmdbId: ${id.tmdbId}`); + try { + const res = await new TMDbMovie().findByTmdbId(id.tmdbId); + if (res) { + return res; + } + // eslint-disable-next-line no-empty + } catch (error) {} + logger.error(`unable to find movie with tmdbId: ${id.tmdbId}`); + } + if (id.imdbId) { + logger.debug(`searching movie by imdbId: ${id.imdbId}`); + try { + const res = await new TMDbMovie().findByImdbId(id.imdbId); + if (res) { + return res; + } + // eslint-disable-next-line no-empty + } catch (error) {} + logger.error(`unable to find movie with imdbId: ${id.imdbId}`); + } + return; + } else if (mediaType === 'audiobook') { + if (id.audibleId) { + try { + const res = await new Audible().findByAudibleId(id.audibleId); + if (res) { + return res; + } + // eslint-disable-next-line no-empty + } catch (error) {} + logger.error(`unable to find audiobook with audibleId: ${id.audibleId}`); + } + } else if (mediaType === 'book') { + if (id.openlibraryId) { + try { + const res = await new OpenLibrary().details({ + openlibraryId: id.openlibraryId, + }); + if (res) { + return res; + } + // eslint-disable-next-line no-empty + } catch (error) {} + logger.error( + `unable to find book with openlibraryId: ${id.openlibraryId}` + ); + } + } + else if (mediaType === 'music') { + if (id.musicBrainzId) { + try { + const res = await new MusicBrainz().details({ + musicBrainzId: id.musicBrainzId, + }); + if (res) { + return res; + } + // eslint-disable-next-line no-empty + } catch (error) {} + logger.error( + `unable to find music release with musicBrainzId: ${id.musicBrainzId}` + ); + } + } + + logger.error( + `no metadata provider for type ${mediaType} with external ids ${JSON.stringify( + _.omitBy(id, (value) => !value) + )}` + ); +}; diff --git a/server/src/metadata/metadataProviders.ts b/server/src/metadata/metadataProviders.ts index 9576b1aa..11c55f1d 100644 --- a/server/src/metadata/metadataProviders.ts +++ b/server/src/metadata/metadataProviders.ts @@ -5,6 +5,7 @@ import { OpenLibrary } from 'src/metadata/provider/openlibrary'; import { TMDbMovie, TMDbTv } from 'src/metadata/provider/tmdb'; import _ from 'lodash'; import { MetadataProvider } from 'src/metadata/metadataProvider'; +import { MusicBrainz } from './provider/musicbrainz'; const providers = [ new IGDB(), @@ -12,6 +13,7 @@ const providers = [ new OpenLibrary(), new TMDbMovie(), new TMDbTv(), + new MusicBrainz(), ]; class MetadataProviders { diff --git a/server/src/metadata/provider/musicbrainz.ts b/server/src/metadata/provider/musicbrainz.ts new file mode 100644 index 00000000..fcbd85f3 --- /dev/null +++ b/server/src/metadata/provider/musicbrainz.ts @@ -0,0 +1,131 @@ +import axios from 'axios'; + +import { MediaItemForProvider, ExternalIds } from 'src/entity/mediaItem'; +import { MetadataProvider } from 'src/metadata/metadataProvider'; + +export class MusicBrainz extends MetadataProvider { + readonly name = 'musicbrainz'; + readonly mediaType = 'music'; + + async search(query: string): Promise { + + const escaped_query = query.replace(" ", "%20") + + const res = await axios.get( + `http://musicbrainz.org/ws/2/release-group?query=release:${query}`, // FIXME + ); + + if (res.status === 200) { + return res.data['release-groups'].map((product) => + this.mapResponse(product) + ); + } + + throw new Error(`Error: ${res.status}`);ODO + } + + async details(args: { + musicBrainzId: string; + }): Promise { + const res = await axios.get( + `http://musicbrainz.org/ws/2/release-group/${args.musicBrainzId}?inc=artist-credits+releases+genres` + ); + + return { + mediaType: this.mediaType, + source: this.name, + title: res.data.title, + url: `https://musicbrainz.org/release-group/${args.musicBrainzId}`, + externalPosterUrl: `https://coverartarchive.org/release-group/${args.musicBrainzId}/front`, + genres: res.data.genres.map(genre => genre.name) + }; + } + + private mapResponse( + item: MusicBrainzResponse.ReleaseGroup, + ): MediaItemForProvider { + + return { + mediaType: this.mediaType, + title: item.title, + needsDetails: true, + releaseDate: item["first-release-date"], + source: this.name, + authors: item["artist-credit"].map(artist => artist.name), + musicBrainzId: item.id, + externalPosterUrl: `https://coverartarchive.org/release-group/${item.id}/front`, + }; + } +} + +namespace MusicBrainzResponse { + + export interface ArtistInfo { + id: string; + name: string; + 'sort-name': string; + disambiguation: string; + } + + export interface ArtistCredit { + name: string; + artist: ArtistInfo; + } + + export interface Release { + id: string; + 'status-id': string; + title: string; + status: string; + date: string; + country: string; + } + + export interface Tag { + count: number; + name: string; + } + + export interface Genre { + count: number; + name: string; + disambiguation: string; + id: string; + } + + export interface ReleaseGroup { + id: string; + score: number; + 'primary-type-id': string; + count: number; + title: string; + 'first-release-date': string, + 'primary-type': string, + 'secondary-types'?: string[], + 'secondary-type-ids'?: string[], + 'artist-credit': ArtistCredit[], + releases: Release[], + tags: Tag[] + } + + export interface SearchResult { + created: Date; + count: number; + offset: number; + 'release-groups': ReleaseGroup[]; + } + + export interface DetailsResult { + 'primary-type-id': string; + 'artist-credit': ArtistCredit[]; + 'first-release-date': string; + 'secondary-types': string[]; + id: string; + disambiguation: string; + releases: Release[]; + 'primary-type': string; + title: string; + 'secondary-type-ids': string[]; + genres: Genre[] + } + } diff --git a/server/src/migrations/20231209165204_add_musicbrainz_column_to_table_mediatype.ts b/server/src/migrations/20231209165204_add_musicbrainz_column_to_table_mediatype.ts new file mode 100644 index 00000000..c61f0e73 --- /dev/null +++ b/server/src/migrations/20231209165204_add_musicbrainz_column_to_table_mediatype.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.table("mediaItem", (table) => { + table.string("musicBrainzId", 128); + }); +}; + +export async function down(knex: Knex): Promise { + await knex.schema.table("mediaItem", (table) => { + table.dropColumn("musicBrainzId"); + }); +}; diff --git a/server/src/migrations/20231210165204_add_musicbrainz_column_to_table_episode.ts b/server/src/migrations/20231210165204_add_musicbrainz_column_to_table_episode.ts new file mode 100644 index 00000000..f583ef33 --- /dev/null +++ b/server/src/migrations/20231210165204_add_musicbrainz_column_to_table_episode.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.table("episode", (table) => { + table.string("musicBrainzId", 128); + }); +}; + +export async function down(knex: Knex): Promise { + await knex.schema.table("episode", (table) => { + table.dropColumn("musicBrainzId"); + }); +}; diff --git a/server/src/repository/mediaItem.ts b/server/src/repository/mediaItem.ts index c4ef7284..243cc6ad 100644 --- a/server/src/repository/mediaItem.ts +++ b/server/src/repository/mediaItem.ts @@ -378,6 +378,7 @@ class MediaItemRepository extends repository({ goodreadsId?: number[]; traktId?: number[]; tvdbId?: number[]; + musicBrainzId?: string[]; mediaType: MediaType; }) { const totalNumberOfIds = externalIdColumnNames.reduce( @@ -456,10 +457,12 @@ class MediaItemRepository extends repository({ if (params.traktId) { qb.orWhere('traktId', params.traktId); } - if (params.tvdbId) { qb.orWhere('tvdbId', params.tvdbId); } + if (params.musicBrainzId) { + qb.orWhere('musicBrainzId', params.musicBrainzId); + } }) .first(); @@ -656,6 +659,7 @@ class MediaItemRepository extends repository({ searchResult: MediaItemForProvider[], mediaType: MediaType ) { + return await Promise.all( searchResult .filter( @@ -715,16 +719,5 @@ const externalIdColumnNames = [ 'traktId', 'goodreadsId', 'tvdbId', -]; - -const groupByExternalId = (items: T[]) => { - return _(externalIdColumnNames) - .keyBy() - .mapValues((externalIdColumnName) => - _(items) - .filter((item) => Boolean(item[externalIdColumnName])) - .groupBy(externalIdColumnName) - .value() - ) - .value(); -}; + 'musicBrainzId' +]; \ No newline at end of file diff --git a/server/src/utils.ts b/server/src/utils.ts index 10853969..f6557fde 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -144,6 +144,12 @@ export const generateExternalUrl = (mediaItem: MediaItemBase) => { return `https://audible.${audibleDomain}/pd/${mediaItem.audibleId}?overrideBaseCountry=true&ipRedirectOverride=true`; } } + + if (mediaItem.mediaType === 'music') { + if (mediaItem.musicBrainzId) { + return `https://musicbrainz.org/release-group/${mediaItem.musicBrainzId}`; + } + } }; export const getImageId = customAlphabet('1234567890abcdef', 32);