diff --git a/apps/web/app/(app)/album/AlbumComponent.tsx b/apps/web/app/(app)/album/AlbumComponent.tsx index a0be187f..43576ce0 100644 --- a/apps/web/app/(app)/album/AlbumComponent.tsx +++ b/apps/web/app/(app)/album/AlbumComponent.tsx @@ -14,6 +14,7 @@ import WikidataLogo from "@/public/wikidata_logo.png"; import WikipediaLogo from "@/public/wikipedia_logo.png"; import { getAlbumInfo, getArtistInfo, LibraryAlbum } from "@music/sdk"; import { Artist } from "@music/sdk/types"; +import { AspectRatio } from "@music/ui/components/aspect-ratio"; import { Badge } from "@music/ui/components/badge"; import { Button } from "@music/ui/components/button"; import { ScrollArea, ScrollBar } from "@music/ui/components/scroll-area"; @@ -34,20 +35,26 @@ export default function AlbumComponent() { if (!id || typeof id !== "string") return; const fetchAlbumInfo = async () => { - const album = await getAlbumInfo(id); + const album = await getAlbumInfo(id) as LibraryAlbum; const artistData = album.artist_object; const contributingArtistIds = album.contributing_artists_ids; - - const contributingArtistsPromises = contributingArtistIds.map((artistId) => - getArtistInfo(artistId) - ); - - const contributingArtistsData = await Promise.all(contributingArtistsPromises); + if (contributingArtistIds) { + const contributingArtistsPromises = contributingArtistIds.map((artistId) => + getArtistInfo(artistId) + ); + + const contributingArtistsData = await Promise.all(contributingArtistsPromises); + setContributingArtists(contributingArtistsData); + } else { + setContributingArtists([]) + } + setAlbum(album); setArtist(artistData); - setContributingArtists(contributingArtistsData); + + // setContributingArtists(contributingArtistsData); }; fetchAlbumInfo(); @@ -109,14 +116,15 @@ export default function AlbumComponent() {
-
- {`${album.name} +
+
+ {`${album.name} +

{album.name}

@@ -129,7 +137,7 @@ export default function AlbumComponent() { height={20} alt={`${artist.name} Profile Picture`} className="rounded-full" - /> + />

{album.release_group_album?.artist_credit.map((artist) => ( {artist.name} @@ -298,7 +306,7 @@ export default function AlbumComponent() {

{contributingArtists.map(artist => ( - +
getSongInfo(item.song_id)); - const songDetails = await Promise.all(songDetailsPromises); + const songDetails = await Promise.all(songDetailsPromises) as LibrarySong[]; setListenHistorySongs(songDetails); } diff --git a/apps/web/app/(app)/home/page.tsx b/apps/web/app/(app)/home/page.tsx index c6616bb8..c08c6052 100644 --- a/apps/web/app/(app)/home/page.tsx +++ b/apps/web/app/(app)/home/page.tsx @@ -39,7 +39,9 @@ export default function Home() { useEffect(() => { const savedConfig = localStorage.getItem("layoutConfig"); if (savedConfig) { - setComponents(JSON.parse(savedConfig)); + const parsedConfig = JSON.parse(savedConfig); + setComponents(parsedConfig); + } else { } }, [setComponents]); @@ -57,6 +59,12 @@ export default function Home() { }; }, []); + useEffect(() => { + if (components && components.length > 0) { + localStorage.setItem("layoutConfig", JSON.stringify(components)); + } + }, [components]); + type ComponentConfig = { id: string; name: string; diff --git a/apps/web/app/(app)/playlist/PlaylistComponent.tsx b/apps/web/app/(app)/playlist/PlaylistComponent.tsx index 55bc2bf3..30835ac3 100644 --- a/apps/web/app/(app)/playlist/PlaylistComponent.tsx +++ b/apps/web/app/(app)/playlist/PlaylistComponent.tsx @@ -15,12 +15,14 @@ interface Playlist extends OriginalPlaylist { updatedAt: Date; } +type LibrarySongWithDate = LibrarySong & { date_added: string } + export default function PlaylistComponent() { const searchParams = useSearchParams(); const id = searchParams?.get("id"); const [playlist, setPlaylist] = useState(null); - const [songsWithMetadata, setSongsWithMetadata] = useState<(LibrarySong & { date_added: string })[]>([]); + const [songsWithMetadata, setSongsWithMetadata] = useState<(LibrarySongWithDate)[]>([]); const [totalDuration, setTotalDuration] = useState(0); useEffect(() => { @@ -45,7 +47,7 @@ export default function PlaylistComponent() { return { ...songData, date_added: songInfo.date_added }; }); - const songsWithMetadataData = await Promise.all(songsWithMetadataPromises); + const songsWithMetadataData = await Promise.all(songsWithMetadataPromises) as unknown as LibrarySongWithDate[]; setSongsWithMetadata(songsWithMetadataData); const totalDuration = songsWithMetadataData.reduce((total: number, song: LibrarySong & { date_added: string }) => total + song.duration, 0); diff --git a/apps/web/app/(app)/search/SearchComponent.tsx b/apps/web/app/(app)/search/SearchComponent.tsx index de882a77..9b754418 100644 --- a/apps/web/app/(app)/search/SearchComponent.tsx +++ b/apps/web/app/(app)/search/SearchComponent.tsx @@ -28,7 +28,7 @@ export default function SearchComponent() { }, [query]); return ( -
+
{suggestion && suggestion.toLowerCase() !== query.toLowerCase() && (

diff --git a/apps/web/app/(app)/social/page.tsx b/apps/web/app/(app)/social/page.tsx index 018dbdfb..9755110e 100644 --- a/apps/web/app/(app)/social/page.tsx +++ b/apps/web/app/(app)/social/page.tsx @@ -10,6 +10,7 @@ import ArtistCard from "@/components/Music/Artist/ArtistCard"; import { User } from "@music/sdk/types"; import UserCard from "@/components/Music/Card/User/UserCard"; import { useSession } from "@/components/Providers/AuthProvider"; +import ProfilePicture from "@/components/User/ProfilePicture"; export default function SocialPage() { const { session } = useSession() @@ -58,11 +59,7 @@ export default function SocialPage() { {userInfo.image ? ( ) : ( - - - {userInfo.username.substring(0, 2).toUpperCase()} - - + )}

@{userInfo.username}

diff --git a/apps/web/app/(unprotected)/page.tsx b/apps/web/app/(unprotected)/page.tsx index cee5c205..8a974ebe 100644 --- a/apps/web/app/(unprotected)/page.tsx +++ b/apps/web/app/(unprotected)/page.tsx @@ -38,6 +38,8 @@ export default function MainPage() { const { session } = useSession() useEffect(() => { + if (process.env.LOCAL_APP) push("/home") + const checkServerUrl = async () => { setLoading(true); diff --git a/apps/web/components/Artist/SongsInLibrary.tsx b/apps/web/components/Artist/SongsInLibrary.tsx index 87bd008f..d8295aa4 100644 --- a/apps/web/components/Artist/SongsInLibrary.tsx +++ b/apps/web/components/Artist/SongsInLibrary.tsx @@ -20,7 +20,7 @@ async function getSongsFromYourLibrary(user_id: number, artist_id: string) { const songsDetailsPromises = playlistSongIDs.map((songID) => getSongInfo(String(songID))); - const songsDetails = await Promise.all(songsDetailsPromises); + const songsDetails = await Promise.all(songsDetailsPromises) as LibrarySong[]; const filteredSongsDetails = songsDetails.filter(song => String(song.artist_object.id) === artist_id); diff --git a/apps/web/components/Friends/FriendActivity.tsx b/apps/web/components/Friends/FriendActivity.tsx index 068f9d9a..718a9981 100644 --- a/apps/web/components/Friends/FriendActivity.tsx +++ b/apps/web/components/Friends/FriendActivity.tsx @@ -2,7 +2,7 @@ import getSession from "@/lib/Authentication/JWT/getSession"; import { getFollowing, getNowPlaying, getSongInfo, getUserInfoById } from "@music/sdk"; -import { Album, Artist, LibrarySong as Song, User } from "@music/sdk/types"; +import { Album, Artist, LibrarySong, LibrarySong as Song, User } from "@music/sdk/types"; import { Avatar, AvatarFallback, AvatarImage } from "@music/ui/components/avatar"; import { Disc3Icon } from 'lucide-react'; import Link from "next/link"; @@ -34,7 +34,7 @@ export default function FriendActivity() { const profilePicture = profilePicBlob ? URL.createObjectURL(profilePicBlob) : null; if (nowPlayingSongID) { - const songResponse = await getSongInfo(String(nowPlayingSongID.now_playing) ?? 0); + const songResponse = await getSongInfo(String(nowPlayingSongID.now_playing) ?? 0) as LibrarySong; return { ...friend, nowPlaying: { diff --git a/apps/web/components/Home/FromYourLibrary.tsx b/apps/web/components/Home/FromYourLibrary.tsx index 957b752a..de2723c4 100644 --- a/apps/web/components/Home/FromYourLibrary.tsx +++ b/apps/web/components/Home/FromYourLibrary.tsx @@ -1,13 +1,12 @@ "use client" -import getSession from "@/lib/Authentication/JWT/getSession"; import setCache, { getCache } from "@/lib/Caching/cache"; import { getPlaylist, getPlaylists, getSongInfo } from "@music/sdk"; import { LibrarySong } from "@music/sdk/types"; import { useEffect, useState } from "react"; import SongCard from "../Music/Card/SongCard"; -import ScrollButtons from "./ScrollButtons"; import { useSession } from "../Providers/AuthProvider"; +import ScrollButtons from "./ScrollButtons"; async function getSongsFromYourLibrary(user_id: number, genre?: string) { const playlists = await getPlaylists(user_id); @@ -22,7 +21,7 @@ async function getSongsFromYourLibrary(user_id: number, genre?: string) { const songsDetailsPromises = playlistSongIDs.map((songID) => getSongInfo(String(songID))); - const songsDetails = await Promise.all(songsDetailsPromises); + const songsDetails = await Promise.all(songsDetailsPromises) as LibrarySong[]; if (genre) { return songsDetails.filter(song => { @@ -73,7 +72,7 @@ export default function FromYourLibrary({ genre }: FromYourLibraryProps) { `` return ( -
+
{librarySongs.map((song, index) => (
diff --git a/apps/web/components/Home/LandingCarousel.tsx b/apps/web/components/Home/LandingCarousel.tsx index fa0df354..6e3a0619 100644 --- a/apps/web/components/Home/LandingCarousel.tsx +++ b/apps/web/components/Home/LandingCarousel.tsx @@ -106,7 +106,7 @@ export default function LandingCarousel() { const albumCoverURL = `${getBaseURL()}/image/${encodeURIComponent(album.cover_url)}?raw=true`; return ( -
+
{`${album.name}([]); + const [listenHistorySongs, setListenHistorySongs] = useState([]); const { session } = useSession() useEffect(() => { const fetchListenHistory = async () => { - const listenHistory = await getListenAgain(Number(session?.sub)); + const listenHistory = await getListenAgain(Number(session?.sub)) as ListenAgainSong[]; setListenHistorySongs(listenHistory); }; @@ -38,7 +38,7 @@ export default function ListenAgain({ genre }: ListenAgainProps) { <> -
+
{listenHistorySongs.map((item, index) => { if (item.item_type === "album") { return ( diff --git a/apps/web/components/Home/RandomSongs.tsx b/apps/web/components/Home/RandomSongs.tsx index a6b3b968..d7a33dbe 100644 --- a/apps/web/components/Home/RandomSongs.tsx +++ b/apps/web/components/Home/RandomSongs.tsx @@ -48,7 +48,7 @@ export default function RandomSongs({ genre }: RandomSongsProps) { return ( -
+
{randomSongs.map((song, index) => (
diff --git a/apps/web/components/Home/RecommendedAlbums.tsx b/apps/web/components/Home/RecommendedAlbums.tsx index 738bedf2..bb1b3811 100644 --- a/apps/web/components/Home/RecommendedAlbums.tsx +++ b/apps/web/components/Home/RecommendedAlbums.tsx @@ -1,13 +1,12 @@ "use client" -import getSession from "@/lib/Authentication/JWT/getSession"; import setCache, { getCache } from "@/lib/Caching/cache"; import { getPlaylist, getPlaylists, getSongInfo } from "@music/sdk"; +import { LibrarySong } from "@music/sdk/types"; import { useEffect, useState } from "react"; import AlbumCard from "../Music/Card/Album/AlbumCard"; -import ScrollButtons from "./ScrollButtons"; -import { LibrarySong } from "@music/sdk/types"; import { useSession } from "../Providers/AuthProvider"; +import ScrollButtons from "./ScrollButtons"; async function getSongsFromYourLibrary(user_id: number, genre?: string) { const playlists = await getPlaylists(user_id); @@ -22,7 +21,7 @@ async function getSongsFromYourLibrary(user_id: number, genre?: string) { const songsDetailsPromises = playlistSongIDs.map((songID) => getSongInfo(String(songID))); - const songsDetails = await Promise.all(songsDetailsPromises); + const songsDetails = await Promise.all(songsDetailsPromises) as unknown as LibrarySong[]; if (genre) { return songsDetails.filter(song => { @@ -73,7 +72,7 @@ export default function RecommendedAlbums({ genre }: RecommendedAlbumsProps) { return ( -
+
{librarySongs.map((song, index) => (
char.toUpperCase()); @@ -48,9 +46,9 @@ export default function SimilarTo() {
-
+
{similarAlbums.map((album, index) => ( -
+
item.song_id))); const songDetailsPromises = uniqueListenHistoryItems.reverse().slice(0, 30).map(song_id => getSongInfo(song_id)); - const songDetails = await Promise.all(songDetailsPromises); + const songDetails = await Promise.all(songDetailsPromises) as unknown as LibrarySong[]; setListenHistorySongs(songDetails); setCache(cacheKey, songDetails, 3600000); diff --git a/apps/web/components/Layout/Sidebar.tsx b/apps/web/components/Layout/Sidebar.tsx index ac6e146d..dfe70e40 100644 --- a/apps/web/components/Layout/Sidebar.tsx +++ b/apps/web/components/Layout/Sidebar.tsx @@ -30,8 +30,6 @@ export default function Sidebar({ children, sidebarContent }: SidebarProps) { const handleResize = () => { if (window.innerWidth < 768) { closeSidebar(); - } else { - openSidebar(); } }; diff --git a/apps/web/components/Music/Album/AlbumTable.tsx b/apps/web/components/Music/Album/AlbumTable.tsx index 1bdb27ae..901a1561 100644 --- a/apps/web/components/Music/Album/AlbumTable.tsx +++ b/apps/web/components/Music/Album/AlbumTable.tsx @@ -10,6 +10,8 @@ import SongContextMenu from "../SongContextMenu"; import { getArtistInfo } from "@music/sdk"; import Link from "next/link"; import { useSession } from "@/components/Providers/AuthProvider"; +import { Pause, Play, Volume2 } from "lucide-react"; +import { cn } from "@music/ui/lib/utils"; type PlaylistTableProps = { songs: LibrarySong[] @@ -31,9 +33,13 @@ function isSimilarLevenshtein(apiTrack: string, userTrack: string): boolean { } export default function AlbumTable({ songs, album, artist }: PlaylistTableProps) { - const { setImageSrc, setSong, setAudioSource, setArtist, setAlbum, addToQueue, setPlayedFromAlbum } = usePlayer(); + const { setImageSrc, setSong, setAudioSource, setArtist, setAlbum, addToQueue, song, setPlayedFromAlbum, isPlaying, togglePlayPause, volume } = usePlayer(); const [orderedSongs, setOrderedSongs] = useState([]); const [contributingArtists, setContributingArtists] = useState<{ [key: string]: Artist[] }>({}); + const [isHovered, setIsHovered] = useState(null); + const [isVolumeHovered, setIsVolumeHovered] = useState(false); + + let playingSongID = song.id const { session } = useSession() const bitrate = session?.bitrate ?? 0; @@ -111,14 +117,6 @@ export default function AlbumTable({ songs, album, artist }: PlaylistTableProps) return (
- {/* - - # - Title - Duration - - */} - {orderedSongs.map(song => ( - - handlePlay(album.cover_url, song, `${getBaseURL()}/api/stream/${encodeURIComponent(song.path)}?bitrate=${bitrate}`, artist)}> - {song.track_number} + + handlePlay(album.cover_url, song, `${getBaseURL()}/api/stream/${encodeURIComponent(song.path)}?bitrate=${bitrate}`, artist)} + onMouseEnter={() => setIsHovered(song.id)} + onMouseLeave={() => setIsHovered(null)} + className={cn( + "mx-2 rounded-lg", + playingSongID === song.id ? "bg-[#1e1e1d] bg-gray-600/50" : "" + )} + > + + {isHovered === song.id || playingSongID === song.id ? ( +
{ + e.stopPropagation(); + if (playingSongID === song.id) { + togglePlayPause(); + } else { + handlePlay(album.cover_url, song, `${getBaseURL()}/api/stream/${encodeURIComponent(song.path)}?bitrate=${bitrate}`, artist); + } + }} + > + {volume < 1 ? ( + isVolumeHovered ? ( + playingSongID === song.id && isPlaying ? ( + + ) : ( + + ) + ) : ( + setIsVolumeHovered(true)} + onMouseLeave={() => setIsVolumeHovered(false)} + /> + ) + ) : ( + playingSongID === song.id && isPlaying ? ( + + ) : ( + + ) + )} +
+ ) : ( + song.track_number + )} +
@@ -157,6 +202,6 @@ export default function AlbumTable({ songs, album, artist }: PlaylistTableProps) ))}
-
+
) } \ No newline at end of file diff --git a/apps/web/components/Music/Card/Album/AlbumCard.tsx b/apps/web/components/Music/Card/Album/AlbumCard.tsx index cfdd6013..12a75783 100644 --- a/apps/web/components/Music/Card/Album/AlbumCard.tsx +++ b/apps/web/components/Music/Card/Album/AlbumCard.tsx @@ -59,13 +59,13 @@ export default function AlbumCard({ {`${album_name}

{album_name}

-

{album_songs_count}

-
+

{album_songs_count}

+

- {artist_name} • {releaseDate} + {artist_name} {releaseDate == "Invalid Date" ? "" : "• " + releaseDate}

diff --git a/apps/web/components/Music/Card/HorizontalCard.tsx b/apps/web/components/Music/Card/HorizontalCard.tsx index cfe2b565..81d12db8 100644 --- a/apps/web/components/Music/Card/HorizontalCard.tsx +++ b/apps/web/components/Music/Card/HorizontalCard.tsx @@ -8,7 +8,7 @@ import formatDuration from "@/lib/Formatting/formatDuration"; import formatFollowers from "@/lib/Formatting/formatFollowers"; import formatReleaseDate from "@/lib/Formatting/formatReleaseDate"; import { getSongInfo } from "@music/sdk"; -import { AlbumInfo, ArtistInfo, CombinedItem } from "@music/sdk/types"; +import { AlbumInfo, ArtistInfo, CombinedItem, LibrarySong } from "@music/sdk/types"; import { FastAverageColor } from "fast-average-color"; import Image from "next/image"; import Link from "next/link"; @@ -47,7 +47,7 @@ export default function HorizontalCard({ item }: HorizontalCardProps) { async function handlePlay() { setImageSrc(imageUrl); - const song = await getSongInfo(item.id ?? "") + const song = await getSongInfo(item.id ?? "") as LibrarySong setArtist(song.artist_object); setAlbum(song.album_object); diff --git a/apps/web/components/Music/Card/MusicVideoCard.tsx b/apps/web/components/Music/Card/MusicVideoCard.tsx index 5575fb32..9a761662 100644 --- a/apps/web/components/Music/Card/MusicVideoCard.tsx +++ b/apps/web/components/Music/Card/MusicVideoCard.tsx @@ -5,23 +5,37 @@ import { LibrarySong, MusicVideoSong } from "@music/sdk/types"; import Image from "next/image"; import Link from "next/link"; import { useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@music/ui/components/dialog"; +import { Video } from "lucide-react"; +import ReactPlayer from 'react-player/youtube'; +import { usePlayer } from "../Player/usePlayer"; type MusicVideoCardProps = { song: MusicVideoSong }; export default function MusicVideoCard({ song }: MusicVideoCardProps) { - const [songInfo, setSongInfo] = useState() + const [songInfo, setSongInfo] = useState(); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isHovered, setIsHovered] = useState(false); + useEffect(() => { async function fetchSongInfo() { - const fetchedSongInfo = await getSongInfo(song.id) - setSongInfo(fetchedSongInfo) + const fetchedSongInfo = await getSongInfo(song.id) as LibrarySong; + setSongInfo(fetchedSongInfo); } - fetchSongInfo() - }, [song.id]) + fetchSongInfo(); + }, [song.id]); - if (!song || !song.music_video) return null + if (!song || !song.music_video) return null; const getVideoId = (url: string) => { try { @@ -41,24 +55,49 @@ export default function MusicVideoCard({ song }: MusicVideoCardProps) { const thumbnailUrl = videoId ? `http://i3.ytimg.com/vi/${videoId}/hqdefault.jpg` : ""; return ( -
{}}> - - YouTube Thumbnail - - +
+ + +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className="flex-grow cursor-pointer" + > + YouTube Thumbnail +
+
+ + + {song.name} Music Video + +
+ +
+ + +
+
+

- {song.name} + {song.name}

- {songInfo?.album_object.name} • {songInfo?.artist_object.name} + {songInfo?.album_object.name ?? ""} • {songInfo?.artist_object.name}

diff --git a/apps/web/components/Music/Card/Search/TopResultsCard.tsx b/apps/web/components/Music/Card/Search/TopResultsCard.tsx index 83cc7c37..7f88fc9e 100644 --- a/apps/web/components/Music/Card/Search/TopResultsCard.tsx +++ b/apps/web/components/Music/Card/Search/TopResultsCard.tsx @@ -4,144 +4,226 @@ import { getAlbumInfo, getArtistInfo, getSongInfo, LibraryAlbum } from '@music/s import { Artist, CombinedItem, LibrarySong } from '@music/sdk/types'; import Image from 'next/image'; import { useEffect, useState } from 'react'; +import { Pause, Play, Volume2 } from 'lucide-react'; import ArtistCard from '../../Artist/ArtistCard'; import AlbumCard from '../Album/AlbumCard'; import SongCard from '../SongCard'; +import Link from 'next/link'; +import { usePlayer } from '../../Player/usePlayer'; +import { useSession } from '@/components/Providers/AuthProvider'; type ResultCardProps = { result: CombinedItem } export default function TopResultsCard({ result }: ResultCardProps) { - const [artist, setArtist] = useState(null) - const [album, setAlbum] = useState(null) - const [song, setSong] = useState(null) + const [artist, setArtist] = useState(null); + const [album, setAlbum] = useState(null); + const [song, setSong] = useState(null); + + const [isVolumeHovered, setIsVolumeHovered] = useState(false); + + const { + setImageSrc, + setAudioSource, + setSong: setPlayerSong, + setArtist: setPlayerArtist, + setAlbum: setPlayerAlbum, + setPlayedFromAlbum, + song: playingSong, + isPlaying, + volume, + togglePlayPause, + } = usePlayer(); + + const playingSongID = playingSong?.id; + const { session } = useSession(); useEffect(() => { async function fetchInfo() { if (result.item_type === "song") { - const song = await getSongInfo(result.id) - setSong(song) + const song = await getSongInfo(result.id) as LibrarySong; + setSong(song); } if (result.item_type === "album") { - const album = await getAlbumInfo(result.id) - setAlbum(album) + const album = await getAlbumInfo(result.id) as LibraryAlbum; + setAlbum(album); } if (result.item_type === "artist") { - const artist = await getArtistInfo(result.id) - setArtist(artist) + const artist = await getArtistInfo(result.id) as Artist; + setArtist(artist); } } - fetchInfo() - }, [result.id, result.item_type]) + fetchInfo(); + }, [result.id, result.item_type]); -const coverUrl = result.item_type === "song" - ? song?.album_object.cover_url - : result.item_type === "album" - ? album?.cover_url - : result.item_type === "artist" - ? artist?.icon_url - : ""; + const coverUrl = result.item_type === "song" + ? song?.album_object.cover_url + : result.item_type === "album" + ? album?.cover_url + : result.item_type === "artist" + ? artist?.icon_url + : ""; const imageSrc = `${getBaseURL()}/image/${encodeURIComponent(coverUrl || "")}?raw=true`; - if (result.item_type === 'song' && song) { - return ( -
- Background Image 0) { + result += `${hours} hr${hours > 1 ? "s" : ""} `; + } + if (minutes > 0) { + result += `${minutes} min${minutes > 1 ? "s" : ""} `; + } + return result.trim(); + } + + async function handlePlay() { + if (!song) return; + setImageSrc(imageSrc); + setPlayerArtist({ id: song.artist_object.id, name: song.artist }); + setPlayerAlbum({ id: song.album_object.id, name: song.album_object.name, cover_url: song.album_object.cover_url }); + try { + const songInfo = await getSongInfo(song.id); + setPlayerSong(songInfo); + const songURL = `${getBaseURL()}/api/stream/${encodeURIComponent(song.path)}?bitrate=${(session && session.bitrate) || 0}`; + setAudioSource(songURL); + setPlayedFromAlbum(false); + } catch (error) { + console.error("Failed to fetch song info:", error); + } + } + + if (result.item_type === 'song' && song) { + return ( +
+
+
-
- + {song.name + {playingSongID === song.id && ( +
{ + e.stopPropagation(); + togglePlayPause(); + }} + > + {volume < 1 ? ( + isVolumeHovered ? ( + isPlaying ? ( + + ) : ( + + ) + ) : ( + setIsVolumeHovered(true)} + onMouseLeave={() => setIsVolumeHovered(false)} + /> + ) + ) : isPlaying ? ( + + ) : ( + + )} +
+ )}
-
- ); - } else if (result.item_type === 'artist' && artist) { - return ( -
- Background Image -
- +
+

{song.name}

+

+ Song • {song.artist} • {song.album_object.name} • {formatDuration(song.duration)} +

+
- ); - } else if (result.item_type === 'album' && album) { - return ( -
- Background Image + ); + } else if (result.item_type === 'artist' && artist) { + return ( +
+ Background Image +
+ +
+
+ ); + } else if (result.item_type === 'album' && album) { + return ( +
+ Background Image +
+ -
- -
- ); - } -} +
+ ); + } +} \ No newline at end of file diff --git a/apps/web/components/Music/Card/SongCard.tsx b/apps/web/components/Music/Card/SongCard.tsx index 0ab013fb..d6f72214 100644 --- a/apps/web/components/Music/Card/SongCard.tsx +++ b/apps/web/components/Music/Card/SongCard.tsx @@ -1,15 +1,16 @@ "use client"; import { useGradientHover } from "@/components/Providers/GradientHoverProvider"; -import getSession from "@/lib/Authentication/JWT/getSession"; import getBaseURL from "@/lib/Server/getBaseURL"; import { FastAverageColor } from "fast-average-color"; +import { Play, Pause, Volume2 } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { usePlayer } from "../Player/usePlayer"; import SongContextMenu from "../SongContextMenu"; import { getSongInfo } from "@music/sdk"; import { useSession } from "@/components/Providers/AuthProvider"; +import { useState } from "react"; type SongCardProps = { song_name: string; @@ -39,9 +40,15 @@ export default function SongCard({ setArtist, setAlbum, setPlayedFromAlbum, + song, + isPlaying, + volume, + togglePlayPause, } = usePlayer(); - const { session } = useSession() + const playingSongID = song?.id; + + const { session } = useSession(); const artist = { id: artist_id, name: artist_name }; const album = { id: album_id, name: album_name, cover_url: album_cover }; @@ -51,7 +58,6 @@ export default function SongCard({ ? "/snf.png" : `${getBaseURL()}/image/${encodeURIComponent(album_cover)}`; - let songURL = `${getBaseURL()}/api/stream/${encodeURIComponent( path )}?bitrate=${(session && session.bitrate) || 0}`; @@ -60,10 +66,14 @@ export default function SongCard({ setImageSrc(imageSrc); setArtist(artist); setAlbum(album); - const songInfo = await getSongInfo(song_id) - setSong(songInfo); - setAudioSource(songURL); - setPlayedFromAlbum(false); + try { + const songInfo = await getSongInfo(song_id); + setSong(songInfo); + setAudioSource(songURL); + setPlayedFromAlbum(false); + } catch (error) { + console.error("Failed to fetch song info:", error); + } } const { setGradient } = useGradientHover(); @@ -71,35 +81,94 @@ export default function SongCard({ function setDominantGradient() { const fac = new FastAverageColor(); const getColor = async () => { - const color = await fac.getColorAsync(imageSrc); - setGradient(color.hex); + try { + const color = await fac.getColorAsync(imageSrc); + setGradient(color.hex); + } catch (error) { + console.error("Failed to get dominant color:", error); + } }; getColor(); } + const [isHovered, setIsHovered] = useState(false); + const [isVolumeHovered, setIsVolumeHovered] = useState(false); + return ( -
- - {song_name +
{ + setDominantGradient(); + setIsHovered(true); + }} + onMouseLeave={() => setIsHovered(false)} + > + +
+ {song_name + {playingSongID === song_id && ( +
{ + e.stopPropagation(); + togglePlayPause(); + }} + > + {volume < 1 ? ( + isVolumeHovered ? ( + isPlaying ? ( + + ) : ( + + ) + ) : ( + setIsVolumeHovered(true)} + onMouseLeave={() => setIsVolumeHovered(false)} + /> + ) + ) : isPlaying ? ( + + ) : ( + + )} +
+ )} +
- -
+ +

{song_name}

-

- Song • {artist_name} +

+ Song • + + + {artist_name} + +

diff --git a/apps/web/components/Music/EditSongDialog.tsx b/apps/web/components/Music/EditSongDialog.tsx new file mode 100644 index 00000000..fb5bd392 --- /dev/null +++ b/apps/web/components/Music/EditSongDialog.tsx @@ -0,0 +1,120 @@ +import { editSongMetadata, getSongInfo } from "@music/sdk"; +import { BareSong } from "@music/sdk/types"; +import { Button } from "@music/ui/components/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@music/ui/components/dialog"; +import { Input } from "@music/ui/components/input"; +import { ContextMenuItem } from "@radix-ui/react-context-menu"; +import { CirclePlus, Pencil } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +type EditSongDialogProps = { + song_id: string; +}; + +export default function EditSongDialog({ + song_id, +}: EditSongDialogProps) { + const [name, setName] = useState(""); + const [artist, setArtist] = useState("") + const [contributingArtists, setContributingArtists] = useState([]); + const [contributingArtistIds, setContributingArtistIds] = useState([]); + const [trackNumber, setTrackNumber] = useState(0); + const [pathValue, setPath] = useState(""); + const [durationValue, setDuration] = useState(0); + const [open, setOpen] = useState(false); + + useEffect(() => { + const fetchSongInfo = async () => { + try { + const songInfo: BareSong = await getSongInfo(song_id, true); + setName(songInfo.name); + setArtist(songInfo.artist) + setContributingArtists(songInfo.contributing_artists); + setContributingArtistIds(songInfo.contributing_artist_ids); + setTrackNumber(songInfo.track_number); + setPath(songInfo.path); + setDuration(songInfo.duration); + } catch (error) { + console.error("Failed to fetch song info:", error); + } + }; + + if (open) { + fetchSongInfo(); + } + }, [open, song_id]); + + const handleEdit = useCallback(async () => { + const updatedSong: BareSong = { + id: song_id, + name, + artist, + contributing_artists: contributingArtists, + contributing_artist_ids: contributingArtistIds, + track_number: trackNumber, + path: pathValue, + duration: durationValue, + music_video: undefined, + }; + try { + await editSongMetadata(updatedSong); + setOpen(false); + } catch (error) { + console.error("Failed to edit song metadata:", error); + } + }, [song_id, name, contributingArtists, contributingArtistIds, trackNumber, pathValue, durationValue, artist]); + + return ( + + + setOpen(true)}> + +

Edit Metadata

+
+ {/* */} +
+ + + Edit Song Metadata + +
+
+ + setName(e.target.value)} /> +
+
+ + setArtist(e.target.value)} /> +
+
+ + setContributingArtists(e.target.value.split(", "))} /> +
+
+ + setContributingArtistIds(e.target.value.split(", "))} /> +
+
+ + setTrackNumber(Number(e.target.value))} /> +
+
+ + setPath(e.target.value)} /> +
+
+ + setDuration(Number(e.target.value))} /> +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/components/Music/Player.tsx b/apps/web/components/Music/Player.tsx index 23ea0de6..ca6c1903 100644 --- a/apps/web/components/Music/Player.tsx +++ b/apps/web/components/Music/Player.tsx @@ -1,8 +1,5 @@ "use client"; -import React, { useState, useEffect, useRef, useContext } from "react"; -import * as Tone from "tone"; -import getSession from "@/lib/Authentication/JWT/getSession"; import getBaseURL from "@/lib/Server/getBaseURL"; import { Slider, @@ -10,25 +7,23 @@ import { SliderThumb, SliderTrack, } from "@music/ui/components/slider"; -import { Popover, PopoverContent, PopoverTrigger } from "@music/ui/components/popover"; -import { FastAverageColor } from "fast-average-color"; import { AudioLines, BookAudioIcon, MicVocal, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; +import React, { useContext, useEffect, useRef, useState } from "react"; +import * as Tone from "tone"; import { AIContext } from "../AI/AIOverlayContext"; import ArrowPath from "../Icons/ArrowPath"; import IconQueue from "../Icons/IconQueue"; import IconPause from "../Icons/Pause"; import IconPlay from "../Icons/Play"; import { LyricsContext } from "../Lyrics/LyricsOverlayContext"; +import { useSession } from "../Providers/AuthProvider"; import { useGradientHover } from "../Providers/GradientHoverProvider"; import { useReverb } from "../Providers/SlowedReverbProvider"; import { usePlayer } from "./Player/usePlayer"; import VideoPlayerDialog from "./Player/VideoPlayerDialog"; import { PanelContext } from "./Queue/QueuePanelContext"; -import { Input } from "@music/ui/components/input"; -import { Label } from "@radix-ui/react-label"; -import { useSession } from "../Providers/AuthProvider"; export default function Player() { const [liked, setLiked] = useState(false); @@ -241,7 +236,7 @@ export default function Player() { height={400} width={400} className="w-full h-full object-fill rounded" - /> + />

{artist.name} - {song.contributing_artists.map((artist, index) => ( + {song.contributing_artist_ids && song.contributing_artists.map((artist, index) => ( , {artist} diff --git a/apps/web/components/Music/Player/VideoPlayerDialog.tsx b/apps/web/components/Music/Player/VideoPlayerDialog.tsx index aa9a3062..4168607f 100644 --- a/apps/web/components/Music/Player/VideoPlayerDialog.tsx +++ b/apps/web/components/Music/Player/VideoPlayerDialog.tsx @@ -19,39 +19,65 @@ export default function VideoPlayerDialog({ url }: VideoPlayerDialogProps) { const { isPlaying, togglePlayPause, song, currentTime, handleTimeChange } = usePlayer(); const [isDialogOpen, setIsDialogOpen] = useState(false); const [isHovered, setIsHovered] = useState(false); - const videoTimeRef = useRef(currentTime); + const videoTimeRef = useRef(currentTime); + const wasPlayingRef = useRef(false); const playerRef = useRef(null); - const hasUpdatedTimeRef = useRef(false); + const lastKnownTimeRef = useRef(currentTime); - useEffect(() => { - if (isPlaying && isDialogOpen) { - togglePlayPause(); - } - }, [isPlaying, isDialogOpen, togglePlayPause]); + const validateTime = (time: number, duration: number): number => { + if (isNaN(time) || time < 0) return 0; + if (duration && time > duration) return duration; + return time; + }; useEffect(() => { - if (isDialogOpen && playerRef.current) { - playerRef.current.seekTo(currentTime, 'seconds'); - hasUpdatedTimeRef.current = true; + if (isDialogOpen && isPlaying) { + wasPlayingRef.current = true; + togglePlayPause(); } - }, [isDialogOpen, currentTime]); + }, [isDialogOpen, isPlaying, togglePlayPause]); useEffect(() => { - if (!isDialogOpen && hasUpdatedTimeRef.current) { - handleTimeChange(videoTimeRef.current.toString()); - hasUpdatedTimeRef.current = false; + if (!isDialogOpen) { + if (videoTimeRef.current !== undefined) { + const duration = playerRef.current?.getDuration() ?? 0; + const validTime = validateTime(videoTimeRef.current, duration); + handleTimeChange(validTime.toString()); + videoTimeRef.current = 0; + + if (wasPlayingRef.current) { + setTimeout(() => { + togglePlayPause(); + wasPlayingRef.current = false; + }, 100); + } + } } - }, [isDialogOpen, handleTimeChange]); + }, [isDialogOpen, handleTimeChange, togglePlayPause]); const handleReady = () => { - if (playerRef.current) { - playerRef.current.seekTo(videoTimeRef.current, 'seconds'); - playerRef.current.getInternalPlayer().playVideo(); + if (playerRef.current && isDialogOpen) { + const player = playerRef.current; + const duration = player.getDuration(); + const validTime = validateTime(currentTime, duration); + player.seekTo(validTime, 'seconds'); + if (isPlaying) { + player.getInternalPlayer().playVideo(); + } } }; const handleProgress = ({ playedSeconds }: { playedSeconds: number }) => { - videoTimeRef.current = playedSeconds; + if (!isNaN(playedSeconds) && isDialogOpen) { + videoTimeRef.current = playedSeconds; + lastKnownTimeRef.current = playedSeconds; + } + }; + + const handlePause = () => { + if (playerRef.current) { + videoTimeRef.current = playerRef.current.getCurrentTime(); + } }; return ( @@ -73,11 +99,12 @@ export default function VideoPlayerDialog({ url }: VideoPlayerDialogProps) { ref={playerRef} controls width="100%" - height={400} + height="70vh" pip={true} playing={isDialogOpen} onReady={handleReady} onProgress={handleProgress} + onPause={handlePause} url={url} />

diff --git a/apps/web/components/Music/Player/usePlayer.tsx b/apps/web/components/Music/Player/usePlayer.tsx index d0061a46..b6ff6d3f 100644 --- a/apps/web/components/Music/Player/usePlayer.tsx +++ b/apps/web/components/Music/Player/usePlayer.tsx @@ -270,7 +270,7 @@ export function PlayerProvider({ children }: PlayerProviderProps) { for (let shuffledSong of shuffledRecommendedSongs) { if (shuffledSong.name !== song.name) { - recommendedSong = await getSongInfo(shuffledSong.id); + recommendedSong = await getSongInfo(shuffledSong.id) as LibrarySong; break; } } diff --git a/apps/web/components/Music/SongContextMenu.tsx b/apps/web/components/Music/SongContextMenu.tsx index 25f8dad7..e6ab2e22 100644 --- a/apps/web/components/Music/SongContextMenu.tsx +++ b/apps/web/components/Music/SongContextMenu.tsx @@ -13,7 +13,9 @@ import { DialogContent, DialogHeader, DialogTitle, - DialogTrigger + DialogTrigger, + DialogClose, + DialogFooter } from "@music/ui/components/dialog"; import { Table, @@ -23,15 +25,16 @@ import { TableRow } from "@music/ui/components/table"; -import getSession from "@/lib/Authentication/JWT/getSession"; import { addSongToPlaylist, getPlaylists, PlaylistsResponse, getSongInfo } from "@music/sdk"; import { CircleArrowUp, CirclePlus, ExternalLink, ListEnd, Plus, UserRoundSearch } from "lucide-react"; import Link from "next/link"; -import { useEffect, useState, useTransition } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import Bars3Left from "../Icons/Bars3Left"; import { usePlayer } from "./Player/usePlayer"; -import { LibrarySong } from "@music/sdk/types"; +import { LibrarySong, BareSong } from "@music/sdk/types"; import { useSession } from "../Providers/AuthProvider"; +import { Button } from "@music/ui/components/button"; +import EditSongDialog from "./EditSongDialog"; type SongContextMenuProps = { children: React.ReactNode; @@ -53,28 +56,80 @@ export default function SongContextMenu({ album_name, }: SongContextMenuProps) { const [playlists, setPlaylists] = useState(null); - const [songInfo, setSongInfo] = useState(null); - const { session } = useSession() + const [songInfo, setSongInfo] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const { session } = useSession(); + const isMounted = useRef(true); + const dialogCleanupTimeout = useRef(); + + // Cleanup function + useEffect(() => { + return () => { + isMounted.current = false; + if (dialogCleanupTimeout.current) { + clearTimeout(dialogCleanupTimeout.current); + } + }; + }, []); useEffect(() => { const getPlaylistsRequest = async () => { - let playlists = await getPlaylists(Number(session?.sub) ?? 0); - setPlaylists(playlists); + try { + if (!session?.sub) return; + const playlists = await getPlaylists(Number(session.sub)); + if (isMounted.current) { + setPlaylists(playlists); + } + } catch (error) { + console.error("Failed to fetch playlists:", error); + } }; getPlaylistsRequest(); }, [session?.sub]); - const [isPending, startTransition] = useTransition(); - + const { addToQueue } = usePlayer(); + + const handleViewProperties = useCallback(async () => { + if (!isMounted.current) return; + try { + const info = await getSongInfo(song_id, true); + if (isMounted.current) { + setSongInfo(info as BareSong); + } + } catch (error) { + console.error("Failed to fetch song info:", error); + } + }, [song_id]); + const handleOpenChange = useCallback((open: boolean) => { + if (open) { + handleViewProperties(); + } else { + setSongInfo(null); + } + setIsDialogOpen(open); +}, [handleViewProperties]); + + const handleDialogOpenChange = useCallback((open: boolean) => { + if (!isMounted.current) return; + + if (open) { + setIsDialogOpen(true); + handleViewProperties(); + } else { + // Delay cleanup to prevent state updates during unmount + dialogCleanupTimeout.current = setTimeout(() => { + if (isMounted.current) { + setSongInfo(null); + setIsDialogOpen(false); + } + }, 100); + } + }, [handleViewProperties]); - const handleViewProperties = async () => { - const songInfo = await getSongInfo(song_id); - setSongInfo(songInfo); - }; return ( - + <> {children} @@ -138,17 +193,20 @@ export default function SongContextMenu({ - - - -

View Properties

-
-
+ setIsDialogOpen(true)}> + +

View Properties

+
+ +
- - - Song Information + + + + + Song Information + @@ -180,8 +238,15 @@ export default function SongContextMenu({
-
-
-
+ + + + + + + + ); } \ No newline at end of file diff --git a/apps/web/components/Providers/LayoutConfigContext.tsx b/apps/web/components/Providers/LayoutConfigContext.tsx index ac8962a5..5dae3fc4 100644 --- a/apps/web/components/Providers/LayoutConfigContext.tsx +++ b/apps/web/components/Providers/LayoutConfigContext.tsx @@ -1,4 +1,4 @@ -"use client" +"use client"; import React, { createContext, useContext, useState, useEffect } from 'react'; @@ -17,15 +17,7 @@ type LayoutConfigContextType = { const LayoutConfigContext = createContext(undefined); export const LayoutConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [components, setComponents] = useState([ - { id: "LandingCarousel", name: "Landing Carousel", visible: true, pinned: false }, - { id: "ListenAgain", name: "Listen Again", visible: true, pinned: false }, - { id: "SimilarTo", name: "Similar To", visible: true, pinned: false }, - { id: "RecommendedAlbums", name: "Recommended Albums", visible: true, pinned: false }, - { id: "RandomSongs", name: "Random Songs", visible: true, pinned: false }, - { id: "FromYourLibrary", name: "From Your Library", visible: true, pinned: false }, - { id: "MusicVideos", name: "Music Videos", visible: true, pinned: false }, - ]); + const [components, setComponents] = useState([]); useEffect(() => { const savedConfig = localStorage.getItem("layoutConfig"); @@ -45,7 +37,9 @@ export const LayoutConfigProvider: React.FC<{ children: React.ReactNode }> = ({ }, []); useEffect(() => { - localStorage.setItem("layoutConfig", JSON.stringify(components)); + if (components.length > 0) { + localStorage.setItem("layoutConfig", JSON.stringify(components)); + } }, [components]); return ( diff --git a/apps/web/components/User/ProfilePicture.tsx b/apps/web/components/User/ProfilePicture.tsx new file mode 100644 index 00000000..52bb4e27 --- /dev/null +++ b/apps/web/components/User/ProfilePicture.tsx @@ -0,0 +1,33 @@ +import { getProfilePicture } from "@music/sdk" +import { Avatar, AvatarFallback, AvatarImage } from "@music/ui/components/avatar" +import { useEffect, useState } from "react" +import { useSession } from "../Providers/AuthProvider" + +export default function ProfilePicture() { + const [profilePicture, setProfilePicture] = useState(null) + const { session } = useSession() + + useEffect(() => { + const fetchSessionAndProfilePicture = async () => { + if (session) { + const profilePic = await getProfilePicture(Number(session.sub)) + if (profilePic) { + setProfilePicture(URL.createObjectURL(profilePic)) + } else { + setProfilePicture(null) + } + } + } + fetchSessionAndProfilePicture() + }, [session]) + + return ( + + {profilePicture ? ( + + ) : ( + {session?.username.substring(0, 2).toUpperCase()} + )} + + ) +} \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 7ba0bee4..80364499 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,7 @@ "main": "electron/main.js", "author": "ParsonLabs", "scripts": { - "dev": "next dev", + "dev": "next dev --turbo", "electron": "concurrently -n \"NEXT,ELECTRON\" -c \"yellow,blue\" --kill-others \"next dev\" \"electron .\"", "electron-build": "next build && electron-builder", "tauri": "tauri dev", diff --git a/crates/backend/src/main.rs b/crates/backend/src/main.rs index 18bc60be..a41be930 100644 --- a/crates/backend/src/main.rs +++ b/crates/backend/src/main.rs @@ -10,13 +10,12 @@ use actix_cors::Cors; use actix_web::HttpResponse; use actix_web::{middleware, web, App, HttpServer}; use actix_web_httpauth::middleware::HttpAuthentication; -use routes::authentication::admin_guard; -use routes::authentication::refresh; +use routes::authentication::{admin_guard, refresh}; use tokio::task; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; -use routes::{album, database, genres}; +use routes::{album, database, genres, music}; use routes::artist; use routes::authentication::{login, register, validator}; use routes::filesystem; @@ -131,7 +130,6 @@ async fn main() -> std::io::Result<()> { } } - info!("Starting server on port {}", port); task::spawn(async move { @@ -154,10 +152,10 @@ async fn main() -> std::io::Result<()> { let protected = web::scope("/api") .wrap(authentication) - .service(songs_list) - .service(test) - .service(stream_song) - .service(format_contributing_artists_route) + .service(music::songs_list) + .service(music::test) + .service(music::stream_song) + .service(music::format_contributing_artists_route) .configure(artist::configure) .configure(album::configure) .configure(song::configure) diff --git a/crates/backend/src/routes/album.rs b/crates/backend/src/routes/album.rs index a232a972..cf72fee3 100644 --- a/crates/backend/src/routes/album.rs +++ b/crates/backend/src/routes/album.rs @@ -1,9 +1,14 @@ -use actix_web::{get, web, HttpResponse}; +use std::sync::Arc; + +use actix_web::{delete, get, post, web, HttpResponse}; use rand::seq::{IteratorRandom, SliceRandom}; use serde::{Deserialize, Serialize}; +use tracing::error; -use crate::structures::structures::{Artist, ReleaseAlbum, ReleaseGroupAlbum, Song}; -use crate::utils::config::{fetch_library, get_config}; +use crate::routes::search::populate_search_data; +use crate::structures::structures::{Album, Artist, ReleaseAlbum, ReleaseGroupAlbum, Song}; +use crate::utils::config::{fetch_library, get_config, refresh_cache, save_library}; +use crate::utils::hash::hash_artist; #[derive(Serialize, Deserialize, Clone)] pub struct ResponseAlbum { @@ -72,13 +77,23 @@ pub async fn fetch_random_albums(amount: usize) -> Result, () Ok(random_albums_with_artists) } -pub async fn fetch_album_info(album_id: String) -> Result { +#[derive(Serialize, Deserialize)] +pub enum AlbumInfo { + Full(ResponseAlbum), + Bare(Album) +} + +pub async fn fetch_album_info(album_id: String, bare: Option) -> Result { let library = fetch_library().await.map_err(|_| ())?; + let bare = bare.unwrap_or(false); for artist in library.iter() { for album in artist.albums.iter() { if album.id == album_id { - return Ok(ResponseAlbum { + if bare { + return Ok(AlbumInfo::Bare(album.clone())) + } + return Ok(AlbumInfo::Full(ResponseAlbum { id: album.id.clone(), name: album.name.clone(), cover_url: album.cover_url.clone(), @@ -93,7 +108,7 @@ pub async fn fetch_album_info(album_id: String) -> Result { contributing_artists_ids: album.contributing_artists_ids.clone(), release_album: album.release_album.clone(), release_group_album: album.release_group_album.clone() - }); + })); } } } @@ -101,6 +116,39 @@ pub async fn fetch_album_info(album_id: String) -> Result { Err(()) } +#[post("/edit/{id}")] +async fn edit_album_metadata(form: web::Json) -> HttpResponse { + let mut library = match fetch_library().await { + Ok(lib) => lib, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + + let new_album = form.into_inner(); + let mut album_found = false; + + for artist in Arc::make_mut(&mut library).iter_mut() { + for album in artist.albums.iter_mut() { + if album.id == new_album.id { + *album= new_album.clone(); + album_found = true; + break; + } + } + } + + if album_found { + if save_library(&library).await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + if refresh_cache().await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + HttpResponse::Ok().finish() + } else { + HttpResponse::InternalServerError().finish() + } +} + #[get("/random/{amount}")] async fn get_random_album(amount: web::Path) -> HttpResponse { match fetch_random_albums(*amount).await { @@ -108,19 +156,147 @@ async fn get_random_album(amount: web::Path) -> HttpResponse { Err(_) => HttpResponse::InternalServerError().finish(), } } +#[derive(Deserialize)] +pub struct AlbumQuery { + bare: Option, +} #[get("/info/{id}")] -async fn get_album_info(id: web::Path) -> HttpResponse { - match fetch_album_info(id.into_inner()).await { +async fn get_album_info(id: web::Path, query: web::Query) -> HttpResponse { + let bare = query.bare.unwrap_or(false); + match fetch_album_info(id.into_inner(), Some(bare)).await { Ok(album) => HttpResponse::Ok().json(album), Err(_) => HttpResponse::NotFound().finish(), } } +#[derive(Deserialize)] +pub struct AddAlbumForm { + album: Album, + artist_id: Option, +} + +#[post("/add")] +pub async fn add_album(form: web::Json) -> HttpResponse { + let new_album = form.album.clone(); + let artist_id = form.artist_id.clone(); + + let mut library = match fetch_library().await { + Ok(lib) => lib, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + + if let Some(artist_id) = artist_id { + for artist in Arc::make_mut(&mut library).iter_mut() { + if artist.id == artist_id { + artist.albums.push(new_album.clone()); + break; + } + } + } else { + let artist_name = new_album.name.clone(); + + let mut artist_found = false; + for artist in Arc::make_mut(&mut library).iter_mut() { + if artist.name == artist_name { + artist.albums.push(new_album.clone()); + artist_found = true; + break; + } + } + + if !artist_found { + let new_artist = Artist { + id: hash_artist(&artist_name), + name: artist_name.clone(), + albums: vec![new_album.clone()], + featured_on_album_ids: vec![], + icon_url: String::new(), + followers: 0, + description: String::new(), + tadb_music_videos: None, + }; + Arc::make_mut(&mut library).push(new_artist); + } + } + + if save_library(&library).await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + + if refresh_cache().await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + + match populate_search_data().await { + Ok(_) => {}, + Err(e) => { + error!("Failed to populate search data: {:?}", e); + } + } + + HttpResponse::Ok().finish() +} + +#[derive(Deserialize)] +pub struct DeleteAlbumForm { + album_id: String, +} + +#[delete("/delete")] +pub async fn delete_album(form: web::Json) -> HttpResponse { + let album_id = form.album_id.clone(); + + let mut library = match fetch_library().await { + Ok(lib) => lib, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + + let mut album_found = false; + + for artist in Arc::make_mut(&mut library).iter_mut() { + artist.albums.retain(|album| { + if album.id == album_id { + album_found = true; + false + } else { + true + } + }); + if album_found { + break; + } + } + + if !album_found { + return HttpResponse::NotFound().finish(); + } + + if save_library(&library).await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + + if refresh_cache().await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + + match populate_search_data().await { + Ok(_) => {}, + Err(e) => { + error!("Failed to populate search data: {:?}", e); + } + } + + HttpResponse::Ok().finish() +} + pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/album") .service(get_random_album) - .service(get_album_info), + .service(get_album_info) + .service(edit_album_metadata) + .service(add_album) + .service(delete_album) ); } \ No newline at end of file diff --git a/crates/backend/src/routes/artist.rs b/crates/backend/src/routes/artist.rs index 94dc1079..fe4be4cf 100644 --- a/crates/backend/src/routes/artist.rs +++ b/crates/backend/src/routes/artist.rs @@ -1,8 +1,11 @@ -use actix_web::{get, web, HttpResponse}; +use std::sync::Arc; + +use actix_web::{delete, get, post, web, HttpResponse}; use rand::seq::SliceRandom; +use serde::Deserialize; use crate::structures::structures::Artist; -use crate::utils::config::get_config; +use crate::utils::config::{fetch_library, get_config, refresh_cache, save_library}; pub async fn fetch_random_artists(amount: usize) -> Result, ()> { let config = get_config().await.map_err(|_| ())?; @@ -55,10 +58,101 @@ async fn get_artist_info(id: web::Path) -> HttpResponse { } } +#[post("/edit/{id}")] +async fn edit_artist_metadata(form: web::Json) -> HttpResponse { + let mut library = match fetch_library().await { + Ok(lib) => lib, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + + let new_artist= form.into_inner(); + let mut artist_found = false; + + for artist in Arc::make_mut(&mut library).iter_mut() { + if artist.id == new_artist.id { + *artist= new_artist.clone(); + artist_found = true; + break; + } + } + + if artist_found { + if save_library(&library).await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + if refresh_cache().await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + HttpResponse::Ok().finish() + } else { + HttpResponse::InternalServerError().finish() + } +} + +#[derive(Deserialize)] +pub struct AddArtistForm { + artist: Artist, +} + +#[post("/add")] +pub async fn add_artist(form: web::Json) -> HttpResponse { + let new_artist = form.artist.clone(); + + let mut library = match fetch_library().await { + Ok(lib) => lib, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + + Arc::make_mut(&mut library).push(new_artist); + + if save_library(&library).await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + + if refresh_cache().await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + + HttpResponse::Ok().finish() +} + +#[derive(Deserialize)] +pub struct DeleteArtistForm { + artist_id: String, +} + +#[delete("/delete")] +pub async fn delete_artist(form: web::Json) -> HttpResponse { + let artist_id = form.artist_id.clone(); + + let mut library = match fetch_library().await { + Ok(lib) => lib, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + + let initial_len = library.len(); + Arc::make_mut(&mut library).retain(|artist| artist.id != artist_id); + + if library.len() == initial_len { + return HttpResponse::NotFound().finish(); + } + + if save_library(&library).await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + + if refresh_cache().await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + + HttpResponse::Ok().finish() +} + pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/artist") .service(get_random_artist) - .service(get_artist_info), + .service(get_artist_info) + .service(edit_artist_metadata) ); } \ No newline at end of file diff --git a/crates/backend/src/routes/authentication.rs b/crates/backend/src/routes/authentication.rs index b054f4b7..ff486497 100644 --- a/crates/backend/src/routes/authentication.rs +++ b/crates/backend/src/routes/authentication.rs @@ -1,3 +1,5 @@ +use std::env; + use actix_web::{cookie::{Cookie, SameSite}, dev::ServiceRequest, http::header, post, web, HttpRequest, HttpResponse, Responder}; use actix_web_httpauth::extractors::bearer::BearerAuth; use argon2::{ @@ -320,6 +322,10 @@ pub fn validator( ) -> Ready> { dotenv().ok(); + if env::var("LOCAL_APP").is_ok() { + return ready(Ok(req)); + } + let token = if let Some(cookie_header) = req.headers().get(header::COOKIE) { if let Ok(cookie_str) = cookie_header.to_str() { cookie_str.split(';') diff --git a/crates/backend/src/routes/music.rs b/crates/backend/src/routes/music.rs index 2d951de9..51515db8 100644 --- a/crates/backend/src/routes/music.rs +++ b/crates/backend/src/routes/music.rs @@ -148,7 +148,8 @@ pub async fn process_library(path_to_library: web::Path) -> impl Respond let json = serde_json::to_string(data_to_serialize).unwrap(); - save_config(&json).await.unwrap(); + save_config(&json, true).await.unwrap(); + match populate_search_data().await { Ok(_) => {}, Err(e) => { @@ -174,7 +175,7 @@ async fn stream_song( ) -> impl Responder { let song = path.into_inner(); let bitrate = query.bitrate; - let slowed_reverb = query.slowed_reverb.unwrap_or(false); + let slowed_reverb: bool = query.slowed_reverb.unwrap_or(false); let file = tokio::fs::metadata(&song).await.unwrap(); let song_file_size = file.len(); @@ -223,7 +224,7 @@ async fn stream_song( .append_header((header::CONTENT_RANGE, content_range)) .body(Bytes::from(buffer)) } else { - let mut command = Command::new("ffmpeg"); + let mut command: Command = Command::new("ffmpeg"); if slowed_reverb { command.args(&[ @@ -268,8 +269,9 @@ async fn stream_song( #[get("/format/{artist}")] async fn format_contributing_artists_route(artist: web::Path) -> impl Responder { let artists = vec![artist.to_string()]; - let formatted_artists = format_contributing_artists(artists); + let formatted_artists: Vec<(String, Vec)> = Into::)>>::into(&*&mut *format_contributing_artists(artists)); let json = serde_json::to_string(&formatted_artists).unwrap(); + HttpResponse::Ok() .content_type("application/json; charset=utf-8") .body(json) diff --git a/crates/backend/src/routes/search.rs b/crates/backend/src/routes/search.rs index 5542384c..840a914a 100644 --- a/crates/backend/src/routes/search.rs +++ b/crates/backend/src/routes/search.rs @@ -349,12 +349,12 @@ async fn search_fn(query: web::Query) -> HttpResponse { let acronym = retrieved_doc.get_first(schema.get_field("acronym").unwrap()).unwrap().as_str().unwrap().to_string(); let song_object = if item_type == "song" { - fetch_song_info(id.clone(), None).await.ok() + fetch_song_info(id.clone(), None, None).await.ok() } else { None }; let album_object = if item_type == "album" { - fetch_album_info(id.clone()).await.ok() + fetch_album_info(id.clone(), None).await.ok() } else { None }; @@ -370,54 +370,65 @@ async fn search_fn(query: web::Query) -> HttpResponse { id, description, acronym, - artist_object: if item_type == "song" { - song_object.as_ref().map(|song| ArtistInfo { - id: song.artist_object.id.clone(), - name: song.artist_object.name.clone(), - icon_url: song.artist_object.icon_url.clone(), - followers: song.artist_object.followers, - description: song.artist_object.description.clone(), - }) - } else if item_type == "album" { - album_object.as_ref().map(|album| ArtistInfo { - id: album.artist_object.id.clone(), - name: album.artist_object.name.clone(), - icon_url: album.artist_object.icon_url.clone(), - followers: album.artist_object.followers, - description: album.artist_object.description.clone(), - }) - } else if item_type == "artist" { - artist_object.as_ref().map(|artist| ArtistInfo { + artist_object: match item_type.as_str() { + "song" => song_object.as_ref().and_then(|song| match song { + super::song::SongInfo::Full(song) => Some(ArtistInfo { + id: song.artist_object.id.clone(), + name: song.artist_object.name.clone(), + icon_url: song.artist_object.icon_url.clone(), + followers: song.artist_object.followers, + description: song.artist_object.description.clone(), + }), + _ => None, + }), + "album" => album_object.as_ref().and_then(|album| match album { + super::album::AlbumInfo::Full(album) => Some(ArtistInfo { + id: album.artist_object.id.clone(), + name: album.artist_object.name.clone(), + icon_url: album.artist_object.icon_url.clone(), + followers: album.artist_object.followers, + description: album.artist_object.description.clone(), + }), + _ => None, + }), + "artist" => artist_object.as_ref().map(|artist| ArtistInfo { id: artist.id.clone(), name: artist.name.clone(), icon_url: artist.icon_url.clone(), followers: artist.followers, description: artist.description.clone(), - }) - } else { - None + }), + _ => None, }, - album_object: if item_type == "song" { - song_object.as_ref().map(|song| AlbumInfo { - id: song.album_object.id.clone(), - name: song.album_object.name.clone(), - cover_url: song.album_object.cover_url.clone(), - first_release_date: song.album_object.first_release_date.clone(), - description: song.album_object.description.clone(), - }) - } else { - album_object.as_ref().map(|album| AlbumInfo { - id: album.id.clone(), - name: album.name.clone(), - cover_url: album.cover_url.clone(), - first_release_date: album.first_release_date.clone(), - description: album.description.clone(), - }) + album_object: match item_type.as_str() { + "song" => song_object.as_ref().and_then(|song| match song { + super::song::SongInfo::Full(song) => Some(AlbumInfo { + id: song.album_object.id.clone(), + name: song.album_object.name.clone(), + cover_url: song.album_object.cover_url.clone(), + first_release_date: song.album_object.first_release_date.clone(), + description: song.album_object.description.clone(), + }), + _ => None, + }), + _ => album_object.as_ref().and_then(|album| match album { + super::album::AlbumInfo::Full(album) => Some(AlbumInfo { + id: album.id.clone(), + name: album.name.clone(), + cover_url: album.cover_url.clone(), + first_release_date: album.first_release_date.clone(), + description: album.description.clone(), + }), + _ => None, + }), }, - song_object: song_object.as_ref().map(|song| SongInfo { - id: song.id.clone(), - name: song.name.clone(), - duration: song.duration, + song_object: song_object.as_ref().and_then(|song| match song { + super::song::SongInfo::Full(song) => Some(SongInfo { + id: song.id.clone(), + name: song.name.clone(), + duration: song.duration, + }), + _ => None, }), } }) diff --git a/crates/backend/src/routes/song.rs b/crates/backend/src/routes/song.rs index 89457782..fa7fcf8b 100644 --- a/crates/backend/src/routes/song.rs +++ b/crates/backend/src/routes/song.rs @@ -1,12 +1,15 @@ use std::collections::HashSet; +use std::sync::Arc; -use actix_web::{get, web, HttpResponse}; +use actix_web::{get, post, web, HttpResponse}; use rand::seq::SliceRandom; use serde::{Deserialize, Serialize}; use tracing::error; +use crate::routes::search::populate_search_data; use crate::structures::structures::{Album, Artist, MusicVideo, Song}; -use crate::utils::config::{fetch_library, get_config}; +use crate::utils::config::{fetch_library, get_config, refresh_cache, save_library}; +use crate::utils::hash::{hash_album, hash_artist}; use super::genres::fetch_albums_by_genres; @@ -83,13 +86,24 @@ pub async fn fetch_random_songs(amount: usize, genre: Option) -> Result< Ok(response_songs) } -pub async fn fetch_song_info(song_id: String, include: Option>) -> Result { +#[derive(Serialize)] +pub enum SongInfo { + Full(ResponseSong), + Bare(Song), +} + +pub async fn fetch_song_info(song_id: String, include: Option>, bare: Option) -> Result { let library = fetch_library().await.map_err(|_| ())?; + let bare = bare.unwrap_or(false); for artist in library.iter() { for album in artist.albums.iter() { for song in album.songs.iter() { if song.id == song_id { + if bare { + return Ok(SongInfo::Bare(song.clone())); + } + let include_fields = include.unwrap_or_else(|| { let mut all_fields = HashSet::new(); all_fields.insert("id".to_string()); @@ -105,7 +119,7 @@ pub async fn fetch_song_info(song_id: String, include: Option>) all_fields }); - return Ok(ResponseSong { + return Ok(SongInfo::Full(ResponseSong { id: if include_fields.contains("id") { song.id.clone() } else { String::new() }, name: if include_fields.contains("name") { song.name.clone() } else { String::new() }, artist: if include_fields.contains("artist") { song.artist.clone() } else { String::new() }, @@ -116,7 +130,7 @@ pub async fn fetch_song_info(song_id: String, include: Option>) album_object: if include_fields.contains("album_object") { album.clone() } else { Album::default() }, artist_object: if include_fields.contains("artist_object") { artist.clone() } else { Artist::default() }, music_video: if include_fields.contains("music_video") { song.music_video.clone().unwrap_or_default() } else { MusicVideo::default() }, - }); + })); } } } @@ -138,13 +152,18 @@ async fn get_random_song(amount: web::Path, query: web::Query, +} + #[get("/info/{id}")] -async fn get_song_info(id: web::Path) -> HttpResponse { +async fn get_song_info(id: web::Path, query: web::Query) -> HttpResponse { let id_str = id.into_inner(); - match fetch_song_info(id_str.clone(), None).await { + let bare = query.bare.unwrap_or(false); + match fetch_song_info(id_str.clone(), None, Some(bare)).await { Ok(song) => HttpResponse::Ok().json(song), Err(_) => { - error!("Song with ID {} not found.", id_str); HttpResponse::NotFound().finish() } } @@ -177,11 +196,181 @@ async fn get_songs_with_music_videos() -> HttpResponse { } } +#[post("/edit/{id}")] +async fn edit_song_metadata(form: web::Json) -> HttpResponse { + let mut library = match fetch_library().await { + Ok(lib) => lib, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + + let new_song = form.into_inner(); + let mut song_found = false; + + for artist in Arc::make_mut(&mut library).iter_mut() { + for album in artist.albums.iter_mut() { + for song in album.songs.iter_mut() { + if song.id == new_song.id { + *song = new_song.clone(); + song_found = true; + break; + } + } + } + } + + if song_found { + if save_library(&library).await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + if refresh_cache().await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + HttpResponse::Ok().finish() + } else { + HttpResponse::InternalServerError().finish() + } +} + +#[derive(Deserialize)] +pub struct AddSongForm { + song: Song, + artist_id: Option, + album_id: Option, +} + +#[post("/add")] +pub async fn add_song(form: web::Json) -> HttpResponse { + let new_song = form.song.clone(); + let artist_id = form.artist_id.clone(); + let album_id = form.album_id.clone(); + + let mut library = match fetch_library().await { + Ok(lib) => lib, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + + if let Some(artist_id) = artist_id { + for artist in Arc::make_mut(&mut library).iter_mut() { + if artist.id == artist_id { + if let Some(album_id) = album_id { + for album in artist.albums.iter_mut() { + if album.id == album_id { + album.songs.push(new_song.clone()); + break; + } + } + } else { + let new_album = Album { + id: hash_album(&new_song.name, &artist.name), + name: new_song.name.clone(), + cover_url: String::new(), + songs: vec![new_song.clone()], + first_release_date: String::new(), + musicbrainz_id: String::new(), + wikidata_id: None, + primary_type: String::new(), + description: String::new(), + contributing_artists: vec![], + contributing_artists_ids: vec![], + release_album: None, + release_group_album: None, + }; + artist.albums.push(new_album); + } + break; + } + } + } else { + let artist_name = new_song.artist.clone(); + let album_name = new_song.name.clone(); + + let mut artist_found = false; + for artist in Arc::make_mut(&mut library).iter_mut() { + if artist.name == artist_name { + for album in artist.albums.iter_mut() { + if album.name == album_name { + album.songs.push(new_song.clone()); + artist_found = true; + break; + } + } + if !artist_found { + let new_album = Album { + id: hash_album(&album_name, &artist_name), + name: album_name.clone(), + cover_url: String::new(), + songs: vec![new_song.clone()], + first_release_date: String::new(), + musicbrainz_id: String::new(), + wikidata_id: None, + primary_type: String::new(), + description: String::new(), + contributing_artists: vec![], + contributing_artists_ids: vec![], + release_album: None, + release_group_album: None, + }; + artist.albums.push(new_album); + } + artist_found = true; + break; + } + } + + if !artist_found { + let new_artist = Artist { + id: hash_artist(&artist_name), + name: artist_name.clone(), + albums: vec![Album { + id: hash_album(&album_name, &artist_name), + name: album_name.clone(), + cover_url: String::new(), + songs: vec![new_song.clone()], + first_release_date: String::new(), + musicbrainz_id: String::new(), + wikidata_id: None, + primary_type: String::new(), + description: String::new(), + contributing_artists: vec![], + contributing_artists_ids: vec![], + release_album: None, + release_group_album: None, + }], + featured_on_album_ids: vec![], + icon_url: String::new(), + followers: 0, + description: String::new(), + tadb_music_videos: None, + }; + Arc::make_mut(&mut library).push(new_artist); + } + } + + if save_library(&library).await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + + if refresh_cache().await.is_err() { + return HttpResponse::InternalServerError().finish(); + } + + match populate_search_data().await { + Ok(_) => {}, + Err(e) => { + error!("Failed to populate search data: {:?}", e); + } + } + + HttpResponse::Ok().finish() +} + pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/song") .service(get_song_info) .service(get_random_song) .service(get_songs_with_music_videos) + .service(edit_song_metadata) + .service(add_song) ); } \ No newline at end of file diff --git a/crates/backend/src/utils/config.rs b/crates/backend/src/utils/config.rs index b5afeebf..8501ff01 100644 --- a/crates/backend/src/utils/config.rs +++ b/crates/backend/src/utils/config.rs @@ -7,7 +7,7 @@ use actix_web::{get, HttpResponse, Responder}; use dotenvy::dotenv; use rand::distributions::Alphanumeric; use rand::Rng; -use serde_json::{from_str, json, Value}; +use serde_json::{from_str, json, to_string, Value}; use tokio::fs::File; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use lazy_static::lazy_static; @@ -71,7 +71,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { } lazy_static! { - static ref LIBRARY_CACHE: RwLock>>> = RwLock::new(None); + pub static ref LIBRARY_CACHE: RwLock>>> = RwLock::new(None); } pub async fn get_config() -> Result> { @@ -97,6 +97,21 @@ pub async fn fetch_library() -> Result>, Box> { Ok(library) } +pub async fn save_library(library: &Arc>) -> Result<(), Box> { + let config = to_string(&**library)?; + save_config(&config, false).await?; + refresh_cache().await?; + Ok(()) +} + +pub async fn refresh_cache() -> Result<(), Box> { + let mut cache = LIBRARY_CACHE.write().await; + *cache = None; + drop(cache); + fetch_library().await?; + Ok(()) +} + pub fn get_config_path() -> PathBuf { let path = if is_docker() { Path::new("/ParsonLabsMusic/Config/music.json").to_path_buf() @@ -130,28 +145,30 @@ async fn has_config() -> impl Responder { } } -pub async fn save_config(indexed_json: &String) -> std::io::Result<()> { - let config_path = get_config_path(); - let config_dir = config_path.parent().unwrap(); - let config_filename = config_path.file_stem().unwrap().to_str().unwrap(); - let config_extension = config_path.extension().unwrap().to_str().unwrap(); +pub async fn save_config(indexed_json: &String, create_backup: bool) -> std::io::Result<()> { + let config_path = get_config_path(); + let config_dir = config_path.parent().unwrap(); + let config_filename = config_path.file_stem().unwrap().to_str().unwrap(); + let config_extension = config_path.extension().unwrap().to_str().unwrap(); - let mut backup_number = 1; - let mut backup_path = config_dir.join(format!("{}_{} (Backup).{}", config_filename, backup_number, config_extension)); + if create_backup { + let mut backup_number = 1; + let mut backup_path = config_dir.join(format!("{}_{} (Backup).{}", config_filename, backup_number, config_extension)); - while backup_path.exists() { - backup_number += 1; - backup_path = config_dir.join(format!("{}_{} (Backup).{}", config_filename, backup_number, config_extension)); - } + while backup_path.exists() { + backup_number += 1; + backup_path = config_dir.join(format!("{}_{} (Backup).{}", config_filename, backup_number, config_extension)); + } - if config_path.exists() { - fs::rename(&config_path, &backup_path)?; - } + if config_path.exists() { + fs::rename(&config_path, &backup_path)?; + } + } - let mut file = File::create(&config_path).await?; - file.write_all(indexed_json.as_bytes()).await?; + let mut file = File::create(&config_path).await?; + file.write_all(indexed_json.as_bytes()).await?; - Ok(()) + Ok(()) } pub fn get_icon_art_path() -> PathBuf { diff --git a/packages/music-sdk/src/lib/album.ts b/packages/music-sdk/src/lib/album.ts index ce39bdea..33a9c540 100644 --- a/packages/music-sdk/src/lib/album.ts +++ b/packages/music-sdk/src/lib/album.ts @@ -2,7 +2,7 @@ import { AxiosResponse } from 'axios'; import axios from './axios'; import { Album, Artist } from './types'; -export type LibraryAlbum = Album & { artist_object: Artist } +export type LibraryAlbum = Album & { artist_object: Artist }; /** * Get a random album. @@ -10,16 +10,45 @@ export type LibraryAlbum = Album & { artist_object: Artist } * @returns {Promise} - A promise that resolves to an array of random albums. */ export async function getRandomAlbum(amount: number): Promise { - const response: AxiosResponse = await axios.get(`/album/random/${amount}`) + const response: AxiosResponse = await axios.get(`/album/random/${amount}`); return response.data; } /** * Get album information by ID. * @param {string} id - The ID of the album. - * @returns {Promise} - A promise that resolves to the album information. + * @param {boolean} [bare=false] - Whether to fetch bare album information. + * @returns {Promise} - A promise that resolves to the album information. */ -export async function getAlbumInfo(id: string): Promise { - const response: AxiosResponse = await axios.get(`/album/info/${id}`); - return response.data; +export async function getAlbumInfo(id: string, bare: boolean = false): Promise { + const response: AxiosResponse<{ Full?: LibraryAlbum; Bare?: Album }> = await axios.get(`/album/info/${id}`, { + params: { bare } + }); + + if (response.data.Full) { + return response.data.Full; + } else if (response.data.Bare) { + return response.data.Bare; + } else { + throw new Error('Unexpected response format'); + } +} + +/** + * Add a new album. + * @param {Album} album - The album to add. + * @param {string} [artist_id] - The optional ID of the artist to add the album to. + * @returns {Promise} - A promise that resolves when the album is added. + */ +export async function addAlbum(album: Album, artist_id?: string): Promise { + await axios.post(`/album/add`, { album, artist_id }); +} + +/** + * Delete an album by ID. + * @param {string} album_id - The ID of the album to delete. + * @returns {Promise} - A promise that resolves when the album is deleted. + */ +export async function deleteAlbum(album_id: string): Promise { + await axios.delete(`/album/delete`, { data: { album_id } }); } \ No newline at end of file diff --git a/packages/music-sdk/src/lib/artist.ts b/packages/music-sdk/src/lib/artist.ts index 23840cf9..858ba130 100644 --- a/packages/music-sdk/src/lib/artist.ts +++ b/packages/music-sdk/src/lib/artist.ts @@ -5,7 +5,7 @@ import { Artist } from './types'; /** * Get a random artist. * @param {number} amount - The number of random artists to retrieve. - * @returns {Promise} - A promise that resolves to an array of random artists. + * @returns {Promise} - A promise that resolves to an array of random artists. */ export async function getRandomArtist(amount: number): Promise { const response: AxiosResponse = await axios.get(`/artist/random/${amount}`); @@ -14,10 +14,28 @@ export async function getRandomArtist(amount: number): Promise { /** * Get artist information by ID. - * @param {number} id - The ID of the artist. - * @returns {Promise} - A promise that resolves to the artist information. + * @param {string} id - The ID of the artist. + * @returns {Promise} - A promise that resolves to the artist information. */ export async function getArtistInfo(id: string): Promise { const response: AxiosResponse = await axios.get(`/artist/info/${id}`); return response.data; +} + +/** + * Add a new artist. + * @param {Artist} artist - The artist to add. + * @returns {Promise} - A promise that resolves when the artist is added. + */ +export async function addArtist(artist: Artist): Promise { + await axios.post(`/artist/add`, { artist }); +} + +/** + * Delete an artist by ID. + * @param {string} id - The ID of the artist to delete. + * @returns {Promise} - A promise that resolves when the artist is deleted. + */ +export async function deleteArtist(id: string): Promise { + await axios.delete(`/artist/delete`, { data: { artist_id: id } }); } \ No newline at end of file diff --git a/packages/music-sdk/src/lib/song.ts b/packages/music-sdk/src/lib/song.ts index fbfaf5cf..3f4ab616 100644 --- a/packages/music-sdk/src/lib/song.ts +++ b/packages/music-sdk/src/lib/song.ts @@ -1,6 +1,10 @@ import { AxiosResponse } from 'axios'; import axios from './axios'; -import { LibrarySong, MusicVideoSong } from './types'; +import { LibrarySong, BareSong, MusicVideoSong } from './types'; + +export type SongInfo = + | { Full: LibrarySong } + | { Bare: BareSong }; /** * Get a random song. @@ -17,11 +21,21 @@ export async function getRandomSong(amount: number, genre?: string): Promise} - A promise that resolves to the song information. + * @param {boolean} [bare=false] - Whether to fetch bare song information. + * @returns {Promise} - A promise that resolves to the song information. */ -export async function getSongInfo(id: string): Promise { - const response: AxiosResponse = await axios.get(`/song/info/${id}`); - return response.data; +export async function getSongInfo(id: string, bare: boolean = false): Promise { + const response: AxiosResponse<{ Full?: LibrarySong; Bare?: BareSong }> = await axios.get(`/song/info/${id}`, { + params: { bare } + }); + + if (response.data.Full) { + return response.data.Full; + } else if (response.data.Bare) { + return response.data.Bare; + } else { + throw new Error('Unexpected response format'); + } } /** @@ -31,4 +45,33 @@ export async function getSongInfo(id: string): Promise { export async function getSongsWithMusicVideos(): Promise { const response: AxiosResponse = await axios.get(`/song/music_videos`); return response.data; +} + +/** + * Add a new song. + * @param {LibrarySong} song - The song to add. + * @param {string} [artist_id] - The optional ID of the artist to add the song to. + * @param {string} [album_id] - The optional ID of the album to add the song to. + * @returns {Promise} - A promise that resolves when the song is added. + */ +export async function addSong(song: LibrarySong, artist_id?: string, album_id?: string): Promise { + await axios.post(`/song/add`, { song, artist_id, album_id }); +} + +/** + * Delete a song by ID. + * @param {string} song_id - The ID of the song to delete. + * @returns {Promise} - A promise that resolves when the song is deleted. + */ +export async function deleteSong(song_id: string): Promise { + await axios.delete(`/song/delete`, { data: { song_id } }); +} + +/** + * Edit song metadata. + * @param {LibrarySong | BareSong} song - The song with updated metadata. + * @returns {Promise} - A promise that resolves when the song metadata is updated. + */ +export async function editSongMetadata(song: LibrarySong | BareSong): Promise { + await axios.post(`/song/edit/${song.id}`, song); } \ No newline at end of file diff --git a/packages/music-sdk/src/lib/types.ts b/packages/music-sdk/src/lib/types.ts index 71defd20..439be0d6 100644 --- a/packages/music-sdk/src/lib/types.ts +++ b/packages/music-sdk/src/lib/types.ts @@ -135,6 +135,18 @@ export interface LibrarySong { music_video?: MusicVideo; } +export interface BareSong { + id: string; + name: string; + artist: string; + contributing_artists: string[]; + contributing_artist_ids: string[]; + track_number: number; + path: string; + duration: number; + music_video?: MusicVideo; +} + export interface MusicVideo { url: string; thumbnail_url?: string; diff --git a/packages/music-sdk/src/lib/web.ts b/packages/music-sdk/src/lib/web.ts index 3b45224e..f25cd515 100644 --- a/packages/music-sdk/src/lib/web.ts +++ b/packages/music-sdk/src/lib/web.ts @@ -1,6 +1,6 @@ import axios from './axios'; -export interface SongInfo { +export interface ListenAgainSong { song_name: string; song_id: string; song_path: string; @@ -29,7 +29,7 @@ export interface AlbumCardProps { * @param {number} userId - The user ID. * @returns {Promise} - A promise that resolves to the list of songs or albums. */ -export async function getListenAgain(userId: number): Promise { +export async function getListenAgain(userId: number): Promise { const response = await axios.get(`/web/listen_again/${userId}`); return response.data; } diff --git a/packages/ui/package.json b/packages/ui/package.json index 3f351c57..4863364a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -40,6 +40,7 @@ "@radix-ui/react-collapsible": "1.1.0", "@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-accordion": "1.2.0", + "@radix-ui/react-aspect-ratio": "1.1.0", "@dnd-kit/core": "6.1.0", "@dnd-kit/modifiers": "7.0.0", "@dnd-kit/sortable": "8.0.0", diff --git a/packages/ui/src/components/aspect-ratio.tsx b/packages/ui/src/components/aspect-ratio.tsx new file mode 100644 index 00000000..d6a5226f --- /dev/null +++ b/packages/ui/src/components/aspect-ratio.tsx @@ -0,0 +1,7 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/packages/ui/src/components/table.tsx b/packages/ui/src/components/table.tsx index 3485b020..236133e5 100644 --- a/packages/ui/src/components/table.tsx +++ b/packages/ui/src/components/table.tsx @@ -58,12 +58,13 @@ const TableRow = React.forwardRef< )) + TableRow.displayName = "TableRow" const TableHead = React.forwardRef<