├── .eslintignore ├── .prettierrc ├── pnpm-workspace.yaml ├── .prettierignore ├── apps ├── web │ ├── public │ │ ├── favicon.ico │ │ ├── icon-192.png │ │ ├── icon-512.png │ │ ├── apple-touch-icon.png │ │ ├── _headers │ │ └── icon.svg │ ├── src │ │ ├── assets │ │ │ ├── media │ │ │ │ ├── now_playing.gif │ │ │ │ ├── fallback-track.svg │ │ │ │ ├── fallback-profile.svg │ │ │ │ ├── fallback-artist.svg │ │ │ │ └── bg.svg │ │ │ └── styles │ │ │ │ └── index.css │ │ ├── services │ │ │ ├── emitter.js │ │ │ └── notify.js │ │ ├── components │ │ │ ├── skeletons │ │ │ │ ├── CardSkeleton.vue │ │ │ │ ├── FieldSkeleton.vue │ │ │ │ ├── TrackRowSkeleton.vue │ │ │ │ ├── SpotifyCardSkeleton.vue │ │ │ │ ├── LeaderboardItemSkeleton.vue │ │ │ │ ├── ProfileSkeleton.vue │ │ │ │ ├── LibraryItemSkeleton.vue │ │ │ │ └── InfopageSkeleton.vue │ │ │ ├── layout │ │ │ │ ├── Container │ │ │ │ │ ├── ContainerItem.vue │ │ │ │ │ ├── Container.vue │ │ │ │ │ └── ContainerItemLabel.vue │ │ │ │ ├── FollowersGrid.vue │ │ │ │ ├── InfopageHeader.vue │ │ │ │ ├── Items │ │ │ │ │ ├── CompatibilityItem.vue │ │ │ │ │ ├── LibraryItem.vue │ │ │ │ │ └── FriendItem.vue │ │ │ │ ├── ProfileBadge.vue │ │ │ │ └── App │ │ │ │ │ └── AppFriends.vue │ │ │ ├── base │ │ │ │ ├── Icon.vue │ │ │ │ ├── TrackRows │ │ │ │ │ ├── TrackRows.vue │ │ │ │ │ └── TrackRow.vue │ │ │ │ ├── Badge.vue │ │ │ │ ├── LoadingSpinner.vue │ │ │ │ ├── HorizontalScroll.vue │ │ │ │ ├── Blankslate.vue │ │ │ │ ├── BaseLink.vue │ │ │ │ ├── Cards │ │ │ │ │ ├── Cards.vue │ │ │ │ │ └── Card.vue │ │ │ │ ├── ArtistsNames.vue │ │ │ │ ├── AsyncWrapper.vue │ │ │ │ ├── Spotify │ │ │ │ │ ├── SpotifyLink.vue │ │ │ │ │ └── SpotifyCard.vue │ │ │ │ ├── Skeleton.vue │ │ │ │ ├── ErrorScreen.vue │ │ │ │ ├── AudioFeatures │ │ │ │ │ ├── AudioFeature.vue │ │ │ │ │ └── AudioFeatures.vue │ │ │ │ ├── Tabs │ │ │ │ │ ├── Tabs.vue │ │ │ │ │ └── Tab.vue │ │ │ │ ├── BaseInput.vue │ │ │ │ ├── BaseSelect.vue │ │ │ │ ├── Notifications │ │ │ │ │ └── Notifications.vue │ │ │ │ ├── BaseImg.vue │ │ │ │ ├── Modal.vue │ │ │ │ ├── AppBar.vue │ │ │ │ └── BaseButton.vue │ │ │ └── preloaders │ │ │ │ ├── TrackRecommendations.vue │ │ │ │ ├── HomeStats.vue │ │ │ │ ├── ArtistRelated.vue │ │ │ │ ├── AlbumContent.vue │ │ │ │ └── HomeFeed.vue │ │ ├── composable │ │ │ ├── useContentWindow.js │ │ │ ├── useBreakpoints.js │ │ │ ├── useQuery.js │ │ │ ├── useNavigator.js │ │ │ ├── useAsync.js │ │ │ ├── useFollow.js │ │ │ ├── useAuth.js │ │ │ └── usePagination.js │ │ ├── main.js │ │ ├── dayjs │ │ │ └── index.js │ │ ├── views │ │ │ ├── Index.vue │ │ │ ├── Profile │ │ │ │ ├── Following.vue │ │ │ │ ├── Followers.vue │ │ │ │ ├── Reports.vue │ │ │ │ ├── History.vue │ │ │ │ ├── Compatibility.vue │ │ │ │ └── Library.vue │ │ │ ├── Search.vue │ │ │ └── Album.vue │ │ ├── config │ │ │ └── index.js │ │ ├── stores │ │ │ ├── friends.js │ │ │ ├── user.js │ │ │ └── profile.js │ │ ├── utils │ │ │ ├── chart.js │ │ │ └── index.js │ │ ├── App.vue │ │ └── router │ │ │ └── index.js │ ├── index.html │ ├── package.json │ ├── windi.config.js │ └── vite.config.js └── api │ ├── src │ ├── routes │ │ ├── follow │ │ │ ├── autohooks.js │ │ │ ├── delete.js │ │ │ └── post.js │ │ ├── settings │ │ │ ├── autohooks.js │ │ │ ├── get.js │ │ │ └── post.js │ │ ├── infopage │ │ │ ├── autohooks.js │ │ │ ├── artist │ │ │ │ └── _id │ │ │ │ │ ├── related-artists │ │ │ │ │ └── index.js │ │ │ │ │ └── albums │ │ │ │ │ └── index.js │ │ │ ├── album │ │ │ │ └── _id │ │ │ │ │ ├── artists │ │ │ │ │ └── index.js │ │ │ │ │ └── content │ │ │ │ │ └── index.js │ │ │ └── track │ │ │ │ └── _id │ │ │ │ └── more-tracks │ │ │ │ └── index.js │ │ ├── users │ │ │ ├── _username │ │ │ │ ├── autohooks.js │ │ │ │ ├── reports │ │ │ │ │ ├── index.js │ │ │ │ │ ├── genres-timeline │ │ │ │ │ │ └── index.js │ │ │ │ │ └── hourly-activity │ │ │ │ │ │ └── index.js │ │ │ │ ├── library │ │ │ │ │ ├── albums │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── artists │ │ │ │ │ │ └── index.js │ │ │ │ │ └── tracks │ │ │ │ │ │ └── index.js │ │ │ │ ├── player │ │ │ │ │ └── currently-playing │ │ │ │ │ │ └── index.js │ │ │ │ ├── listening-history │ │ │ │ │ └── index.js │ │ │ │ ├── followers │ │ │ │ │ └── index.js │ │ │ │ └── follows │ │ │ │ │ └── index.js │ │ │ ├── me │ │ │ │ ├── delete.js │ │ │ │ └── get.js │ │ │ ├── index.js │ │ │ └── top │ │ │ │ └── index.js │ │ ├── health │ │ │ ├── index.js │ │ │ └── memory │ │ │ │ └── index.js │ │ ├── auth │ │ │ ├── check │ │ │ │ └── index.js │ │ │ └── login │ │ │ │ └── index.js │ │ ├── lyrics │ │ │ └── index.js │ │ ├── info │ │ │ └── stats │ │ │ │ └── index.js │ │ └── search │ │ │ └── index.js │ ├── includes │ │ ├── cron-workers │ │ │ ├── historyParser │ │ │ │ ├── index.js │ │ │ │ ├── albums.js │ │ │ │ ├── artists.js │ │ │ │ └── tracks.js │ │ │ ├── avatars.js │ │ │ ├── genres.js │ │ │ ├── tokens.js │ │ │ └── recentlyPlayed.js │ │ ├── getRandomToken.js │ │ ├── parseAudioFeatures.js │ │ ├── forAllUsers.js │ │ └── api.js │ ├── plugins │ │ ├── decorators │ │ │ ├── spotifyAPI.js │ │ │ ├── CustomError.js │ │ │ └── db.js │ │ ├── functions │ │ │ ├── randomToken.js │ │ │ ├── audioFeatures.js │ │ │ ├── getCountry.js │ │ │ ├── user │ │ │ │ └── top │ │ │ │ │ ├── artists.js │ │ │ │ │ ├── albums.js │ │ │ │ │ └── tracks.js │ │ │ └── favouriteTracks.js │ │ ├── handlers │ │ │ ├── notFoundHandler.js │ │ │ └── errorHandler.js │ │ ├── auth │ │ │ └── session.js │ │ └── hooks │ │ │ └── userInfo.js │ ├── schema │ │ ├── rates.js │ │ ├── overview.js │ │ ├── album.js │ │ ├── entity.js │ │ ├── user.js │ │ ├── audioFeatures.js │ │ ├── track.js │ │ └── top.js │ ├── models │ │ ├── Artist.js │ │ ├── Album.js │ │ ├── Track.js │ │ └── User.js │ ├── index.js │ ├── utils │ │ └── index.js │ └── app.js │ └── package.json ├── .env.example ├── netlify.toml ├── .gitignore ├── README.md ├── .eslintrc.js ├── package.json └── LICENSE /.eslintignore: -------------------------------------------------------------------------------- 1 | *.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false 3 | } 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - apps/* 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.lock 2 | node_modules 3 | pnpm-lock.yaml 4 | pnpm-workspace.yaml 5 | dist 6 | build -------------------------------------------------------------------------------- /apps/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anyrange/measurify/HEAD/apps/web/public/favicon.ico -------------------------------------------------------------------------------- /apps/web/public/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anyrange/measurify/HEAD/apps/web/public/icon-192.png -------------------------------------------------------------------------------- /apps/web/public/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anyrange/measurify/HEAD/apps/web/public/icon-512.png -------------------------------------------------------------------------------- /apps/web/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anyrange/measurify/HEAD/apps/web/public/apple-touch-icon.png -------------------------------------------------------------------------------- /apps/web/src/assets/media/now_playing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anyrange/measurify/HEAD/apps/web/src/assets/media/now_playing.gif -------------------------------------------------------------------------------- /apps/api/src/routes/follow/autohooks.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.addHook("preValidation", fastify.auth) 3 | } 4 | -------------------------------------------------------------------------------- /apps/api/src/routes/settings/autohooks.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.addHook("preValidation", fastify.auth) 3 | } 4 | -------------------------------------------------------------------------------- /apps/web/src/services/emitter.js: -------------------------------------------------------------------------------- 1 | import { createNanoEvents } from "nanoevents" 2 | const emitter = createNanoEvents() 3 | export default emitter 4 | -------------------------------------------------------------------------------- /apps/web/src/components/skeletons/CardSkeleton.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /apps/web/src/components/layout/Container/ContainerItem.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /apps/web/src/components/base/Icon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /apps/web/src/components/base/TrackRows/TrackRows.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /apps/web/src/components/layout/Container/Container.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /apps/api/src/includes/cron-workers/historyParser/index.js: -------------------------------------------------------------------------------- 1 | import addArtists from "./artists.js" 2 | import addAlbums from "./albums.js" 3 | import addTracks from "./tracks.js" 4 | 5 | export { addArtists, addAlbums, addTracks } 6 | -------------------------------------------------------------------------------- /apps/web/src/components/skeletons/FieldSkeleton.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /apps/api/src/plugins/decorators/spotifyAPI.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | import api from "#src/includes/api.js" 3 | 4 | const plugin = fp(async (fastify) => fastify.decorate("spotifyAPI", api)) 5 | 6 | export default plugin 7 | -------------------------------------------------------------------------------- /apps/web/src/components/base/Badge.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /apps/web/src/components/base/LoadingSpinner.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /apps/api/src/routes/infopage/autohooks.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.addHook("onSend", async (request, reply) => { 3 | if (reply.statusCode === 200) 4 | reply.header("Cache-Control", "public, max-age=30") 5 | }) 6 | } 7 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/_username/autohooks.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.addHook("preSerialization", async (req, reply) => { 3 | if (reply.statusCode === 200) 4 | reply.header("Cache-Control", "public, max-age=5") 5 | }) 6 | } 7 | -------------------------------------------------------------------------------- /apps/api/src/plugins/functions/randomToken.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | import getRandomToken from "#src/includes/getRandomToken.js" 3 | 4 | const plugin = fp(async (fastify) => 5 | fastify.decorate("getRandomToken", getRandomToken) 6 | ) 7 | 8 | export default plugin 9 | -------------------------------------------------------------------------------- /apps/api/src/plugins/decorators/CustomError.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | 3 | const plugin = fp(async function plugin(fastify) { 4 | fastify.decorate("error", (message, code) => 5 | Object.assign(new Error(message), { code }) 6 | ) 7 | }) 8 | 9 | export default plugin 10 | -------------------------------------------------------------------------------- /apps/api/src/plugins/functions/audioFeatures.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | import parseAudioFeatures from "#src/includes/parseAudioFeatures.js" 3 | 4 | const plugin = fp(async (fastify) => 5 | fastify.decorate("parseAudioFeatures", parseAudioFeatures) 6 | ) 7 | 8 | export default plugin 9 | -------------------------------------------------------------------------------- /apps/api/src/includes/getRandomToken.js: -------------------------------------------------------------------------------- 1 | import User from "#src/models/User.js" 2 | 3 | export default async function getRandomToken() { 4 | const user = await User.findOne( 5 | { "tokens.refreshToken": { $ne: "" } }, 6 | "tokens.token" 7 | ).lean() 8 | return user.tokens.token 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/src/components/layout/Container/ContainerItemLabel.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /apps/web/src/components/base/HorizontalScroll.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /apps/web/src/components/layout/FollowersGrid.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DB_URI="mongodb+srv://:@cluster0.lygbu.mongodb.net/spotify-worm" 2 | 3 | SPOTIFY_CLIENT_ID= 4 | SPOTIFY_CLIENT_SECRET= 5 | 6 | VITE_SERVER_URI="http://localhost:8888" 7 | CLIENT_URI="http://localhost:3000" 8 | 9 | SERVICE_ID= 10 | SECRET_COOKIE="64symbols" 11 | 12 | GENIUS_API_SECRET_KEY= -------------------------------------------------------------------------------- /apps/web/src/components/base/Blankslate.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /apps/api/src/plugins/handlers/notFoundHandler.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | 3 | const plugin = fp(async function plugin(fastify) { 4 | fastify.setNotFoundHandler((req, reply) => { 5 | return reply.code(404).send({ message: "Service not found", status: 404 }) 6 | }) 7 | }) 8 | 9 | export default plugin 10 | -------------------------------------------------------------------------------- /apps/web/public/_headers: -------------------------------------------------------------------------------- 1 | / 2 | Cache-Control: public, max-age=0, s-maxage=0, must-revalidate 3 | 4 | /assets/* 5 | Cache-Control: public, max-age=31536000, immutable 6 | 7 | /workbox-* 8 | Cache-Control: public, max-age=31536000, immutable 9 | 10 | /manifest.webmanifest 11 | Content-Type: application/manifest+json 12 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build.environment] 2 | NPM_FLAGS = "--version" # prevents Netlify npm install 3 | NODE_VERSION = "16" 4 | 5 | [build] 6 | publish = "apps/web/dist" 7 | command = "npx pnpm install --store=node_modules/.pnpm-store && npx pnpm build" 8 | 9 | [[redirects]] 10 | from = "/*" 11 | to = "/" 12 | status = 200 -------------------------------------------------------------------------------- /apps/web/src/composable/useContentWindow.js: -------------------------------------------------------------------------------- 1 | import { inject } from "vue" 2 | 3 | export const useContentWindow = () => { 4 | const contentWindow = inject("contentWindow") 5 | 6 | const scrollToTop = () => { 7 | contentWindow.value.scroll({ top: 0, left: 0 }) 8 | } 9 | 10 | return { 11 | scrollToTop, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | *.lock 4 | node_modules 5 | dist 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | .env 11 | 12 | # Log files 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | pnpm-debug.log* 17 | 18 | # Editor directories and files 19 | .idea 20 | .vscode 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? -------------------------------------------------------------------------------- /apps/api/src/plugins/functions/getCountry.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | 3 | const plugin = fp(async (fastify) => 4 | fastify.decorate("getCountry", async (_id) => { 5 | const user = _id 6 | ? await fastify.db.User.findOne({ _id }, "country").lean() 7 | : { country: "US" } 8 | 9 | return user.country 10 | }) 11 | ) 12 | 13 | export default plugin 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # measurify 2 | 3 | Yet another app using Spotify Web API 4 | 5 | [![Uptime Robot](https://img.shields.io/uptimerobot/status/m787497444-7b36a8b8a8545c2335febb2b?label=server)](https://stats.uptimerobot.com/kXD0runRnw/787497444) 6 | 7 | ## Contributing 8 | 9 | Contributions are welcome. Just submit a pull request or open an issue. 10 | 11 | ## License 12 | 13 | [MIT](/LICENSE) 14 | -------------------------------------------------------------------------------- /apps/web/src/components/base/BaseLink.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | -------------------------------------------------------------------------------- /apps/api/src/schema/rates.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | 3 | const plugin = fp(async function plugin(fastify) { 4 | fastify.addSchema({ 5 | $id: "rates", 6 | title: "rates", 7 | type: "object", 8 | properties: { 9 | LT: { type: "number" }, 10 | MT: { type: "number" }, 11 | ST: { type: "number" }, 12 | }, 13 | }) 14 | }) 15 | 16 | export default plugin 17 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | "vue/setup-compiler-macros": true, 6 | }, 7 | extends: [ 8 | "plugin:vue/vue3-strongly-recommended", 9 | "eslint:recommended", 10 | "@vue/eslint-config-prettier", 11 | ], 12 | rules: { 13 | "vue/multi-word-component-names": "off", 14 | }, 15 | parserOptions: { 16 | ecmaVersion: 2022, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /apps/api/src/schema/overview.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | 3 | const plugin = fp(async function plugin(fastify) { 4 | fastify.addSchema({ 5 | $id: "overview", 6 | title: "overview", 7 | type: "object", 8 | required: ["plays", "playtime"], 9 | properties: { 10 | plays: { type: "number" }, 11 | playtime: { type: "number" }, 12 | }, 13 | }) 14 | }) 15 | 16 | export default plugin 17 | -------------------------------------------------------------------------------- /apps/web/src/composable/useBreakpoints.js: -------------------------------------------------------------------------------- 1 | import { useBreakpoints as useVueUseBreakpoints } from "@vueuse/core" 2 | import { BREAKPOINTS } from "@/config" 3 | 4 | export function useBreakpoints() { 5 | const breakpoints = useVueUseBreakpoints(BREAKPOINTS) 6 | 7 | const xlAndLarger = breakpoints.greater("xl") 8 | const smallerThanMd = breakpoints.smaller("md") 9 | 10 | return { breakpoints, xlAndLarger, smallerThanMd } 11 | } 12 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/me/delete.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.delete( 3 | "", 4 | { 5 | schema: { tags: ["users"] }, 6 | preValidation: [fastify.auth], 7 | }, 8 | async function (req, reply) { 9 | const id = req.user.id 10 | 11 | await fastify.db.User.deleteOne({ _id: id }) 12 | 13 | req.session.delete() 14 | return reply.code(204).send() 15 | } 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/src/composable/useQuery.js: -------------------------------------------------------------------------------- 1 | export function useQuery() { 2 | const querystring = window.location.href.split("?")[1] 3 | const query = querystring 4 | ? querystring 5 | .split("&") 6 | .map((item) => item.split("=")) 7 | .reduce((acc, [key, value]) => { 8 | acc[key] = value 9 | return acc 10 | }, {}) 11 | : {} 12 | 13 | return { 14 | querystring, 15 | query, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/api/src/routes/health/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get( 3 | "", 4 | { 5 | schema: { 6 | response: { 7 | 200: { 8 | type: "object", 9 | properties: { 10 | message: { type: "string" }, 11 | }, 12 | }, 13 | }, 14 | tags: ["server"], 15 | }, 16 | }, 17 | () => ({ message: "I'm alive" }) 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/src/composable/useNavigator.js: -------------------------------------------------------------------------------- 1 | import { computed } from "vue" 2 | 3 | export const useNavigator = () => { 4 | const isMobile = computed(() => 5 | /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( 6 | navigator.userAgent 7 | ) 8 | ) 9 | const isSafari = computed(() => 10 | /^((?!chrome|android).)*safari/i.test(navigator.userAgent) 11 | ) 12 | return { 13 | isMobile, 14 | isSafari, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/components/preloaders/TrackRecommendations.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | -------------------------------------------------------------------------------- /apps/web/src/components/base/Cards/Cards.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 29 | -------------------------------------------------------------------------------- /apps/web/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue" 2 | import { createPinia } from "pinia" 3 | import App from "./App.vue" 4 | import router from "./router" 5 | import VWave from "v-wave" 6 | import "virtual:windi.css" 7 | import "./assets/styles/index.css" 8 | import { registerSW } from "virtual:pwa-register" 9 | 10 | registerSW({ immediate: true }) 11 | 12 | const app = createApp(App) 13 | const pinia = createPinia() 14 | 15 | app.use(router).use(VWave).use(pinia).mount("#app") 16 | -------------------------------------------------------------------------------- /apps/web/src/components/base/ArtistsNames.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 21 | -------------------------------------------------------------------------------- /apps/web/src/components/preloaders/HomeStats.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | -------------------------------------------------------------------------------- /apps/api/src/includes/cron-workers/avatars.js: -------------------------------------------------------------------------------- 1 | import User from "#src/models/User.js" 2 | import api from "#src/includes/api.js" 3 | import forAllUsers from "#src/includes/forAllUsers.js" 4 | 5 | export async function refreshAvatars() { 6 | await forAllUsers({ operation: "avatars" }, refreshAvatar) 7 | } 8 | 9 | async function refreshAvatar({ tokens: { token }, display_name }) { 10 | const user = await api({ route: `me`, token }) 11 | const avatar = user.images.length ? user.images[0].url : "" 12 | await User.updateOne({ display_name }, { avatar }) 13 | } 14 | -------------------------------------------------------------------------------- /apps/api/src/schema/album.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | import { entity, entities } from "./entity.js" 3 | 4 | const album = { 5 | type: "object", 6 | properties: { 7 | ...entity.properties, 8 | artists: entities, 9 | }, 10 | } 11 | 12 | const albums = { type: "array", items: album } 13 | 14 | const plugin = fp(async (fastify) => { 15 | fastify.addSchema({ $id: "album", title: "album", ...album }) 16 | fastify.addSchema({ $id: "albums", title: "albums", ...albums }) 17 | }) 18 | 19 | export default plugin 20 | export { album, albums } 21 | -------------------------------------------------------------------------------- /apps/api/src/schema/entity.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | 3 | const entity = { 4 | type: "object", 5 | properties: { 6 | name: { type: "string" }, 7 | image: { type: "string" }, 8 | id: { type: "string" }, 9 | }, 10 | } 11 | 12 | const entities = { type: "array", items: entity } 13 | 14 | const plugin = fp(async (fastify) => { 15 | fastify.addSchema({ $id: "entity", title: "entity", ...entity }) 16 | fastify.addSchema({ $id: "entities", title: "entities", ...entities }) 17 | }) 18 | 19 | export default plugin 20 | export { entity, entities } 21 | -------------------------------------------------------------------------------- /apps/api/src/routes/auth/check/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get( 3 | "", 4 | { 5 | schema: { 6 | response: { 7 | 200: { 8 | type: "object", 9 | properties: { 10 | authenticated: { type: "boolean" }, 11 | }, 12 | }, 13 | }, 14 | tags: ["auth"], 15 | }, 16 | }, 17 | (req, reply) => { 18 | const token = req.headers.authorization?.split(" ")[1] 19 | return reply.send({ authenticated: !!token }) 20 | } 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/src/assets/media/fallback-track.svg: -------------------------------------------------------------------------------- 1 | Asset 1 -------------------------------------------------------------------------------- /apps/web/src/components/base/AsyncWrapper.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 29 | -------------------------------------------------------------------------------- /apps/api/src/schema/user.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | 3 | const plugin = fp(async function plugin(fastify) { 4 | fastify.addSchema({ 5 | $id: "user", 6 | title: "user", 7 | type: "object", 8 | properties: { 9 | username: { type: "string" }, 10 | avatar: { type: "string" }, 11 | display_name: { type: "string" }, 12 | lastLogin: { type: "string", format: "date-time" }, 13 | registrationDate: { type: "string", format: "date-time" }, 14 | spotifyID: { type: "string" }, 15 | country: { type: "string" }, 16 | }, 17 | }) 18 | }) 19 | 20 | export default plugin 21 | -------------------------------------------------------------------------------- /apps/web/src/components/base/Spotify/SpotifyLink.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "measurify", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "pnpm --stream -r run dev", 7 | "build": "pnpm --filter web run build", 8 | "start": "pnpm --stream -r start", 9 | "lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix .", 10 | "format": "prettier --write ." 11 | }, 12 | "packageManager": "pnpm@7.9.0", 13 | "engines": { 14 | "node": ">=16.14.0" 15 | }, 16 | "devDependencies": { 17 | "@vue/eslint-config-prettier": "^7.0.0", 18 | "eslint": "8.26.0", 19 | "eslint-plugin-vue": "^9.6.0", 20 | "prettier": "2.7.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get( 3 | "", 4 | { 5 | schema: { 6 | response: { 7 | 200: { 8 | type: "object", 9 | required: ["quantity", "status"], 10 | properties: { 11 | quantity: { type: "number" }, 12 | status: { type: "number" }, 13 | }, 14 | }, 15 | }, 16 | tags: ["users"], 17 | }, 18 | }, 19 | async function () { 20 | const quantity = await fastify.db.User.estimatedDocumentCount() 21 | return { quantity } 22 | } 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /apps/web/src/composable/useAsync.js: -------------------------------------------------------------------------------- 1 | import { ref } from "vue" 2 | 3 | // stolen from here 4 | // https://github.com/mutoe/vue3-realworld-example-app/blob/master/src/utils/create-async-process.ts 5 | 6 | export function createAsyncProcess(fn) { 7 | const loading = ref(false) 8 | const error = ref(false) 9 | const run = async (...args) => { 10 | try { 11 | loading.value = true 12 | error.value = false 13 | const result = await fn(...args) 14 | return result 15 | } catch (e) { 16 | error.value = e 17 | return e 18 | } finally { 19 | loading.value = false 20 | } 21 | } 22 | return { loading, error, run } 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/src/components/skeletons/TrackRowSkeleton.vue: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /apps/web/src/composable/useFollow.js: -------------------------------------------------------------------------------- 1 | import { createAsyncProcess } from "@/composable/useAsync.js" 2 | import { unfollowProfile, followProfile } from "@/api" 3 | 4 | export function useFollow({ username, following, onUpdate }) { 5 | async function toggleFollow() { 6 | try { 7 | if (following.value) { 8 | await unfollowProfile(username.value) 9 | } else { 10 | await followProfile(username.value) 11 | } 12 | onUpdate() 13 | } catch (error) { 14 | console.log(error) 15 | } 16 | } 17 | const { loading, run } = createAsyncProcess(toggleFollow) 18 | return { 19 | followProcessGoing: loading, 20 | toggleFollow: run, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/src/components/skeletons/SpotifyCardSkeleton.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 25 | -------------------------------------------------------------------------------- /apps/web/src/components/base/Skeleton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 34 | -------------------------------------------------------------------------------- /apps/web/src/components/base/ErrorScreen.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 25 | -------------------------------------------------------------------------------- /apps/web/src/composable/useAuth.js: -------------------------------------------------------------------------------- 1 | import { computed } from "vue" 2 | import { useUserStore } from "@/stores/user" 3 | import { useProfileStore } from "@/stores/profile" 4 | 5 | export const useAuth = () => { 6 | const userStore = useUserStore() 7 | const profileStore = useProfileStore() 8 | 9 | const user = computed(() => { 10 | return userStore.user 11 | }) 12 | 13 | const profile = computed(() => { 14 | return profileStore.profile 15 | }) 16 | 17 | const isAuthenticated = computed(() => { 18 | return userStore.isAuthenticated 19 | }) 20 | 21 | const isUserProfile = computed(() => { 22 | return profile.value.user.username === user.value.username 23 | }) 24 | 25 | return { isUserProfile, isAuthenticated } 26 | } 27 | -------------------------------------------------------------------------------- /apps/api/src/plugins/auth/session.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import fp from "fastify-plugin" 4 | 5 | export default fp(async function (fastify) { 6 | fastify.register(import("@fastify/jwt"), { 7 | secret: process.env.SECRET_COOKIE, 8 | }) 9 | 10 | fastify.decorate("getId", async function (request) { 11 | if (!request.headers.authorization) return null 12 | 13 | const token = request.headers.authorization.split(" ")[1] 14 | const decoded = await fastify.jwt.decode(token) 15 | return decoded.id 16 | }) 17 | 18 | fastify.decorate("auth", async function (request, reply) { 19 | try { 20 | await request.jwtVerify() 21 | } catch (err) { 22 | reply.code(401).send({ message: "Unauthorized" }) 23 | } 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /apps/web/src/services/notify.js: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid" 2 | import emitter from "./emitter.js" 3 | 4 | /** 5 | * @param {('info'|'success'|'warning'|'danger')} type 6 | */ 7 | 8 | export const notify = { 9 | show: ({ 10 | id = nanoid(), 11 | message, 12 | type = "info", 13 | delay = 3000, 14 | progress = true, 15 | closable = true, 16 | actions = {}, 17 | }) => { 18 | emitter.emit("newNotification", { 19 | id, 20 | message, 21 | type, 22 | delay, 23 | progress, 24 | closable, 25 | actions, 26 | }) 27 | }, 28 | dismiss: (id) => { 29 | emitter.emit("dismissNotification", id) 30 | }, 31 | clear: () => { 32 | emitter.emit("clearNotifications") 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /apps/web/src/components/skeletons/LeaderboardItemSkeleton.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 26 | -------------------------------------------------------------------------------- /apps/web/src/components/base/AudioFeatures/AudioFeature.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 32 | -------------------------------------------------------------------------------- /apps/web/src/components/preloaders/ArtistRelated.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 30 | -------------------------------------------------------------------------------- /apps/web/src/components/skeletons/ProfileSkeleton.vue: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /apps/web/src/dayjs/index.js: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs" 2 | import durationPlugin from "dayjs/plugin/duration" 3 | import relativeTimePlugin from "dayjs/plugin/relativeTime" 4 | import weekday from "dayjs/plugin/weekday" 5 | 6 | const shortRealitveTime = { 7 | name: "s-en", 8 | relativeTime: { 9 | future: "in %s", 10 | past: "%s ago", 11 | s: "few s", 12 | m: "1 m", 13 | mm: "%d m", 14 | h: "1 h", 15 | hh: "%d h", 16 | d: "1 d", 17 | dd: "%d d", 18 | M: "1 mn", 19 | MM: "%d mn", 20 | y: "1 y", 21 | yy: "%d y", 22 | }, 23 | } 24 | 25 | dayjs.locale(shortRealitveTime, null, true) 26 | dayjs.locale("en") 27 | 28 | dayjs.extend(durationPlugin) 29 | dayjs.extend(relativeTimePlugin) 30 | dayjs.extend(weekday) 31 | 32 | export default dayjs 33 | -------------------------------------------------------------------------------- /apps/api/src/schema/audioFeatures.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | 3 | const plugin = fp(async function plugin(fastify) { 4 | fastify.addSchema({ 5 | $id: "audioFeatures", 6 | title: "audio features", 7 | type: "object", 8 | properties: { 9 | danceability: { type: "number" }, 10 | energy: { type: "number" }, 11 | popularity: { type: "number" }, 12 | key: { type: "number" }, 13 | loudness: { type: "number" }, 14 | mode: { type: "number" }, 15 | speechiness: { type: "number" }, 16 | acousticness: { type: "number" }, 17 | instrumentalness: { type: "number" }, 18 | liveness: { type: "number" }, 19 | valence: { type: "number" }, 20 | tempo: { type: "number" }, 21 | }, 22 | }) 23 | }) 24 | 25 | export default plugin 26 | -------------------------------------------------------------------------------- /apps/web/src/components/skeletons/LibraryItemSkeleton.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 30 | -------------------------------------------------------------------------------- /apps/web/src/views/Index.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 35 | -------------------------------------------------------------------------------- /apps/web/src/composable/usePagination.js: -------------------------------------------------------------------------------- 1 | import { ref, watch, computed } from "vue" 2 | 3 | const noop = () => { 4 | // do nothing 5 | } 6 | 7 | export function usePagination({ 8 | fn, 9 | range, 10 | page, 11 | search = ref(""), 12 | onUpdate = noop, 13 | immediate = false, 14 | }) { 15 | const pageStateOptions = computed(() => { 16 | return { 17 | search: search.value, 18 | page: page.value, 19 | range: range.value, 20 | } 21 | }) 22 | 23 | const fetch = () => { 24 | fn(pageStateOptions.value) 25 | } 26 | 27 | watch(pageStateOptions, async (query) => { 28 | onUpdate() 29 | await fn(query) 30 | }) 31 | 32 | watch([search, range], () => { 33 | page.value = 1 34 | }) 35 | 36 | if (immediate) { 37 | fetch() 38 | } 39 | 40 | return { 41 | fetch, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /apps/api/src/plugins/decorators/db.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | import User from "#src/models/User.js" 3 | import Artist from "#src/models/Artist.js" 4 | import Album from "#src/models/Album.js" 5 | import Track from "#src/models/Track.js" 6 | import mongoose from "mongoose" 7 | 8 | mongoose.connect( 9 | process.env.DB_URI, 10 | { 11 | useNewUrlParser: true, 12 | useFindAndModify: false, 13 | useUnifiedTopology: true, 14 | useCreateIndex: true, 15 | }, 16 | (err) => { 17 | if (err) return console.error(`Database connection error: ${err.message}`) 18 | console.info(`Database successfully connected`) 19 | } 20 | ) 21 | 22 | const plugin = fp(async function plugin(fastify) { 23 | fastify.decorate("db", { 24 | User, 25 | Artist, 26 | Album, 27 | Track, 28 | }) 29 | }) 30 | 31 | export default plugin 32 | -------------------------------------------------------------------------------- /apps/web/src/components/base/Cards/Card.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 32 | -------------------------------------------------------------------------------- /apps/web/src/components/base/Tabs/Tabs.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 30 | 31 | 39 | -------------------------------------------------------------------------------- /apps/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "nodemon src/index.js", 8 | "start": "cross-env NODE_ENV=production node src/index.js" 9 | }, 10 | "imports": { 11 | "#src/*": "./src/*" 12 | }, 13 | "dependencies": { 14 | "@fastify/autoload": "^5.4.1", 15 | "@fastify/compress": "^6.1.1", 16 | "@fastify/cors": "^8.1.1", 17 | "@fastify/jwt": "^6.3.2", 18 | "@fastify/swagger": "^8.1.0", 19 | "dayjs": "^1.11.6", 20 | "dotenv": "^16.0.3", 21 | "fastify": "^4.9.2", 22 | "fastify-plugin": "^4.3.0", 23 | "genius-lyrics-api": "^3.2.0", 24 | "mongoose": "5.13.14", 25 | "node-cron": "^3.0.2", 26 | "node-fetch": "^3.2.10" 27 | }, 28 | "devDependencies": { 29 | "cross-env": "^7.0.3", 30 | "nodemon": "^2.0.20" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/api/src/routes/health/memory/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get( 3 | "", 4 | { 5 | schema: { 6 | response: { 7 | 200: { 8 | type: "object", 9 | properties: { 10 | heapUsed: { type: "number" }, 11 | rss: { type: "number" }, 12 | heapTotal: { type: "number" }, 13 | external: { type: "number" }, 14 | date: { type: "string", format: "date-time" }, 15 | }, 16 | }, 17 | }, 18 | tags: ["server"], 19 | }, 20 | }, 21 | (req, res) => { 22 | const { heapUsed, rss, heapTotal, external } = process.memoryUsage() 23 | 24 | res.send({ 25 | heapUsed, 26 | rss, 27 | heapTotal, 28 | external, 29 | date: new Date(), 30 | }) 31 | } 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /apps/api/src/routes/settings/get.js: -------------------------------------------------------------------------------- 1 | import User from "#src/models/User.js" 2 | 3 | export default async function (fastify) { 4 | fastify.get( 5 | "", 6 | { 7 | schema: { 8 | response: { 9 | 200: { 10 | type: "object", 11 | properties: { 12 | privacy: { type: "string" }, 13 | username: { type: "string" }, 14 | id: { type: "string" }, 15 | display_name: { type: "string" }, 16 | }, 17 | }, 18 | }, 19 | tags: ["settings"], 20 | }, 21 | }, 22 | async function (req, reply) { 23 | const id = req.user.id 24 | 25 | const user = await User.findById(id, "display_name settings").lean() 26 | if (!user) throw fastify.error("User not found", 404) 27 | 28 | user.id = id 29 | return reply.send(Object.assign(user, user.settings)) 30 | } 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/src/components/base/AudioFeatures/AudioFeatures.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 30 | -------------------------------------------------------------------------------- /apps/api/src/schema/track.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | import { entity, entities } from "./entity.js" 3 | 4 | const track = { 5 | type: "object", 6 | properties: { 7 | ...entity.properties, 8 | album: entity, 9 | artists: entities, 10 | }, 11 | } 12 | 13 | const tracks = { type: "array", items: track } 14 | 15 | const withDuration = { 16 | type: "object", 17 | properties: { 18 | ...track.properties, 19 | duration_ms: { type: "number" }, 20 | }, 21 | } 22 | 23 | const plugin = fp(async (fastify) => { 24 | fastify.addSchema({ 25 | ...track, 26 | $id: "track", 27 | title: "track", 28 | definitions: { withDuration }, 29 | }) 30 | 31 | fastify.addSchema({ 32 | ...tracks, 33 | $id: "tracks", 34 | title: "tracks", 35 | definitions: { withDuration: { type: "array", items: withDuration } }, 36 | }) 37 | }) 38 | 39 | export default plugin 40 | export { track, tracks } 41 | -------------------------------------------------------------------------------- /apps/web/src/components/skeletons/InfopageSkeleton.vue: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /apps/web/src/config/index.js: -------------------------------------------------------------------------------- 1 | export const BREAKPOINTS = { 2 | sm: "500px", 3 | md: "768px", 4 | lg: "1024px", 5 | xl: "1280px", 6 | "2xl": "1536px", 7 | } 8 | 9 | export const FEATURE_NAMES = { 10 | danceability: "Danceable", 11 | popularity: "Popularity", 12 | acousticness: "Acoustic", 13 | liveness: "Lively", 14 | energy: "Energetic", 15 | speechiness: "Speechful", 16 | instrumentalness: "Instrumental", 17 | valence: "Valence", 18 | } 19 | 20 | export const PERIODS = { 21 | LT: "lifetime", 22 | MT: "past 6 months", 23 | ST: "past 4 weeks", 24 | } 25 | 26 | export const PRIVACY_OPTIONS = [ 27 | { label: "Private", value: "private" }, 28 | { label: "Public", value: "public" }, 29 | ] 30 | 31 | export const RANGE_OPTIONS = [ 32 | { label: "10", value: 10 }, 33 | { label: "15", value: 15 }, 34 | { label: "25", value: 25 }, 35 | { label: "50", value: 50 }, 36 | { label: "100", value: 100 }, 37 | ] 38 | -------------------------------------------------------------------------------- /apps/web/src/stores/friends.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia" 2 | import { ref, computed } from "vue" 3 | import { useUserStore } from "@/stores/user" 4 | import { getFollowing } from "@/api" 5 | import { orderByDate } from "@/utils" 6 | import { createAsyncProcess } from "@/composable/useAsync" 7 | 8 | export const useFriendsStore = defineStore("friends", () => { 9 | const friends = ref(null) 10 | 11 | const userStore = useUserStore() 12 | 13 | const fetchFriends = async () => { 14 | friends.value = await getFollowing(userStore.user.username) 15 | } 16 | 17 | const friendsSortedByLastListened = computed(() => { 18 | return orderByDate(friends.value, "lastPlayed") 19 | }) 20 | 21 | const { run: updateFriends, loading } = createAsyncProcess(fetchFriends) 22 | 23 | return { 24 | updateFriends, 25 | loading, 26 | friends, 27 | fetchFriends, 28 | friendsSortedByLastListened, 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /apps/web/src/utils/chart.js: -------------------------------------------------------------------------------- 1 | import colorLib from "@kurkle/color" 2 | 3 | const COLORS = [ 4 | "rgb(230,57,1)", 5 | "rgb(255,102,0)", 6 | "rgb(255,201,0)", 7 | "rgb(143,191,0)", 8 | "rgb(0,101,255)", 9 | "rgb(153,0,255)", 10 | "rgb(184,0,0)", 11 | "rgb(0,101,255)", 12 | "rgb(54,54,217)", 13 | "rgb(204,1,153)", 14 | "rgb(1,174,190)", 15 | "", 16 | ] 17 | 18 | export function transparentize(value, opacity) { 19 | var alpha = opacity === undefined ? 0.5 : 1 - opacity 20 | return colorLib(value).alpha(alpha).rgbString() 21 | } 22 | 23 | export function randomColor(index) { 24 | return COLORS[index % COLORS.length] 25 | } 26 | 27 | export const down = (ctx, value) => 28 | ctx.p0.parsed.y > ctx.p1.parsed.y ? value : undefined 29 | 30 | export const nullish = (ctx, value) => 31 | ctx.p0.parsed.y === 0 ? value : undefined 32 | 33 | export const skipped = (ctx, value) => 34 | ctx.p0.skip || ctx.p1.skip ? value : undefined 35 | -------------------------------------------------------------------------------- /apps/api/src/routes/auth/login/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get("", { schema: { tags: ["auth"] } }, (req, reply) => { 3 | const redirect_uri = `${process.env.VITE_SERVER_URI}/auth/callback` 4 | const scopes = [ 5 | "playlist-read-collaborative", 6 | "user-read-currently-playing", 7 | "user-read-recently-played", 8 | // "playlist-modify-private", 9 | // "playlist-modify-public", 10 | "playlist-read-private", 11 | "user-read-private", 12 | "user-library-read", 13 | "user-follow-read", 14 | // "ugc-image-upload", 15 | "user-read-email", 16 | "user-top-read", 17 | ] 18 | 19 | return reply.redirect( 20 | "https://accounts.spotify.com/authorize?" + 21 | `response_type=code` + 22 | `&client_id=${process.env.SPOTIFY_CLIENT_ID}` + 23 | `&scope=${scopes.join(" ")}` + 24 | `&redirect_uri=${redirect_uri}` 25 | ) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 15 | 22 | measurify 23 | 24 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /apps/api/src/routes/infopage/artist/_id/related-artists/index.js: -------------------------------------------------------------------------------- 1 | import { addImage } from "#src/utils/index.js" 2 | 3 | export default async function (fastify) { 4 | fastify.get( 5 | "", 6 | { 7 | schema: { 8 | params: { 9 | type: "object", 10 | required: ["id"], 11 | properties: { id: { type: "string", minLength: 22, maxLength: 22 } }, 12 | }, 13 | response: { 14 | 200: { $ref: "entities#" }, 15 | }, 16 | tags: ["infopages"], 17 | }, 18 | }, 19 | async function (req, reply) { 20 | const artistID = req.params.id 21 | 22 | const token = await fastify.getRandomToken() 23 | 24 | const relatedArtists = await fastify.spotifyAPI({ 25 | route: `artists/${artistID}/related-artists`, 26 | token, 27 | }) 28 | 29 | return reply.send( 30 | relatedArtists.artists.map((artist) => addImage(artist, 1)) 31 | ) 32 | } 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /apps/api/src/includes/parseAudioFeatures.js: -------------------------------------------------------------------------------- 1 | import api from "#src/includes/api.js" 2 | 3 | export default async (tracks, token) => { 4 | const base = { 5 | danceability: 0, 6 | energy: 0, 7 | speechiness: 0, 8 | loudness: 0, 9 | acousticness: 0, 10 | instrumentalness: 0, 11 | liveness: 0, 12 | valence: 0, 13 | tempo: 0, 14 | } 15 | 16 | if (!tracks.length) return base 17 | 18 | // features of several tracks 19 | const { audio_features } = await api({ 20 | route: `audio-features?ids=${tracks.slice(0, 99).join(",")}`, 21 | token, 22 | }) 23 | 24 | // sum of features of several tracks 25 | audio_features.forEach((trackFeatures) => { 26 | Object.keys(base).forEach((key) => { 27 | base[key] += trackFeatures[key] 28 | }) 29 | }) 30 | 31 | const itemsNum = audio_features.length 32 | 33 | // average 34 | Object.keys(base).forEach((key) => { 35 | base[key] = Number((base[key] / itemsNum).toFixed(3)) 36 | }) 37 | 38 | return base 39 | } 40 | -------------------------------------------------------------------------------- /apps/api/src/models/Artist.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose" 2 | const { Schema, model } = mongoose 3 | 4 | const schema = new Schema( 5 | { 6 | _id: { type: String }, 7 | name: { type: String }, 8 | followers: { type: Number, default: 0 }, 9 | genres: [{ type: String }], 10 | audioFeatures: { 11 | tempo: { type: Number }, 12 | energy: { type: Number }, 13 | loudness: { type: Number }, 14 | danceability: { type: Number }, 15 | instrumentalness: { type: Number }, 16 | acousticness: { type: Number }, 17 | speechiness: { type: Number }, 18 | popularity: { type: Number }, 19 | liveness: { type: Number }, 20 | valence: { type: Number }, 21 | }, 22 | images: { 23 | highQuality: { type: String, default: "" }, 24 | mediumQuality: { type: String, default: "" }, 25 | lowQuality: { type: String, default: "" }, 26 | }, 27 | }, 28 | { timestamps: { updatedAt: "updated_at" } } 29 | ) 30 | 31 | export default model("Artist", schema) 32 | -------------------------------------------------------------------------------- /apps/api/src/plugins/hooks/userInfo.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | 3 | const plugin = fp(async function plugin(fastify) { 4 | fastify.decorateRequest("userInfo", "") 5 | fastify.decorate("getUserInfo", async function (req) { 6 | const { username } = req.params 7 | 8 | const user = await fastify.db.User.findOne( 9 | { "settings.username": username }, 10 | { 11 | token: "$tokens.token", 12 | refreshToken: "$tokens.refreshToken", 13 | country: 1, 14 | "settings.privacy": 1, 15 | username: "$settings.username", 16 | } 17 | ).lean() 18 | 19 | if (!user) throw fastify.error("User not found", 404) 20 | 21 | const isPrivate = user.settings.privacy === "private" 22 | const requestorID = await fastify.getId(req) 23 | if (isPrivate && user._id !== requestorID) 24 | throw fastify.error("Private profile", 403) 25 | 26 | user.leaved = user.refreshToken === "" 27 | req.userInfo = user 28 | }) 29 | }) 30 | 31 | export default plugin 32 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "start": "vite preview" 9 | }, 10 | "dependencies": { 11 | "@kurkle/color": "^0.2.1", 12 | "@vueuse/core": "^9.4.0", 13 | "@vueuse/router": "^9.4.0", 14 | "axios": "^1.1.3", 15 | "chart.js": "^3.9.1", 16 | "chartjs-plugin-datalabels": "^2.1.0", 17 | "dayjs": "^1.11.6", 18 | "nanoevents": "^7.0.1", 19 | "nanoid": "^4.0.0", 20 | "pinia": "^2.0.23", 21 | "v-wave": "^1.5.0", 22 | "vue": "^3.2.41", 23 | "vue-chart-3": "^3.1.8", 24 | "vue-router": "^4.1.6", 25 | "workbox-window": "^6.5.4" 26 | }, 27 | "devDependencies": { 28 | "@iconify/vue": "^4.0.0", 29 | "@vitejs/plugin-vue": "^3.2.0", 30 | "nodemon": "^2.0.20", 31 | "unplugin-vue-components": "^0.22.9", 32 | "vite": "^3.2.1", 33 | "vite-plugin-pwa": "^0.13.1", 34 | "vite-plugin-windicss": "^1.8.8", 35 | "windicss": "^3.5.6" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/api/src/includes/forAllUsers.js: -------------------------------------------------------------------------------- 1 | import User from "#src/models/User.js" 2 | import { timeDiff } from "#src/utils/index.js" 3 | 4 | export default async function ({ operation = "unknown operation" }, cb) { 5 | try { 6 | const start = new Date() 7 | 8 | const users = await User.find( 9 | { "tokens.refreshToken": { $ne: "" } }, 10 | { 11 | tokens: 1, 12 | listeningHistory: { $slice: ["$listeningHistory", 1] }, 13 | lastLogin: 1, 14 | display_name: 1, 15 | } 16 | ).lean() 17 | 18 | const requests = users.map((user) => 19 | cb(user).catch((err) => { 20 | console.error(`!${operation} [${user.display_name}]: ${err.message}`) 21 | }) 22 | ) 23 | 24 | await Promise.all(requests) 25 | 26 | const end = new Date() 27 | console.info( 28 | `${operation} [${requests.length}]: updated in ${timeDiff( 29 | start, 30 | end 31 | )} sec` 32 | ) 33 | } catch (err) { 34 | console.error(`!${operation} [all]: ${err.message}`) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /apps/api/src/schema/top.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | import { entity } from "./entity.js" 3 | import { track } from "./track.js" 4 | 5 | const topItems = { 6 | type: "array", 7 | items: { 8 | type: "object", 9 | required: ["id", "image", "name", "plays"], 10 | properties: Object.assign({ plays: { type: "number" } }, entity.properties), 11 | }, 12 | } 13 | 14 | const plugin = fp(async function plugin(fastify) { 15 | fastify.addSchema({ 16 | $id: "top", 17 | title: "top", 18 | type: "object", 19 | properties: { 20 | tracks: { 21 | type: "array", 22 | items: { 23 | type: "object", 24 | properties: { 25 | ...track.properties, 26 | plays: { type: "number" }, 27 | duration_ms: { type: "number" }, 28 | }, 29 | }, 30 | }, 31 | albums: topItems, 32 | artists: topItems, 33 | }, 34 | }) 35 | fastify.addSchema({ 36 | $id: "topItems", 37 | title: "topItems", 38 | ...topItems, 39 | }) 40 | }) 41 | 42 | export default plugin 43 | -------------------------------------------------------------------------------- /apps/api/src/models/Album.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose" 2 | const { Schema, model } = mongoose 3 | 4 | const schema = new Schema( 5 | { 6 | _id: { type: String }, 7 | name: { type: String }, 8 | audioFeatures: { 9 | tempo: { type: Number }, 10 | energy: { type: Number }, 11 | loudness: { type: Number }, 12 | danceability: { type: Number }, 13 | instrumentalness: { type: Number }, 14 | acousticness: { type: Number }, 15 | speechiness: { type: Number }, 16 | popularity: { type: Number }, 17 | liveness: { type: Number }, 18 | valence: { type: Number }, 19 | }, 20 | release_date: { type: String }, 21 | total_tracks: { type: Number }, 22 | genres: { type: [String] }, 23 | label: { type: String }, 24 | images: { 25 | highQuality: { type: String, default: "" }, 26 | mediumQuality: { type: String, default: "" }, 27 | lowQuality: { type: String, default: "" }, 28 | }, 29 | }, 30 | { timestamps: { updatedAt: "updated_at" } } 31 | ) 32 | 33 | export default model("Album", schema) 34 | -------------------------------------------------------------------------------- /apps/api/src/routes/infopage/artist/_id/albums/index.js: -------------------------------------------------------------------------------- 1 | import { addImage } from "#src/utils/index.js" 2 | 3 | export default async function (fastify) { 4 | fastify.get( 5 | "", 6 | { 7 | schema: { 8 | params: { 9 | type: "object", 10 | required: ["id"], 11 | properties: { id: { type: "string", minLength: 22, maxLength: 22 } }, 12 | }, 13 | response: { 14 | 200: { $ref: "entities#" }, 15 | }, 16 | tags: ["infopages"], 17 | }, 18 | }, 19 | async function (req, reply) { 20 | const artistID = req.params.id 21 | const id = await fastify.getId(req) 22 | const [token, country] = await Promise.all([ 23 | fastify.getRandomToken(), 24 | fastify.getCountry(id), 25 | ]) 26 | 27 | const albums = await fastify.spotifyAPI({ 28 | route: `artists/${artistID}/albums?include_groups=album,single&market=${country}`, 29 | token, 30 | }) 31 | 32 | return reply.send(albums.items.map((artist) => addImage(artist, 1))) 33 | } 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/me/get.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get( 3 | "", 4 | { 5 | schema: { 6 | response: { 7 | 200: { 8 | type: "object", 9 | properties: { 10 | status: { type: "number" }, 11 | avatar: { type: "string" }, 12 | username: { type: "string" }, 13 | display_name: { type: "string" }, 14 | country: { type: "string" }, 15 | }, 16 | }, 17 | }, 18 | tags: ["users"], 19 | }, 20 | preValidation: [fastify.auth], 21 | }, 22 | async function (req, reply) { 23 | const id = req.user.id 24 | 25 | const user = await fastify.db.User.findByIdAndUpdate(id, { 26 | lastLogin: Date.now(), 27 | }) 28 | .select("settings.username display_name country avatar") 29 | .lean() 30 | 31 | if (!user) throw fastify.error("User not found", 404) 32 | 33 | user.username = user.settings.username 34 | 35 | return reply.send(user) 36 | } 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /apps/api/src/routes/follow/delete.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.delete( 3 | "", 4 | { 5 | schema: { 6 | query: { 7 | type: "object", 8 | required: ["username"], 9 | properties: { 10 | username: { type: "string" }, 11 | }, 12 | }, 13 | tags: ["follow"], 14 | }, 15 | }, 16 | async function (req, reply) { 17 | const _id = req.user.id 18 | const username = req.query.username 19 | 20 | const user = await fastify.db.User.findOne( 21 | { "settings.username": username }, 22 | "_id followers" 23 | ) 24 | 25 | if (!user) return reply.code(404).send({ message: "User not found" }) 26 | 27 | const userID = user._id 28 | 29 | await Promise.all([ 30 | fastify.db.User.updateOne({ _id }, { $pull: { follows: userID } }), 31 | fastify.db.User.updateOne( 32 | { _id: userID }, 33 | { $pull: { followers: _id } } 34 | ), 35 | ]) 36 | 37 | return reply.code(204).send() 38 | } 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /apps/api/src/models/Track.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose" 2 | const { Schema, model } = mongoose 3 | 4 | const schema = new Schema( 5 | { 6 | _id: { type: String }, 7 | name: { type: String }, 8 | duration_ms: { type: Number }, 9 | release_date: { type: String }, 10 | audioFeatures: { 11 | tempo: { type: Number }, 12 | energy: { type: Number }, 13 | loudness: { type: Number }, 14 | danceability: { type: Number }, 15 | instrumentalness: { type: Number }, 16 | acousticness: { type: Number }, 17 | speechiness: { type: Number }, 18 | popularity: { type: Number }, 19 | liveness: { type: Number }, 20 | valence: { type: Number }, 21 | }, 22 | images: { 23 | highQuality: { type: String, default: "" }, 24 | mediumQuality: { type: String, default: "" }, 25 | lowQuality: { type: String, default: "" }, 26 | }, 27 | album: { type: String, ref: "Album" }, 28 | artists: [{ type: String, ref: "Artist" }], 29 | }, 30 | { timestamps: { updatedAt: "updated_at" } } 31 | ) 32 | 33 | export default model("Track", schema) 34 | -------------------------------------------------------------------------------- /apps/web/src/stores/user.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia" 2 | import { useRouter } from "vue-router" 3 | import { computed } from "vue" 4 | import { useStorage } from "@vueuse/core" 5 | import { getCurrentUser, checkAuthorization } from "@/api" 6 | 7 | export const useUserStore = defineStore("user", () => { 8 | const user = useStorage("measurify-user", null) 9 | const token = useStorage("measurify-token", null) 10 | const router = useRouter() 11 | 12 | const isAuthenticated = computed(() => !!user.value && !!token.value) 13 | 14 | async function updateUser() { 15 | try { 16 | const { authenticated } = await checkAuthorization() 17 | if (!authenticated) return 18 | user.value = await getCurrentUser() 19 | } catch (error) { 20 | return Promise.reject(error) 21 | } 22 | } 23 | async function logout() { 24 | try { 25 | token.value = null 26 | await router.push({ name: "home" }) 27 | user.value = null 28 | } catch (error) { 29 | return Promise.reject(error) 30 | } 31 | } 32 | 33 | return { user, token, isAuthenticated, updateUser, logout } 34 | }) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 anyrange 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/api/src/routes/infopage/album/_id/artists/index.js: -------------------------------------------------------------------------------- 1 | import { addImage } from "#src/utils/index.js" 2 | 3 | export default async function (fastify) { 4 | fastify.get( 5 | "", 6 | { 7 | schema: { 8 | params: { 9 | type: "object", 10 | required: ["id"], 11 | properties: { id: { type: "string", minLength: 22, maxLength: 22 } }, 12 | }, 13 | response: { 14 | 200: { $ref: "entities#" }, 15 | }, 16 | tags: ["infopages"], 17 | }, 18 | }, 19 | async function (req, reply) { 20 | const albumID = req.params.id 21 | 22 | const token = await fastify.getRandomToken() 23 | 24 | const album = await fastify.spotifyAPI({ 25 | route: `albums/${albumID}`, 26 | token, 27 | }) 28 | 29 | const artists = await fastify 30 | .spotifyAPI({ 31 | route: `artists?ids=${album.artists.map(({ id }) => id).join(",")}`, 32 | token, 33 | }) 34 | .then(({ artists }) => artists.map((artist) => addImage(artist, 1))) 35 | 36 | return reply.send(artists) 37 | } 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/src/components/layout/InfopageHeader.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 36 | -------------------------------------------------------------------------------- /apps/api/src/index.js: -------------------------------------------------------------------------------- 1 | import app from "./app.js" 2 | 3 | const port = process.env.PORT || 8888 4 | 5 | app.listen({ port, host: "0.0.0.0" }, (err) => { 6 | if (err) return console.error(err) 7 | console.info(`Docs are available on: http://localhost:${port}/docs`) 8 | }) 9 | 10 | const isProduction = process.env.NODE_ENV == "production" 11 | if (isProduction) startScheduledJobs() 12 | 13 | async function startScheduledJobs() { 14 | const { default: cron } = await import("node-cron") 15 | 16 | const cronPath = "#src/includes/cron-workers/" 17 | 18 | const { refreshTokens } = await import(cronPath + "tokens.js") 19 | const { parseHistory } = await import(cronPath + "recentlyPlayed.js") 20 | const { refreshAvatars } = await import(cronPath + "avatars.js") 21 | const { parseGenres } = await import(cronPath + "genres.js") 22 | 23 | await refreshTokens() 24 | await parseHistory() 25 | 26 | cron.schedule("*/30 * * * *", refreshTokens) // every 30 min 27 | cron.schedule("*/5 * * * *", parseHistory) // every 5 min 28 | cron.schedule("0 */12 * * *", refreshAvatars) // every 12 hours 29 | cron.schedule("0 0 * * MON", parseGenres) // every monday 30 | } 31 | -------------------------------------------------------------------------------- /apps/api/src/routes/infopage/album/_id/content/index.js: -------------------------------------------------------------------------------- 1 | import { arrLastEl } from "#src/utils/index.js" 2 | 3 | export default async function (fastify) { 4 | fastify.get( 5 | "", 6 | { 7 | schema: { 8 | params: { 9 | type: "object", 10 | required: ["id"], 11 | properties: { id: { type: "string", minLength: 22, maxLength: 22 } }, 12 | }, 13 | response: { 14 | 200: fastify.getSchema("tracks").definitions.withDuration, 15 | }, 16 | tags: ["infopages"], 17 | }, 18 | }, 19 | async function (req, reply) { 20 | const albumID = req.params.id 21 | const id = await fastify.getId(req) 22 | const [token, country] = await Promise.all([ 23 | fastify.getRandomToken(), 24 | fastify.getCountry(id), 25 | ]) 26 | 27 | const album = await fastify.spotifyAPI({ 28 | route: `albums/${albumID}?market=${country}`, 29 | token, 30 | }) 31 | 32 | return reply.send( 33 | album.tracks.items.map((track) => ({ 34 | ...track, 35 | image: arrLastEl(album.images).url || "", 36 | })) 37 | ) 38 | } 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /apps/api/src/routes/infopage/track/_id/more-tracks/index.js: -------------------------------------------------------------------------------- 1 | import { formatTrack } from "#src/utils/index.js" 2 | 3 | export default async function (fastify) { 4 | fastify.get( 5 | "", 6 | { 7 | schema: { 8 | params: { 9 | type: "object", 10 | required: ["id"], 11 | properties: { id: { type: "string", minLength: 22, maxLength: 22 } }, 12 | }, 13 | response: { 14 | 200: fastify.getSchema("tracks").definitions.withDuration, 15 | }, 16 | tags: ["infopages"], 17 | }, 18 | }, 19 | async function (req, reply) { 20 | const trackID = req.params.id 21 | 22 | const token = await fastify.getRandomToken() 23 | 24 | const track = await fastify.spotifyAPI({ 25 | route: `tracks/${trackID}`, 26 | token, 27 | }) 28 | 29 | const { tracks: moreTracks } = await fastify.spotifyAPI({ 30 | route: `artists/${track.artists[0].id}/top-tracks?market=US`, 31 | token, 32 | }) 33 | 34 | return reply.send( 35 | moreTracks 36 | .map((track) => formatTrack(track)) 37 | .filter((track) => track.id !== trackID) 38 | ) 39 | } 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /apps/web/src/stores/profile.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia" 2 | import { ref, computed } from "vue" 3 | import dayjs from "@/dayjs" 4 | 5 | const defaultRange = { 6 | week: { 7 | firstDate: dayjs().weekday(-6).format("YYYY-MM-DD"), 8 | lastDate: dayjs().weekday(0).format("YYYY-MM-DD"), 9 | }, 10 | month: { 11 | firstDate: dayjs() 12 | .subtract(1, "months") 13 | .startOf("month") 14 | .format("YYYY-MM-DD"), 15 | lastDate: dayjs().subtract(1, "months").endOf("month").format("YYYY-MM-DD"), 16 | }, 17 | year: { 18 | firstDate: dayjs() 19 | .subtract(1, "years") 20 | .startOf("year") 21 | .format("YYYY-MM-DD"), 22 | lastDate: dayjs().subtract(1, "years").endOf("year").format("YYYY-MM-DD"), 23 | }, 24 | } 25 | 26 | export const useProfileStore = defineStore("profile", () => { 27 | const profile = ref(null) 28 | 29 | const dateRanges = computed(() => ({ 30 | ...defaultRange, 31 | overall: { 32 | firstDate: dayjs(profile.value?.user?.registrationDate).format( 33 | "YYYY-MM-DD" 34 | ), 35 | lastDate: dayjs().format("YYYY-MM-DD"), 36 | }, 37 | })) 38 | 39 | return { 40 | profile, 41 | dateRanges, 42 | } 43 | }) 44 | -------------------------------------------------------------------------------- /apps/web/public/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/api/src/routes/lyrics/index.js: -------------------------------------------------------------------------------- 1 | import { getLyrics } from "genius-lyrics-api" 2 | 3 | export default async function (fastify) { 4 | fastify.get( 5 | "", 6 | { 7 | schema: { 8 | query: { 9 | type: "object", 10 | required: ["title", "artist"], 11 | properties: { 12 | title: { type: "string" }, 13 | artist: { type: "string" }, 14 | }, 15 | }, 16 | response: { 17 | 200: { 18 | type: "object", 19 | properties: { 20 | lyrics: { type: "string" }, 21 | status: { type: "number" }, 22 | }, 23 | }, 24 | }, 25 | tags: ["other"], 26 | }, 27 | }, 28 | async function (req, reply) { 29 | const { artist, title } = req.query 30 | const GENIUS_API_SECRET_KEY = process.env.GENIUS_API_SECRET_KEY 31 | 32 | const lyrics = await getLyrics({ 33 | apiKey: GENIUS_API_SECRET_KEY, 34 | title: title, 35 | artist: artist, 36 | optimizeQuery: true, 37 | }) 38 | 39 | return reply 40 | .header("Cache-Control", "public, max-age=600") 41 | .send({ lyrics }) 42 | } 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /apps/web/src/components/preloaders/AlbumContent.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 47 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/_username/reports/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get( 3 | "", 4 | { 5 | schema: { 6 | params: { 7 | type: "object", 8 | required: ["username"], 9 | properties: { username: { type: "string" } }, 10 | }, 11 | 12 | response: { 13 | 200: { 14 | type: "object", 15 | required: ["plays", "playtime", "meantime"], 16 | properties: { 17 | plays: { type: "number" }, 18 | playtime: { type: "number" }, 19 | meantime: { type: "number" }, 20 | }, 21 | }, 22 | }, 23 | tags: ["user"], 24 | }, 25 | preHandler: [fastify.getUserInfo], 26 | }, 27 | async function (req, reply) { 28 | const user = req.userInfo 29 | 30 | const res = await fastify.db.User.findById(user._id, { 31 | listened: 1, 32 | }).lean() 33 | 34 | const plays = res.listened?.count || 0 35 | const playtime = (res.listened?.time || 0) / 60 36 | 37 | return reply.send({ 38 | plays, 39 | playtime: Math.round(playtime), 40 | meantime: (playtime / plays || 1).toFixed(2), 41 | }) 42 | } 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /apps/web/src/components/base/BaseInput.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 52 | -------------------------------------------------------------------------------- /apps/api/src/models/User.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose" 2 | const { Schema, model } = mongoose 3 | 4 | const schema = new Schema({ 5 | _id: { type: String }, 6 | display_name: { type: String, required: true }, 7 | avatar: { type: String }, 8 | country: { type: String, default: "US" }, 9 | listened: { 10 | count: { type: Number, default: 0 }, 11 | time: { type: Number, default: 0 }, 12 | }, 13 | listeningHistory: [ 14 | { 15 | _id: false, 16 | track: { type: String, ref: "Track" }, 17 | played_at: { type: Date }, 18 | }, 19 | ], 20 | settings: { 21 | username: { type: String, unique: true, required: true }, 22 | privacy: { type: String, default: "public", required: true }, 23 | }, 24 | genresTimeline: [ 25 | { 26 | _id: false, 27 | genres: [{ type: String }], 28 | date: { type: Date, default: Date.now }, 29 | }, 30 | ], 31 | tokens: { 32 | refreshToken: { type: String, required: true }, 33 | token: { type: String, required: true }, 34 | }, 35 | followers: { type: [String], ref: "User", default: [] }, 36 | follows: { type: [String], ref: "User", default: [] }, 37 | lastLogin: { type: Date, default: Date.now }, 38 | registrationDate: { type: Date, default: Date.now }, 39 | }) 40 | 41 | export default model("User", schema) 42 | -------------------------------------------------------------------------------- /apps/web/src/components/layout/Items/CompatibilityItem.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 52 | -------------------------------------------------------------------------------- /apps/web/src/components/layout/ProfileBadge.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 37 | -------------------------------------------------------------------------------- /apps/web/src/components/base/Tabs/Tab.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 56 | -------------------------------------------------------------------------------- /apps/api/src/utils/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param {Date} start 4 | * @param {Date} end 5 | * @returns {string} 6 | */ 7 | 8 | export function timeDiff(start, end) { 9 | const diffInMs = end.getTime() - start.getTime() 10 | const diffInS = diffInMs / 1000 11 | return Number(diffInS.toFixed(2)) 12 | } 13 | 14 | export function arrLastEl(arr) { 15 | if (!arr?.length) return null 16 | return arr.slice(-1)[0] 17 | } 18 | 19 | export function formatTrack(track) { 20 | const images = track.album.images 21 | track.image = arrLastEl(images)?.url || "" 22 | return track 23 | } 24 | 25 | export function addImage(item, quality = 0) { 26 | item.image = item.images[quality]?.url || item.images[0]?.url || "" 27 | return item 28 | } 29 | 30 | const CHUNK_SIZE = 20 31 | 32 | export async function multipleRequests(ids, cb) { 33 | const chunks = [] 34 | 35 | for (let i = 0; i < Math.ceil(ids.length / CHUNK_SIZE); i++) { 36 | const sliceStart = i * CHUNK_SIZE 37 | chunks.push(ids.slice(sliceStart, sliceStart + CHUNK_SIZE).join(",")) 38 | } 39 | 40 | const responses = await Promise.all(chunks.map((chunk) => cb(chunk))) 41 | 42 | return responses 43 | } 44 | 45 | export function getMonday() { 46 | let d = new Date() 47 | const day = d.getDay() 48 | const diff = d.getDate() - day + (day == 0 ? -6 : 1) 49 | 50 | return new Date(d.setDate(diff)) 51 | } 52 | -------------------------------------------------------------------------------- /apps/api/src/routes/follow/post.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.post( 3 | "", 4 | { 5 | schema: { 6 | query: { 7 | type: "object", 8 | required: ["username"], 9 | properties: { 10 | username: { type: "string" }, 11 | }, 12 | }, 13 | tags: ["follow"], 14 | }, 15 | }, 16 | async function (req, reply) { 17 | const _id = req.user.id 18 | const username = req.query.username 19 | 20 | const user = await fastify.db.User.findOne( 21 | { "settings.username": username }, 22 | "_id followers" 23 | ) 24 | 25 | if (!user) return reply.code(404).send({ message: "User not found" }) 26 | 27 | const userID = user._id 28 | 29 | if (userID === _id) 30 | return reply.code(400).send({ message: "You can't follow yourself" }) 31 | 32 | if (user.followers.includes(_id)) 33 | return reply 34 | .code(400) 35 | .send({ message: "You are already following this user" }) 36 | 37 | await Promise.all([ 38 | fastify.db.User.updateOne({ _id }, { $push: { follows: userID } }), 39 | fastify.db.User.updateOne( 40 | { _id: userID }, 41 | { $push: { followers: _id } } 42 | ), 43 | ]) 44 | 45 | return reply.code(204).send() 46 | } 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /apps/web/src/assets/media/fallback-profile.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/_username/library/albums/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get( 3 | "", 4 | { 5 | schema: { 6 | params: { 7 | type: "object", 8 | required: ["username"], 9 | properties: { username: { type: "string" } }, 10 | }, 11 | querystring: { 12 | type: "object", 13 | properties: { 14 | range: { type: "number", minimum: 1, maximum: 50, default: 20 }, 15 | page: { type: "number", minimum: 1, default: 1 }, 16 | firstDate: { type: "string", format: "date" }, 17 | lastDate: { type: "string", format: "date" }, 18 | }, 19 | }, 20 | response: { 21 | 200: { 22 | type: "object", 23 | properties: { 24 | status: { type: "number" }, 25 | albums: { $ref: "topItems#" }, 26 | pages: { type: "number" }, 27 | }, 28 | }, 29 | }, 30 | tags: ["user"], 31 | }, 32 | preHandler: [fastify.getUserInfo], 33 | }, 34 | async function (req, reply) { 35 | const { range, page, firstDate, lastDate } = req.query 36 | 37 | const _id = req.userInfo._id 38 | const options = { _id, range, page, firstDate, lastDate } 39 | 40 | const top = await fastify.userTopAlbums(options) 41 | 42 | return reply.send(top) 43 | } 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/_username/library/artists/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get( 3 | "", 4 | { 5 | schema: { 6 | params: { 7 | type: "object", 8 | required: ["username"], 9 | properties: { username: { type: "string" } }, 10 | }, 11 | querystring: { 12 | type: "object", 13 | properties: { 14 | range: { type: "number", minimum: 1, maximum: 50, default: 20 }, 15 | page: { type: "number", minimum: 1, default: 1 }, 16 | firstDate: { type: "string", format: "date" }, 17 | lastDate: { type: "string", format: "date" }, 18 | }, 19 | }, 20 | response: { 21 | 200: { 22 | type: "object", 23 | properties: { 24 | status: { type: "number" }, 25 | artists: { $ref: "topItems#" }, 26 | pages: { type: "number" }, 27 | }, 28 | }, 29 | }, 30 | tags: ["user"], 31 | }, 32 | preHandler: [fastify.getUserInfo], 33 | }, 34 | async function (req, reply) { 35 | const { range, page, firstDate, lastDate } = req.query 36 | 37 | const _id = req.userInfo._id 38 | const options = { _id, range, page, firstDate, lastDate } 39 | 40 | const top = await fastify.userTopArtists(options) 41 | 42 | return reply.send(top) 43 | } 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /apps/api/src/includes/cron-workers/genres.js: -------------------------------------------------------------------------------- 1 | import User from "#src/models/User.js" 2 | import api from "#src/includes/api.js" 3 | import forAllUsers from "#src/includes/forAllUsers.js" 4 | 5 | export async function parseGenres() { 6 | await forAllUsers({ operation: "genres" }, refreshGenres) 7 | } 8 | 9 | async function refreshGenres({ tokens: { token }, display_name }) { 10 | const genres = await getUserGenres(token) 11 | 12 | await User.updateOne( 13 | { display_name }, 14 | { 15 | $push: { 16 | genresTimeline: { 17 | $each: [{ genres }], 18 | $position: 0, 19 | }, 20 | }, 21 | } 22 | ) 23 | } 24 | 25 | export const getUserGenres = async (token) => { 26 | const artists = await api({ 27 | route: `me/top/artists?time_range=short_term&limit=50`, 28 | token, 29 | }) 30 | 31 | if (!artists.items.length) return [] 32 | const genres = artists.items.map(({ genres }) => genres).flat(1) 33 | 34 | const res = genres.reduce((data, curr) => { 35 | data[curr] = data[curr] ? ++data[curr] : 1 36 | return data 37 | }, {}) 38 | 39 | const genresTop = Object.entries(res).map(([val, numTimes]) => ({ 40 | genre: val, 41 | times: numTimes, 42 | })) 43 | 44 | const GENRES_LIMIT = 10 45 | 46 | const formattedGenres = genresTop 47 | .sort((a, b) => b.times - a.times) 48 | .map(({ genre }) => genre) 49 | .slice(0, GENRES_LIMIT) 50 | 51 | return formattedGenres 52 | } 53 | -------------------------------------------------------------------------------- /apps/web/src/assets/styles/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | @apply bg-secondary-darker; 3 | -webkit-tap-highlight-color: rgba(255, 255, 255, 0); 4 | font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", 5 | "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | overscroll-behavior-y: none; 10 | } 11 | 12 | .custom-scrollbar #content-window::-webkit-scrollbar, 13 | #horizontal-scrollbar::-webkit-scrollbar, 14 | #friends-list::-webkit-scrollbar { 15 | overflow: overlay; 16 | @apply w-2 h-1; 17 | } 18 | 19 | .custom-scrollbar #feedList::-webkit-scrollbar { 20 | overflow: overlay; 21 | @apply w-2 h-0; 22 | } 23 | 24 | .custom-scrollbar #content-window::-webkit-scrollbar-thumb, 25 | #horizontal-scrollbar::-webkit-scrollbar-thumb, 26 | #friends-list::-webkit-scrollbar-thumb, 27 | #feedList::-webkit-scrollbar-thumb { 28 | @apply rounded-lg bg-secondary-dark; 29 | } 30 | 31 | .h-title { 32 | @apply sm:text-2xl text-xl font-semibold text-white; 33 | } 34 | .h-subtitle { 35 | @apply text-base sm:text-lg font-normal text-secondary-lighter; 36 | } 37 | 38 | .fullwidth { 39 | @apply w-full table table-fixed; 40 | } 41 | .link { 42 | @apply text-white hover:underline; 43 | } 44 | .break-inside { 45 | break-inside: avoid; 46 | } 47 | 48 | a { 49 | @apply default-focus; 50 | } 51 | hr { 52 | @apply border-secondary-dark; 53 | } 54 | -------------------------------------------------------------------------------- /apps/api/src/includes/api.js: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch" 2 | 3 | function sleep(ms) { 4 | return new Promise((resolve) => { 5 | setTimeout(resolve, ms) 6 | }) 7 | } 8 | 9 | export default async function ({ route, token, body = {}, method = "GET" }) { 10 | let options = { method } 11 | if (method !== "GET") options = Object.assign(options, { body }) 12 | 13 | if (token) 14 | options = Object.assign(options, { 15 | headers: { Authorization: "Bearer " + token }, 16 | }) 17 | 18 | const callApi = async () => { 19 | let res = await fetch(`https://api.spotify.com/v1/${route}`, options) 20 | 21 | if (res.status !== 429) return res 22 | 23 | await sleep(2000) 24 | 25 | return await callApi() 26 | } 27 | 28 | const res = await callApi() 29 | 30 | if (res.status === 204) throw Object.assign(new Error(""), { code: 204 }) 31 | 32 | const json = await res.json().catch(() => { 33 | console.error("API Error:", res.statusText, res.status) 34 | console.error(`https://api.spotify.com/v1/${route}`) 35 | console.error(options) 36 | throw Object.assign(new Error("Something went wrong"), { code: 500 }) 37 | }) 38 | 39 | if (json.error) { 40 | console.error("API Error:", res.statusText, res.status) 41 | console.error(`https://api.spotify.com/v1/${route}`) 42 | const err = json.error 43 | console.error(err) 44 | throw Object.assign(new Error(err.message), { 45 | code: err.status || 500, 46 | }) 47 | } 48 | 49 | return json 50 | } 51 | -------------------------------------------------------------------------------- /apps/web/src/components/layout/Items/LibraryItem.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 55 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/top/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get( 3 | "", 4 | { 5 | schema: { 6 | response: { 7 | 200: { 8 | type: "array", 9 | items: { 10 | type: "object", 11 | properties: { 12 | username: { type: "string" }, 13 | avatar: { type: "string" }, 14 | display_name: { type: "string" }, 15 | lastLogin: { type: "string", format: "date-time" }, 16 | listened: { 17 | type: "object", 18 | properties: { 19 | count: { type: "number" }, 20 | time: { type: "number" }, 21 | }, 22 | }, 23 | }, 24 | }, 25 | }, 26 | }, 27 | tags: ["users"], 28 | }, 29 | }, 30 | async function (req, reply) { 31 | const top = await fastify.db.User.find( 32 | { 33 | "settings.privacy": "public", 34 | listeningHistory: { $exists: true, $type: "array", $ne: [] }, 35 | }, 36 | { 37 | "listened.count": 1, 38 | "listened.time": { 39 | $round: { 40 | $divide: ["$listened.time", 60], 41 | }, 42 | }, 43 | display_name: 1, 44 | avatar: 1, 45 | lastLogin: 1, 46 | username: "$settings.username", 47 | } 48 | ).lean() 49 | 50 | return reply.send(top) 51 | } 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /apps/web/src/components/base/BaseSelect.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 60 | -------------------------------------------------------------------------------- /apps/web/src/components/base/Spotify/SpotifyCard.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 54 | -------------------------------------------------------------------------------- /apps/api/src/app.js: -------------------------------------------------------------------------------- 1 | import fastify from "fastify" 2 | import autoLoad from "@fastify/autoload" 3 | import { fileURLToPath } from "url" 4 | import { dirname, join, resolve } from "path" 5 | import dotenv from "dotenv" 6 | 7 | const __dirname = dirname(fileURLToPath(import.meta.url)) 8 | 9 | dotenv.config({ path: resolve(__dirname, "../../../.env") }) 10 | 11 | const app = fastify() 12 | 13 | // plugins 14 | 15 | app.register(import("@fastify/cors"), { 16 | origin: process.env.CLIENT_URI || "http://localhost:3000", 17 | credentials: true, 18 | }) 19 | 20 | app.register(import("@fastify/compress")) 21 | 22 | if (process.env.NODE_ENV != "production") { 23 | app.register(import("@fastify/swagger"), { 24 | routePrefix: "/docs", 25 | swagger: { 26 | info: { 27 | title: "measurify", 28 | description: "measurify API documentation", 29 | }, 30 | }, 31 | uiConfig: { 32 | deepLinking: true, 33 | displayRequestDuration: true, 34 | "syntaxHighlight.theme": "nord", 35 | docExpansion: "none", 36 | }, 37 | exposeRoute: true, 38 | }) 39 | } 40 | 41 | // custom plugins 42 | app.register(autoLoad, { dir: join(__dirname, "plugins") }) 43 | 44 | // schemas 45 | app.register(autoLoad, { dir: join(__dirname, "schema") }) 46 | 47 | // routes 48 | app.register(autoLoad, { 49 | dir: join(__dirname, "routes"), 50 | routeParams: true, 51 | autoHooks: true, 52 | cascadeHooks: true, 53 | }) 54 | 55 | // for ape moments 56 | process.on("unhandledRejection", (error) => 57 | console.error(`Unhandled - ${error}`) 58 | ) 59 | 60 | export default app 61 | -------------------------------------------------------------------------------- /apps/web/windi.config.js: -------------------------------------------------------------------------------- 1 | import { BREAKPOINTS } from "./src/config" 2 | 3 | export default { 4 | darkMode: "class", 5 | theme: { 6 | screens: BREAKPOINTS, 7 | variants: { 8 | extend: { 9 | opacity: ["disabled"], 10 | translate: ["motion-safe"], 11 | scale: ["active", "group-hover"], 12 | }, 13 | }, 14 | extend: { 15 | colors: { 16 | primary: "#1ed760", 17 | "secondary-darkest": "#000000", 18 | "secondary-darker": "#121212", 19 | "secondary-dark": "#181818", 20 | secondary: "#333333", 21 | "secondary-light": "#333333", 22 | "secondary-lighter": "#ABABAB", 23 | "secondary-lightest": "#BFBFBF", 24 | }, 25 | spacing: { 26 | 14: "3.5rem", 27 | 22: "5.5rem", 28 | 72: "18rem", 29 | 200: "50rem", 30 | half: "50vh", 31 | }, 32 | width: { 33 | "0.5/10": "5%", 34 | "1/10": "10%", 35 | "1.5/10": "15%", 36 | "2/10": "20%", 37 | "2.5/10": "25%", 38 | "3/10": "30%", 39 | "3.5/10": "35%", 40 | "4/10": "40%", 41 | "4.5/10": "45%", 42 | "7/10": "70%", 43 | }, 44 | }, 45 | }, 46 | plugins: [ 47 | require("windicss/plugin/line-clamp"), 48 | require("windicss/plugin/typography"), 49 | require("windicss/plugin/filters"), 50 | ], 51 | shortcuts: { 52 | "default-border": "border-secondary-dark", 53 | "default-focus": 54 | "outline-none focus:outline-none focus-visible:ring-1.5 focus:ring-inset focus:ring-secondary", 55 | }, 56 | } 57 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/_username/player/currently-playing/index.js: -------------------------------------------------------------------------------- 1 | import { formatTrack } from "#src/utils/index.js" 2 | 3 | export default async function (fastify) { 4 | fastify.addHook("onSend", async (req, reply) => { 5 | if (reply.statusCode === 200) reply.removeHeader("Cache-Control") 6 | }) 7 | 8 | fastify.get( 9 | "", 10 | { 11 | schema: { 12 | params: { 13 | type: "object", 14 | required: ["username"], 15 | properties: { username: { type: "string" } }, 16 | }, 17 | response: { 18 | 200: { 19 | type: "object", 20 | properties: { 21 | ...fastify.getSchema("track").properties, 22 | duration_ms: { type: "number" }, 23 | played_at: { type: "string", format: "date" }, 24 | }, 25 | }, 26 | }, 27 | tags: ["user"], 28 | }, 29 | preHandler: [fastify.getUserInfo], 30 | }, 31 | async function (req, reply) { 32 | const user = req.userInfo 33 | 34 | if (user.leaved) 35 | return reply 36 | .code(403) 37 | .send({ message: `Currently not available for ${user.username}` }) 38 | 39 | const currentPlayer = await fastify 40 | .spotifyAPI({ 41 | route: `me/player/currently-playing?market=${user.country}`, 42 | token: user.token, 43 | }) 44 | .catch(() => reply.code(204).send()) 45 | 46 | if (!currentPlayer.item) return reply.code(204).send() 47 | 48 | return reply.send(formatTrack(currentPlayer.item)) 49 | } 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /apps/web/src/views/Profile/Following.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 54 | -------------------------------------------------------------------------------- /apps/web/src/views/Profile/Followers.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 54 | -------------------------------------------------------------------------------- /apps/web/vite.config.js: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from "path" 2 | import { fileURLToPath } from "url" 3 | import { defineConfig } from "vite" 4 | import { VitePWA } from "vite-plugin-pwa" 5 | import Vue from "@vitejs/plugin-vue" 6 | import Windi from "vite-plugin-windicss" 7 | import Components from "unplugin-vue-components/vite" 8 | 9 | const _dirname = dirname(fileURLToPath(import.meta.url)) 10 | 11 | export default defineConfig({ 12 | resolve: { 13 | alias: { 14 | "@": resolve(_dirname, "./src"), 15 | }, 16 | }, 17 | server: { 18 | port: 3000, 19 | }, 20 | preview: { 21 | port: 3000, 22 | }, 23 | plugins: [ 24 | Vue(), 25 | Windi(), 26 | Components({ 27 | dirs: ["src/components"], 28 | extensions: ["vue"], 29 | deep: true, 30 | dts: true, 31 | }), 32 | VitePWA({ 33 | includeAssets: ["favicon.ico", "robots.txt", "apple-touch-icon.png"], 34 | registerType: "autoUpdate", 35 | workbox: { 36 | cleanupOutdatedCaches: false, 37 | sourcemap: true, 38 | }, 39 | manifest: { 40 | name: "measurify", 41 | short_name: "measurify", 42 | description: "Measure your listening history", 43 | theme_color: "#121212", 44 | background_color: "#121212", 45 | display: "standalone", 46 | icons: [ 47 | { 48 | src: "icon-192.png", 49 | sizes: "192x192", 50 | type: "image/png", 51 | }, 52 | { 53 | src: "icon-512.png", 54 | sizes: "512x512", 55 | type: "image/png", 56 | }, 57 | ], 58 | }, 59 | }), 60 | ], 61 | }) 62 | -------------------------------------------------------------------------------- /apps/api/src/includes/cron-workers/tokens.js: -------------------------------------------------------------------------------- 1 | import { URLSearchParams } from "url" 2 | import fetch from "node-fetch" 3 | import User from "#src/models/User.js" 4 | import forAllUsers from "#src/includes/forAllUsers.js" 5 | import { timeDiff } from "#src/utils/index.js" 6 | 7 | export async function refreshTokens() { 8 | await forAllUsers({ operation: "tokens" }, rewriteTokens) 9 | } 10 | 11 | const MONTH = 60 * 60 * 24 * 30 12 | 13 | async function rewriteTokens({ tokens, _id, lastLogin }) { 14 | const filter = { _id } 15 | 16 | if (timeDiff(lastLogin, new Date()) > 3 * MONTH) { 17 | await deactivateProfile(filter) 18 | return 19 | } 20 | 21 | const params = new URLSearchParams() 22 | params.append("grant_type", "refresh_token") 23 | params.append("refresh_token", tokens.refreshToken) 24 | 25 | const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID 26 | const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET 27 | 28 | const body = await fetch(`https://accounts.spotify.com/api/token`, { 29 | method: "POST", 30 | body: params, 31 | headers: { 32 | Authorization: 33 | "Basic " + 34 | Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"), 35 | }, 36 | }).then((res) => res.json()) 37 | 38 | if (body.error) { 39 | if (body.error === "invalid_grant") { 40 | await deactivateProfile(filter) 41 | } 42 | throw new Error(body.error.message || body.error) 43 | } 44 | 45 | const update = { "tokens.token": body.access_token } 46 | await User.updateOne(filter, update) 47 | } 48 | 49 | const deactivateProfile = (filter) => { 50 | return User.updateOne(filter, { 51 | "tokens.refreshToken": "", 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /apps/api/src/routes/info/stats/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.addHook("onSend", async (request, reply) => { 3 | if (reply.statusCode === 200) 4 | reply.header("Cache-Control", "public, max-age=30") 5 | }) 6 | fastify.get( 7 | "", 8 | { 9 | schema: { 10 | response: { 11 | 200: { 12 | type: "object", 13 | properties: { 14 | users: { 15 | type: "object", 16 | properties: { 17 | total: { type: "number" }, 18 | active: { type: "number" }, 19 | inactive: { type: "number" }, 20 | }, 21 | }, 22 | tracks: { type: "number" }, 23 | albums: { type: "number" }, 24 | artists: { type: "number" }, 25 | }, 26 | }, 27 | }, 28 | tags: ["other"], 29 | }, 30 | }, 31 | async function (req, reply) { 32 | const [users, activeUsers, tracks, albums, artists] = await Promise.all([ 33 | fastify.db.User.estimatedDocumentCount(), 34 | fastify.db.User.find( 35 | { "tokens.refreshToken": { $ne: "" } }, 36 | "_id" 37 | ).countDocuments(), 38 | fastify.db.Track.estimatedDocumentCount(), 39 | fastify.db.Album.estimatedDocumentCount(), 40 | fastify.db.Artist.estimatedDocumentCount(), 41 | ]) 42 | return reply.send({ 43 | users: { 44 | total: users, 45 | active: activeUsers, 46 | inactive: users - activeUsers, 47 | }, 48 | tracks, 49 | albums, 50 | artists, 51 | }) 52 | } 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /apps/web/src/components/base/Notifications/Notifications.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 49 | 50 | 55 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/_username/library/tracks/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get( 3 | "", 4 | { 5 | schema: { 6 | params: { 7 | type: "object", 8 | required: ["username"], 9 | properties: { username: { type: "string" } }, 10 | }, 11 | querystring: { 12 | type: "object", 13 | properties: { 14 | range: { type: "number", minimum: 1, maximum: 50, default: 20 }, 15 | page: { type: "number", minimum: 1, default: 1 }, 16 | firstDate: { type: "string", format: "date" }, 17 | lastDate: { type: "string", format: "date" }, 18 | }, 19 | }, 20 | response: { 21 | 200: { 22 | type: "object", 23 | properties: { 24 | status: { type: "number" }, 25 | pages: { type: "number" }, 26 | tracks: { 27 | type: "array", 28 | items: { 29 | type: "object", 30 | properties: { 31 | ...fastify.getSchema("track").properties, 32 | plays: { type: "number" }, 33 | duration_ms: { type: "number" }, 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | tags: ["user"], 41 | }, 42 | preHandler: [fastify.getUserInfo], 43 | }, 44 | async function (req, reply) { 45 | const { range, page, firstDate, lastDate } = req.query 46 | 47 | const _id = req.userInfo._id 48 | const options = { _id, range, page, firstDate, lastDate } 49 | 50 | const top = await fastify.userTopTracks(options) 51 | 52 | return reply.send(top) 53 | } 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /apps/web/src/App.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 57 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/_username/listening-history/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get( 3 | "", 4 | { 5 | schema: { 6 | params: { 7 | type: "object", 8 | required: ["username"], 9 | properties: { username: { type: "string" } }, 10 | }, 11 | querystring: { 12 | type: "object", 13 | properties: { 14 | page: { type: "number", minimum: 1, default: 1 }, 15 | range: { type: "number", minimum: 1, default: 15 }, 16 | search: { type: "string", default: "" }, 17 | }, 18 | }, 19 | response: { 20 | 200: { 21 | type: "object", 22 | required: ["pages", "history"], 23 | properties: { 24 | status: { type: "number" }, 25 | pages: { type: "number" }, 26 | history: { 27 | type: "array", 28 | items: { 29 | type: "object", 30 | properties: { 31 | ...fastify.getSchema("track").properties, 32 | duration_ms: { type: "number" }, 33 | played_at: { type: "string", format: "date-time" }, 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | tags: ["user"], 41 | }, 42 | preHandler: [fastify.getUserInfo], 43 | }, 44 | async function (req, reply) { 45 | const { search, range, page } = req.query 46 | const user = req.userInfo 47 | 48 | const history = await fastify.userListeningHistory({ 49 | _id: user._id, 50 | range, 51 | page, 52 | search, 53 | }) 54 | 55 | return reply.send(history) 56 | } 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /apps/api/src/plugins/handlers/errorHandler.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import fp from "fastify-plugin" 4 | 5 | const plugin = fp(async function plugin(fastify) { 6 | fastify.setErrorHandler((error, req, reply) => { 7 | const { message, code, validation, name } = error 8 | 9 | if (validation) { 10 | const message = validate(error) 11 | return reply.code(400).send({ message }) 12 | } 13 | 14 | if (code === "ETIMEDOUT") 15 | return reply.code(503).send({ message: "Try again later" }) 16 | 17 | if (code && typeof code === "number" && code > 200 && code < 600) 18 | return reply.code(code).send({ message }) 19 | 20 | switch (name) { 21 | case "JsonWebTokenError": 22 | return reply.code(400).send({ message, status: 400 }) 23 | case "FetchError": 24 | return reply.code(400).send({ message, status: 400 }) 25 | case "MongooseError": 26 | console.error(error) 27 | reply 28 | .code(503) 29 | .header("Retry-After", 3000) 30 | .send({ message: "Try again later", status: 503 }) 31 | break 32 | default: 33 | console.error(error) 34 | reply 35 | .status(500) 36 | .send({ message: "Something went wrong!", status: 500 }) 37 | break 38 | } 39 | }) 40 | }) 41 | 42 | const validate = ({ validationContext, validation }) => { 43 | const result = validation[0] 44 | const errorVar = result.dataPath.substring(1) 45 | const message = `${errorVar} ${result.message}` 46 | 47 | switch (validationContext) { 48 | case "querystring": 49 | return `Invalid query parameters: ${message}` 50 | case "params": 51 | return `Invalid ${errorVar}` 52 | case "body": 53 | return `Invalid body: ${message}` 54 | default: 55 | return "Bad request" 56 | } 57 | } 58 | 59 | export default plugin 60 | -------------------------------------------------------------------------------- /apps/web/src/assets/media/fallback-artist.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /apps/web/src/assets/media/bg.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /apps/web/src/utils/index.js: -------------------------------------------------------------------------------- 1 | import dayjs from "@/dayjs" 2 | 3 | export const notEmpty = (array) => { 4 | return Array.isArray(array) && array.length !== 0 5 | } 6 | 7 | export const debounce = (fn, delay = 0, immediate = false) => { 8 | let timeout 9 | return (...args) => { 10 | if (immediate && !timeout) fn(...args) 11 | clearTimeout(timeout) 12 | 13 | timeout = setTimeout(() => { 14 | fn(...args) 15 | }, delay) 16 | } 17 | } 18 | 19 | export const deepEqual = (object1, object2) => { 20 | const keys1 = Object.keys(object1) 21 | const keys2 = Object.keys(object2) 22 | 23 | if (keys1.length !== keys2.length) { 24 | return false 25 | } 26 | 27 | for (const key of keys1) { 28 | const val1 = object1[key] 29 | const val2 = object2[key] 30 | const areObjects = isObject(val1) && isObject(val2) 31 | if ( 32 | (areObjects && !deepEqual(val1, val2)) || 33 | (!areObjects && val1 !== val2) 34 | ) { 35 | return false 36 | } 37 | } 38 | 39 | return true 40 | } 41 | 42 | export const isObject = (object) => { 43 | return object != null && typeof object === "object" 44 | } 45 | 46 | export const orderByDate = (array, key) => { 47 | return array.sort( 48 | (a, b) => new Date(b[key]).getTime() - new Date(a[key]).getTime() 49 | ) 50 | } 51 | 52 | export const getRealtiveTime = (date) => { 53 | return dayjs(Date.parse(date)).fromNow() 54 | } 55 | 56 | export const getShortRelativeTime = (date) => { 57 | return dayjs(Date.parse(date)).locale("s-en").fromNow(true) 58 | } 59 | 60 | export const formatDate = (date) => { 61 | return dayjs(date).format("MMM D, YYYY") 62 | } 63 | 64 | export const getTwelveHourTime = (time) => { 65 | return dayjs("1/1/1 " + time).format("hh:mm a") 66 | } 67 | 68 | export const formatDateShorter = (date) => { 69 | return dayjs(date).format("MMM D") 70 | } 71 | 72 | export const getDuration = (time) => { 73 | return dayjs.duration(time).format("mm:ss") 74 | } 75 | 76 | export const getDecimals = (number) => { 77 | const [before, after] = `${number}`.split(".") 78 | return { before, after } 79 | } 80 | -------------------------------------------------------------------------------- /apps/web/src/components/layout/Items/FriendItem.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 64 | -------------------------------------------------------------------------------- /apps/web/src/views/Profile/Reports.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 62 | -------------------------------------------------------------------------------- /apps/api/src/routes/settings/post.js: -------------------------------------------------------------------------------- 1 | import User from "#src/models/User.js" 2 | 3 | export default async function (fastify) { 4 | fastify.post( 5 | "", 6 | { 7 | schema: { 8 | body: { 9 | type: "object", 10 | required: ["privacy", "username", "display_name"], 11 | properties: { 12 | privacy: { 13 | type: "string", 14 | pattern: "^(public|private)$", 15 | }, 16 | username: { 17 | type: "string", 18 | maxLength: 20, 19 | minLength: 3, 20 | pattern: 21 | "^(?!.*(?:overview|listening-history|about|profile|top-listeners|account|track))[a-z0-9_-]{3,16}$", 22 | }, 23 | display_name: { type: "string", minLength: 3, maxLength: 30 }, 24 | }, 25 | }, 26 | response: { 27 | "2xx": { 28 | type: "object", 29 | required: ["message"], 30 | properties: { message: { type: "string" } }, 31 | }, 32 | "4xx": { 33 | type: "object", 34 | required: ["message"], 35 | properties: { message: { type: "string" } }, 36 | }, 37 | }, 38 | tags: ["settings"], 39 | }, 40 | }, 41 | async function (req, reply) { 42 | const _id = req.user.id 43 | 44 | const { privacy, username, display_name } = req.body 45 | 46 | const user = await User.findOne( 47 | { "settings.username": username }, 48 | "_id" 49 | ).lean() 50 | 51 | if (user && user._id !== _id) 52 | throw this.error("This username is already taken", 403) 53 | 54 | const updateResult = await User.updateOne( 55 | { _id }, 56 | { settings: { privacy, username }, display_name } 57 | ) 58 | 59 | if (updateResult.n === 0) throw this.error("User not found", 404) 60 | 61 | if (updateResult.nModified === 0) 62 | throw this.error("Nothing to update", 400) 63 | 64 | return reply.send({ message: "Successfully updated" }) 65 | } 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/_username/reports/genres-timeline/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get( 3 | "", 4 | { 5 | schema: { 6 | params: { 7 | type: "object", 8 | required: ["username"], 9 | properties: { username: { type: "string" } }, 10 | }, 11 | querystring: { 12 | type: "object", 13 | properties: { 14 | firstDate: { type: "string", format: "date" }, 15 | lastDate: { type: "string", format: "date" }, 16 | range: { type: "number", default: 5 }, 17 | }, 18 | }, 19 | response: { 20 | 200: { 21 | type: "array", 22 | items: { 23 | type: "object", 24 | properties: { 25 | genres: { type: "array", items: { type: "string" } }, 26 | date: { type: "string", format: "date-time" }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | tags: ["user"], 32 | }, 33 | preHandler: [fastify.getUserInfo], 34 | }, 35 | async function (req, reply) { 36 | const user = req.userInfo 37 | const { firstDate, lastDate, range } = req.query 38 | 39 | const userRef = fastify.db.User 40 | 41 | if (!firstDate && !lastDate) { 42 | const data = await userRef 43 | .findById(user._id, { genresTimeline: { $slice: range } }) 44 | .lean() 45 | return reply.send(data.genresTimeline) 46 | } 47 | 48 | const agg = getAgg(user._id, firstDate, lastDate) 49 | const [data] = await userRef.aggregate(agg) 50 | 51 | return reply.send(data.genresTimeline) 52 | } 53 | ) 54 | } 55 | 56 | const getAgg = (_id, firstDate, lastDate = new Date()) => { 57 | return [ 58 | { $match: { _id } }, 59 | { 60 | $project: { 61 | genresTimeline: { 62 | $filter: { 63 | input: "$genresTimeline", 64 | as: "item", 65 | cond: { 66 | $and: [ 67 | { $gte: ["$$item.date", new Date(firstDate)] }, 68 | { $lte: ["$$item.date", new Date(lastDate)] }, 69 | ], 70 | }, 71 | }, 72 | }, 73 | }, 74 | }, 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /apps/api/src/plugins/functions/user/top/artists.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | import User from "#src/models/User.js" 3 | 4 | export const artists = async ({ 5 | _id, 6 | range = 10, 7 | page = 1, 8 | firstDate, 9 | lastDate, 10 | }) => { 11 | if (!_id) return { artists: [], pages: 1 } 12 | 13 | const agg = [ 14 | { $match: { _id } }, 15 | { $project: { listeningHistory: 1 } }, 16 | { $unwind: { path: "$listeningHistory" } }, 17 | ] 18 | 19 | if (firstDate) 20 | agg.push({ 21 | $match: { "listeningHistory.played_at": { $gte: new Date(firstDate) } }, 22 | }) 23 | 24 | if (lastDate) 25 | agg.push({ 26 | $match: { "listeningHistory.played_at": { $lte: new Date(lastDate) } }, 27 | }) 28 | 29 | agg.push( 30 | { 31 | $lookup: { 32 | from: "tracks", 33 | localField: "listeningHistory.track", 34 | foreignField: "_id", 35 | as: "tracks", 36 | }, 37 | }, 38 | { 39 | $lookup: { 40 | from: "artists", 41 | localField: "tracks.artists", 42 | foreignField: "_id", 43 | as: "artists", 44 | }, 45 | }, 46 | { $unwind: { path: "$artists" } }, 47 | { 48 | $group: { 49 | _id: { 50 | id: "$artists._id", 51 | name: "$artists.name", 52 | image: "$artists.images.mediumQuality", 53 | }, 54 | plays: { $sum: 1 }, 55 | }, 56 | }, 57 | { 58 | $addFields: { 59 | id: "$_id.id", 60 | name: "$_id.name", 61 | image: "$_id.image", 62 | }, 63 | }, 64 | { $sort: { plays: -1, name: 1 } }, 65 | { 66 | $group: { 67 | _id: null, 68 | artists: { 69 | $push: { id: "$id", name: "$name", image: "$image", plays: "$plays" }, 70 | }, 71 | count: { $sum: 1 }, 72 | }, 73 | }, 74 | { 75 | $project: { 76 | count: { $ceil: { $divide: ["$count", range] } }, 77 | artists: { $slice: ["$artists", (page - 1) * range, range] }, 78 | }, 79 | } 80 | ) 81 | 82 | const [res] = await User.aggregate(agg) 83 | 84 | return { artists: res?.artists || [], pages: res?.count || 1 } 85 | } 86 | 87 | export default fp(async (fastify) => 88 | fastify.decorate("userTopArtists", artists) 89 | ) 90 | -------------------------------------------------------------------------------- /apps/api/src/plugins/functions/favouriteTracks.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | 3 | const plugin = fp(async function plugin(fastify) { 4 | fastify.decorate("favouriteTracks", ({ _id, filterID, type }) => { 5 | const agg = [ 6 | { $match: { _id } }, 7 | { $project: { listeningHistory: 1 } }, 8 | { $unwind: { path: "$listeningHistory" } }, 9 | { 10 | $group: { 11 | _id: "$listeningHistory.track", 12 | plays: { $sum: 1 }, 13 | lastPlayedAt: { $max: "$listeningHistory.played_at" }, 14 | }, 15 | }, 16 | { 17 | $lookup: { 18 | from: "tracks", 19 | localField: "_id", 20 | foreignField: "_id", 21 | as: "tracks", 22 | }, 23 | }, 24 | ] 25 | 26 | if (type === "album") agg.push({ $match: { "tracks.album": filterID } }) 27 | if (type === "artist") agg.push({ $match: { "tracks.artists": filterID } }) 28 | 29 | agg.push( 30 | { 31 | $lookup: { 32 | from: "albums", 33 | localField: "tracks.album", 34 | foreignField: "_id", 35 | as: "albums", 36 | }, 37 | }, 38 | { 39 | $lookup: { 40 | from: "artists", 41 | localField: "tracks.artists", 42 | foreignField: "_id", 43 | as: "artists", 44 | }, 45 | }, 46 | { 47 | $addFields: { 48 | id: "$_id", 49 | name: { $first: "$tracks.name" }, 50 | duration_ms: { $first: "$tracks.duration_ms" }, 51 | image: { $first: "$tracks.images.mediumQuality" }, 52 | album: { 53 | id: { $first: "$albums._id" }, 54 | name: { $first: "$albums.name" }, 55 | image: { $first: "$albums.images.lowQuality" }, 56 | }, 57 | artists: { 58 | $map: { 59 | input: "$artists", 60 | as: "artist", 61 | in: { 62 | id: "$$artist._id", 63 | name: "$$artist.name", 64 | image: "$$artist.images.lowQuality", 65 | }, 66 | }, 67 | }, 68 | }, 69 | }, 70 | { $sort: { plays: -1, name: 1 } } 71 | ) 72 | 73 | return fastify.db.User.aggregate(agg) 74 | }) 75 | }) 76 | 77 | export default plugin 78 | -------------------------------------------------------------------------------- /apps/api/src/plugins/functions/user/top/albums.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | import User from "#src/models/User.js" 3 | 4 | export const albums = async ({ 5 | _id, 6 | range = 10, 7 | page = 1, 8 | firstDate, 9 | lastDate, 10 | }) => { 11 | if (!_id) return { albums: [], pages: 1 } 12 | 13 | const agg = [ 14 | { $match: { _id } }, 15 | { $project: { listeningHistory: 1 } }, 16 | { $unwind: { path: "$listeningHistory" } }, 17 | ] 18 | 19 | if (firstDate) 20 | agg.push({ 21 | $match: { "listeningHistory.played_at": { $gte: new Date(firstDate) } }, 22 | }) 23 | 24 | if (lastDate) 25 | agg.push({ 26 | $match: { "listeningHistory.played_at": { $lte: new Date(lastDate) } }, 27 | }) 28 | 29 | agg.push( 30 | { 31 | $lookup: { 32 | from: "tracks", 33 | localField: "listeningHistory.track", 34 | foreignField: "_id", 35 | as: "tracks", 36 | }, 37 | }, 38 | { 39 | $lookup: { 40 | from: "albums", 41 | localField: "tracks.album", 42 | foreignField: "_id", 43 | as: "albums", 44 | }, 45 | }, 46 | { 47 | $addFields: { 48 | id: { $first: "$tracks.album" }, 49 | name: { $first: "$albums.name" }, 50 | image: { $first: "$albums.images.mediumQuality" }, 51 | }, 52 | }, 53 | { 54 | $group: { 55 | _id: { 56 | id: "$id", 57 | name: "$name", 58 | image: "$image", 59 | album: { $first: "$tracks.album" }, 60 | }, 61 | plays: { $sum: 1 }, 62 | }, 63 | }, 64 | { 65 | $addFields: { 66 | id: "$_id.id", 67 | name: "$_id.name", 68 | image: "$_id.image", 69 | }, 70 | }, 71 | { $sort: { plays: -1, name: 1 } }, 72 | { 73 | $group: { 74 | _id: null, 75 | albums: { $push: "$$ROOT" }, 76 | count: { $sum: 1 }, 77 | }, 78 | }, 79 | { 80 | $project: { 81 | count: { $ceil: { $divide: ["$count", range] } }, 82 | albums: { $slice: ["$albums", (page - 1) * range, range] }, 83 | }, 84 | } 85 | ) 86 | const [res] = await User.aggregate(agg) 87 | 88 | return { albums: res?.albums || [], pages: res?.count || 1 } 89 | } 90 | 91 | export default fp(async (fastify) => fastify.decorate("userTopAlbums", albums)) 92 | -------------------------------------------------------------------------------- /apps/web/src/components/base/BaseImg.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 90 | -------------------------------------------------------------------------------- /apps/api/src/includes/cron-workers/recentlyPlayed.js: -------------------------------------------------------------------------------- 1 | import User from "#src/models/User.js" 2 | import api from "#src/includes/api.js" 3 | import forAllUsers from "#src/includes/forAllUsers.js" 4 | 5 | import { 6 | addArtists, 7 | addAlbums, 8 | addTracks, 9 | } from "#src/includes/cron-workers/historyParser/index.js" 10 | 11 | export async function parseHistory() { 12 | await forAllUsers({ operation: "history" }, parseNewTracks) 13 | } 14 | 15 | export async function parseNewTracks(user, limit = 10) { 16 | const newListened = [] 17 | 18 | const listenedTracks = await api({ 19 | route: `me/player/recently-played?limit=${limit}`, 20 | token: user.tokens.token, 21 | }) 22 | 23 | if (listenedTracks.error) throw new Error(listenedTracks.error.message) 24 | if (!listenedTracks.items.length) return 25 | 26 | if (!user.listeningHistory || !user.listeningHistory.length) { 27 | newListened.push(...listenedTracks.items) 28 | } else { 29 | const lastPlayedAt = user.listeningHistory[0].played_at 30 | 31 | listenedTracks.items.forEach((item) => { 32 | if (Date.parse(lastPlayedAt.toISOString()) < Date.parse(item.played_at)) 33 | newListened.push(item) 34 | }) 35 | } 36 | 37 | if (!newListened.length) return 38 | 39 | const artistIds = newListened 40 | .map(({ track }) => [ 41 | ...track.artists.map((artist) => artist.id), 42 | ...track.album.artists.map((artist) => artist.id), 43 | ]) 44 | .flat(1) 45 | 46 | const albumIds = newListened.map(({ track }) => track.album.id) 47 | const tracks = newListened.map(({ track }) => track) 48 | 49 | await Promise.all([ 50 | addArtists(artistIds, user.tokens.token), 51 | addAlbums(albumIds, user.tokens.token), 52 | addTracks(tracks), 53 | updateHistory(newListened, user._id), 54 | ]) 55 | } 56 | 57 | const updateHistory = async (newItems, _id) => { 58 | await User.updateOne( 59 | { _id }, 60 | { 61 | $inc: { 62 | "listened.count": newItems.length, 63 | "listened.time": 64 | newItems.reduce((acc, curr) => acc + curr.track.duration_ms, 0) / 65 | 1000, 66 | }, 67 | 68 | $push: { 69 | listeningHistory: { 70 | $each: newItems.map((item) => ({ 71 | track: item.track.id, 72 | played_at: item.played_at, 73 | })), 74 | $position: 0, 75 | }, 76 | }, 77 | } 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /apps/web/src/components/base/Modal.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 72 | -------------------------------------------------------------------------------- /apps/api/src/routes/search/index.js: -------------------------------------------------------------------------------- 1 | import { addImage, formatTrack } from "#src/utils/index.js" 2 | 3 | export default async function (fastify) { 4 | fastify.get( 5 | "", 6 | { 7 | schema: { 8 | query: { 9 | type: "object", 10 | required: ["search"], 11 | properties: { 12 | search: { type: "string", minLength: 1 }, 13 | limit: { type: "number", minimum: 3, default: 10 }, 14 | page: { type: "number", minimum: 1, default: 1 }, 15 | }, 16 | }, 17 | response: { 18 | 200: { 19 | type: "object", 20 | properties: { 21 | albums: { $ref: "albums#" }, 22 | artists: { $ref: "entities#" }, 23 | tracks: { $ref: "tracks#" }, 24 | users: { 25 | type: "array", 26 | items: { 27 | type: "object", 28 | properties: { 29 | username: { type: "string" }, 30 | display_name: { type: "string" }, 31 | avatar: { type: "string" }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | }, 37 | }, 38 | tags: ["other"], 39 | }, 40 | }, 41 | async function (req, reply) { 42 | const { search, limit, page } = req.query 43 | 44 | const id = await fastify.getId(req) 45 | const [token, country] = await Promise.all([ 46 | fastify.getRandomToken(), 47 | fastify.getCountry(id), 48 | ]) 49 | 50 | const query = new RegExp( 51 | `.*${search.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&")}.*` 52 | ) 53 | 54 | const [res, users] = await Promise.all([ 55 | fastify.spotifyAPI({ 56 | route: `search?q=${search}&type=track,artist,album&market=${country}&limit=${limit}&offset=${ 57 | (page - 1) * limit 58 | }`, 59 | token, 60 | }), 61 | fastify.db.User.find( 62 | { 63 | "settings.username": { $regex: query, $options: "i" }, 64 | "settings.privacy": "public", 65 | }, 66 | { username: "$settings.username", display_name: 1, avatar: 1 } 67 | ).lean(), 68 | ]) 69 | 70 | return reply.send({ 71 | albums: res.albums.items.map(addImage), 72 | artists: res.artists.items.map(addImage), 73 | tracks: res.tracks.items.map(formatTrack), 74 | users, 75 | }) 76 | } 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/_username/followers/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get( 3 | "", 4 | { 5 | schema: { 6 | params: { 7 | type: "object", 8 | required: ["username"], 9 | properties: { username: { type: "string" } }, 10 | }, 11 | response: { 12 | 200: { 13 | type: "array", 14 | items: { 15 | type: "object", 16 | properties: { 17 | username: { type: "string" }, 18 | avatar: { type: "string" }, 19 | display_name: { type: "string" }, 20 | private: { type: "boolean" }, 21 | lastTrack: { $ref: "entity#" }, 22 | lastPlayed: { type: "string", format: "date-time" }, 23 | lastLogin: { type: "string", format: "date-time" }, 24 | }, 25 | }, 26 | }, 27 | }, 28 | tags: ["user"], 29 | }, 30 | }, 31 | async function (req, reply) { 32 | const { username } = req.params 33 | 34 | const user = await fastify.db.User.findOne( 35 | { "settings.username": username }, 36 | "followers settings.privacy" 37 | ) 38 | .populate({ 39 | path: "followers", 40 | select: { 41 | listeningHistory: { $first: "$listeningHistory" }, 42 | lastPlayed: { $first: "$listeningHistory.played_at" }, 43 | avatar: 1, 44 | display_name: 1, 45 | refreshToken: "$tokens.refreshToken", 46 | lastLogin: "$lastLogin", 47 | username: "$settings.username", 48 | privacy: "$settings.privacy", 49 | }, 50 | populate: { 51 | path: "listeningHistory.track", 52 | select: { 53 | id: "$_id", 54 | name: 1, 55 | }, 56 | }, 57 | }) 58 | .lean() 59 | 60 | if (!user) return reply.code(404).send({ message: "User not found" }) 61 | 62 | const isPrivate = user.settings.privacy === "private" 63 | const requestorID = await fastify.getId(req) 64 | if (isPrivate && user._id !== requestorID) 65 | return reply.code(403).send({ message: "Private profile" }) 66 | 67 | return reply.send( 68 | user.followers?.map((user) => ({ 69 | ...user, 70 | inactive: user.refreshToken === "", 71 | private: user.privacy === "private", 72 | lastTrack: user.listeningHistory?.track, 73 | })) || [] 74 | ) 75 | } 76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /apps/api/src/includes/cron-workers/historyParser/albums.js: -------------------------------------------------------------------------------- 1 | import Album from "#src/models/Album.js" 2 | import api from "#src/includes/api.js" 3 | import parseFeatures from "#src/includes/parseAudioFeatures.js" 4 | import getRandomToken from "#src/includes/getRandomToken.js" 5 | import { arrLastEl, multipleRequests } from "#src/utils/index.js" 6 | 7 | export default async (albums, token) => { 8 | const uniqueAlbums = [...new Set(albums)] 9 | 10 | const existingAlbums = await Album.find( 11 | { _id: { $in: uniqueAlbums } }, 12 | "_id" 13 | ).lean() 14 | 15 | const newAlbums = uniqueAlbums.filter( 16 | (id) => !existingAlbums.find((existingAlbum) => id === existingAlbum._id) 17 | ) 18 | 19 | if (!newAlbums.length) return 20 | 21 | const responses = await multipleRequests(newAlbums, (chunk) => 22 | api({ route: `albums?ids=${chunk}`, token }) 23 | ) 24 | 25 | const fullInfo = responses.map((res) => res.albums).flat(1) 26 | 27 | const bulk = fullInfo.map((album) => createAlbumBulk(album)) 28 | 29 | await Album.bulkWrite(bulk) 30 | } 31 | 32 | const createAlbumBulk = (album) => ({ 33 | updateOne: { 34 | filter: { _id: album.id }, 35 | update: { 36 | images: { 37 | highQuality: album.images[0]?.url, 38 | mediumQuality: album.images[1]?.url || album.images[0]?.url, 39 | lowQuality: arrLastEl(album.images)?.url, 40 | }, 41 | name: album.name, 42 | genres: album.genres, 43 | release_date: album.release_date, 44 | total_tracks: album.total_tracks, 45 | label: album.label, 46 | }, 47 | upsert: true, 48 | }, 49 | }) 50 | 51 | export const addAlbum = async (albumID, token) => { 52 | const usableToken = token || (await getRandomToken()) 53 | 54 | const album = await api({ 55 | route: `albums/${albumID}`, 56 | token: usableToken, 57 | }) 58 | 59 | const audioFeatures = await parseFeatures( 60 | album.tracks.items.map(({ id }) => id), 61 | usableToken 62 | ) 63 | 64 | audioFeatures.popularity = album.popularity / 100 65 | 66 | const images = album.images 67 | const newItem = { 68 | images: { 69 | highQuality: images[0]?.url, 70 | mediumQuality: images[1]?.url || images[0]?.url, 71 | lowQuality: arrLastEl(album.images)?.url, 72 | }, 73 | name: album.name, 74 | audioFeatures, 75 | genres: album.genres, 76 | release_date: album.release_date, 77 | total_tracks: album.total_tracks, 78 | label: album.label, 79 | } 80 | 81 | Album.updateOne({ _id: albumID }, newItem, { upsert: true }).then() 82 | newItem._id = albumID 83 | return newItem 84 | } 85 | -------------------------------------------------------------------------------- /apps/api/src/includes/cron-workers/historyParser/artists.js: -------------------------------------------------------------------------------- 1 | import Artist from "#src/models/Artist.js" 2 | import api from "#src/includes/api.js" 3 | import parseFeatures from "#src/includes/parseAudioFeatures.js" 4 | import getRandomToken from "#src/includes/getRandomToken.js" 5 | import { arrLastEl, multipleRequests } from "#src/utils/index.js" 6 | 7 | export default async (artists, token) => { 8 | const uniqueArtists = [...new Set(artists)] 9 | 10 | const existingArtists = await Artist.find( 11 | { _id: { $in: uniqueArtists } }, 12 | "_id" 13 | ).lean() 14 | 15 | const newArtists = uniqueArtists.filter( 16 | (id) => !existingArtists.find((existingArtist) => id === existingArtist._id) 17 | ) 18 | 19 | if (!newArtists.length) return 20 | 21 | const responses = await multipleRequests(newArtists, (chunk) => 22 | api({ route: `artists?ids=${chunk}`, token }) 23 | ) 24 | 25 | const fullInfo = responses.map((res) => res.artists).flat(1) 26 | 27 | const bulk = fullInfo.map((artist) => createArtistBulk(artist)) 28 | 29 | await Artist.bulkWrite(bulk) 30 | } 31 | 32 | const createArtistBulk = (artist) => ({ 33 | updateOne: { 34 | filter: { _id: artist.id }, 35 | update: { 36 | name: artist.name, 37 | genres: artist.genres, 38 | images: { 39 | highQuality: artist.images[0]?.url, 40 | mediumQuality: artist.images[1]?.url || artist.images[0]?.url, 41 | lowQuality: arrLastEl(artist.images)?.url, 42 | }, 43 | followers: artist.followers.total, 44 | }, 45 | upsert: true, 46 | }, 47 | }) 48 | 49 | export const addArtist = async (artistID, token) => { 50 | const usableToken = token || (await getRandomToken()) 51 | 52 | const [artist, audioFeatures] = await Promise.all([ 53 | api({ 54 | route: `artists/${artistID}`, 55 | token: usableToken, 56 | }), 57 | api({ 58 | route: `artists/${artistID}/top-tracks?market=US`, 59 | token: usableToken, 60 | }).then(({ tracks }) => 61 | parseFeatures( 62 | tracks.map((track) => track.id), 63 | usableToken 64 | ) 65 | ), 66 | ]) 67 | 68 | audioFeatures.popularity = artist.popularity / 100 69 | 70 | const images = artist.images 71 | 72 | const newItem = { 73 | name: artist.name, 74 | genres: artist.genres, 75 | images: { 76 | highQuality: images[0]?.url, 77 | mediumQuality: images[1]?.url || images[0]?.url, 78 | lowQuality: arrLastEl(images)?.url, 79 | }, 80 | audioFeatures, 81 | followers: artist.followers.total, 82 | } 83 | 84 | Artist.updateOne({ _id: artistID }, newItem, { upsert: true }).then() 85 | 86 | newItem._id = artistID 87 | return newItem 88 | } 89 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/_username/follows/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.addHook("onSend", async (req, reply) => { 3 | reply.removeHeader("Cache-Control") 4 | }) 5 | fastify.get( 6 | "", 7 | { 8 | schema: { 9 | params: { 10 | type: "object", 11 | required: ["username"], 12 | properties: { username: { type: "string" } }, 13 | }, 14 | response: { 15 | 200: { 16 | type: "array", 17 | items: { 18 | type: "object", 19 | properties: { 20 | username: { type: "string" }, 21 | avatar: { type: "string" }, 22 | display_name: { type: "string" }, 23 | private: { type: "boolean" }, 24 | lastTrack: { $ref: "entity#" }, 25 | lastPlayed: { type: "string", format: "date-time" }, 26 | lastLogin: { type: "string", format: "date-time" }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | tags: ["user"], 32 | }, 33 | }, 34 | async function (req, reply) { 35 | const { username } = req.params 36 | 37 | const user = await fastify.db.User.findOne( 38 | { "settings.username": username }, 39 | "follows settings.privacy" 40 | ) 41 | .populate({ 42 | path: "follows", 43 | select: { 44 | listeningHistory: { $first: "$listeningHistory" }, 45 | lastPlayed: { $first: "$listeningHistory.played_at" }, 46 | avatar: 1, 47 | display_name: 1, 48 | refreshToken: "$tokens.refreshToken", 49 | lastLogin: "$lastLogin", 50 | username: "$settings.username", 51 | privacy: "$settings.privacy", 52 | }, 53 | populate: { 54 | path: "listeningHistory.track", 55 | select: { 56 | id: "$_id", 57 | name: 1, 58 | }, 59 | }, 60 | }) 61 | .lean() 62 | 63 | if (!user) return reply.code(404).send({ message: "User not found" }) 64 | 65 | const isPrivate = user.settings.privacy === "private" 66 | const requestorID = await fastify.getId(req) 67 | if (isPrivate && user._id !== requestorID) 68 | return reply.code(403).send({ message: "Private profile" }) 69 | 70 | return reply.send( 71 | user.follows?.map((user) => ({ 72 | ...user, 73 | inactive: user.refreshToken === "", 74 | private: user.privacy === "private", 75 | lastTrack: user.listeningHistory?.track, 76 | })) || [] 77 | ) 78 | } 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /apps/web/src/components/preloaders/HomeFeed.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 92 | -------------------------------------------------------------------------------- /apps/web/src/views/Profile/History.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 96 | -------------------------------------------------------------------------------- /apps/api/src/includes/cron-workers/historyParser/tracks.js: -------------------------------------------------------------------------------- 1 | import Track from "#src/models/Track.js" 2 | import api from "#src/includes/api.js" 3 | import parseFeatures from "#src/includes/parseAudioFeatures.js" 4 | import getRandomToken from "#src/includes/getRandomToken.js" 5 | import { arrLastEl } from "#src/utils/index.js" 6 | import { addArtist } from "#src/includes/cron-workers/historyParser/artists.js" 7 | import { addAlbum } from "#src/includes/cron-workers/historyParser/albums.js" 8 | 9 | export default async (tracks) => { 10 | const trackIds = tracks.map((track) => track.id) 11 | 12 | const uniqueTracks = [...new Set(trackIds)] 13 | 14 | if (!uniqueTracks.length) return 15 | 16 | const existingTracks = await Track.find( 17 | { _id: { $in: uniqueTracks } }, 18 | "_id" 19 | ).lean() 20 | 21 | const newTracks = tracks.filter( 22 | (track) => 23 | !existingTracks.find((existingTrack) => track.id === existingTrack._id) 24 | ) 25 | 26 | if (!newTracks.length) return 27 | 28 | const bulk = newTracks.map((track) => createTrackBulk(track)) 29 | 30 | await Track.bulkWrite(bulk) 31 | } 32 | 33 | const createTrackBulk = (track) => ({ 34 | updateOne: { 35 | filter: { _id: track.id }, 36 | update: { 37 | name: track.name, 38 | duration_ms: track.duration_ms, 39 | album: track.album.id, 40 | release_date: track.album.release_date, 41 | artists: track.artists.map(({ id }) => id), 42 | images: { 43 | highQuality: track.album.images[0]?.url, 44 | mediumQuality: track.album.images[1]?.url || track.album.images[0]?.url, 45 | lowQuality: arrLastEl(track.album.images)?.url, 46 | }, 47 | }, 48 | upsert: true, 49 | }, 50 | }) 51 | 52 | export const addTrack = async (trackID, token) => { 53 | const usableToken = token || (await getRandomToken()) 54 | 55 | const [track, audioFeatures] = await Promise.all([ 56 | api({ 57 | route: `tracks/${trackID}`, 58 | token: usableToken, 59 | }), 60 | parseFeatures([trackID], usableToken), 61 | ]) 62 | 63 | audioFeatures.popularity = track.popularity / 100 64 | 65 | const images = track.album.images 66 | const newItem = { 67 | name: track.name, 68 | duration_ms: track.duration_ms, 69 | album: track.album.id, 70 | artists: track.artists.map(({ id }) => id), 71 | images: { 72 | highQuality: images[0]?.url, 73 | mediumQuality: images[1]?.url || images[0]?.url, 74 | lowQuality: arrLastEl(images)?.url, 75 | }, 76 | audioFeatures, 77 | release_date: track.album.release_date, 78 | } 79 | 80 | const [, album, ...artists] = await Promise.all([ 81 | Track.updateOne({ _id: trackID }, newItem, { upsert: true }), 82 | addAlbum(track.album.id), 83 | ...track.artists.map((artist) => addArtist(artist.id)), 84 | ]) 85 | newItem.album = album 86 | newItem.artists = artists 87 | newItem._id = trackID 88 | 89 | return newItem 90 | } 91 | -------------------------------------------------------------------------------- /apps/web/src/components/layout/App/AppFriends.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 82 | 83 | 100 | -------------------------------------------------------------------------------- /apps/web/src/components/base/AppBar.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 100 | -------------------------------------------------------------------------------- /apps/api/src/plugins/functions/user/top/tracks.js: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin" 2 | import User from "#src/models/User.js" 3 | 4 | export const tracks = async ({ 5 | _id, 6 | range = 10, 7 | page = 1, 8 | firstDate, 9 | lastDate, 10 | }) => { 11 | if (!_id) return { tracks: [], pages: 1 } 12 | 13 | const agg = [ 14 | { $match: { _id } }, 15 | { $project: { listeningHistory: 1 } }, 16 | { $unwind: { path: "$listeningHistory" } }, 17 | ] 18 | 19 | if (firstDate) 20 | agg.push({ 21 | $match: { "listeningHistory.played_at": { $gte: new Date(firstDate) } }, 22 | }) 23 | 24 | if (lastDate) 25 | agg.push({ 26 | $match: { "listeningHistory.played_at": { $lte: new Date(lastDate) } }, 27 | }) 28 | 29 | agg.push( 30 | { 31 | $group: { 32 | _id: "$listeningHistory.track", 33 | plays: { $sum: 1 }, 34 | }, 35 | }, 36 | { $sort: { plays: -1, name: 1 } }, 37 | { 38 | $group: { 39 | _id: "", 40 | listeningHistory: { 41 | $push: { 42 | plays: "$plays", 43 | track: "$_id", 44 | }, 45 | }, 46 | }, 47 | }, 48 | { $addFields: { count: { $size: "$listeningHistory" } } }, 49 | { $unwind: { path: "$listeningHistory" } }, 50 | { $skip: (page - 1) * range }, 51 | { $limit: range }, 52 | { 53 | $lookup: { 54 | from: "tracks", 55 | localField: "listeningHistory.track", 56 | foreignField: "_id", 57 | as: "tracks", 58 | }, 59 | }, 60 | { 61 | $lookup: { 62 | from: "albums", 63 | localField: "tracks.album", 64 | foreignField: "_id", 65 | as: "albums", 66 | }, 67 | }, 68 | { 69 | $lookup: { 70 | from: "artists", 71 | localField: "tracks.artists", 72 | foreignField: "_id", 73 | as: "artists", 74 | }, 75 | }, 76 | { 77 | $addFields: { 78 | id: { $first: "$tracks._id" }, 79 | name: { $first: "$tracks.name" }, 80 | image: { $first: "$tracks.images.lowQuality" }, 81 | duration_ms: { $first: "$tracks.duration_ms" }, 82 | plays: "$listeningHistory.plays", 83 | album: { 84 | id: { $first: "$albums._id" }, 85 | name: { $first: "$albums.name" }, 86 | image: { $first: "$albums.images.lowQuality" }, 87 | }, 88 | artists: { 89 | $map: { 90 | input: "$artists", 91 | as: "artist", 92 | in: { 93 | id: "$$artist._id", 94 | name: "$$artist.name", 95 | image: "$$artist.images.lowQuality", 96 | }, 97 | }, 98 | }, 99 | }, 100 | } 101 | ) 102 | 103 | const tracks = await User.aggregate(agg) 104 | 105 | return { 106 | pages: Math.ceil((tracks[0]?.count || 0) / range) || 1, 107 | tracks, 108 | } 109 | } 110 | 111 | export default fp(async (fastify) => fastify.decorate("userTopTracks", tracks)) 112 | -------------------------------------------------------------------------------- /apps/web/src/components/base/BaseButton.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 116 | 117 | 127 | -------------------------------------------------------------------------------- /apps/web/src/views/Search.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 95 | -------------------------------------------------------------------------------- /apps/web/src/views/Profile/Compatibility.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 94 | -------------------------------------------------------------------------------- /apps/web/src/views/Profile/Library.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 110 | -------------------------------------------------------------------------------- /apps/web/src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from "vue-router" 2 | import { useUserStore } from "@/stores/user" 3 | import { useTitle } from "@vueuse/core" 4 | 5 | const routes = [ 6 | { 7 | path: "/", 8 | name: "home", 9 | component: () => import("@/views/Index.vue"), 10 | }, 11 | { 12 | path: "/track/:trackId", 13 | name: "track", 14 | component: () => import("@/views/Track.vue"), 15 | }, 16 | { 17 | path: "/artist/:artistId", 18 | name: "artist", 19 | component: () => import("@/views/Artist.vue"), 20 | }, 21 | { 22 | path: "/album/:albumId", 23 | name: "album", 24 | component: () => import("@/views/Album.vue"), 25 | }, 26 | { 27 | path: "/:username", 28 | name: "profile", 29 | component: () => import("@/views/Profile.vue"), 30 | redirect: () => { 31 | return { 32 | name: "profile-overview", 33 | } 34 | }, 35 | children: [ 36 | { 37 | path: "", 38 | name: "profile-overview", 39 | component: () => import("@/views/Profile/Overview.vue"), 40 | }, 41 | { 42 | path: "history", 43 | name: "profile-history", 44 | component: () => import("@/views/Profile/History.vue"), 45 | }, 46 | { 47 | path: "compatibility", 48 | name: "profile-compatibility", 49 | component: () => import("@/views/Profile/Compatibility.vue"), 50 | }, 51 | { 52 | path: "following", 53 | name: "profile-following", 54 | component: () => import("@/views/Profile/Following.vue"), 55 | }, 56 | { 57 | path: "followers", 58 | name: "profile-followers", 59 | component: () => import("@/views/Profile/Followers.vue"), 60 | }, 61 | { 62 | path: "library", 63 | name: "profile-library", 64 | component: () => import("@/views/Profile/Library.vue"), 65 | }, 66 | { 67 | path: "reports", 68 | name: "profile-reports", 69 | component: () => import("@/views/Profile/Reports.vue"), 70 | }, 71 | ], 72 | }, 73 | { 74 | path: "/account", 75 | name: "account", 76 | meta: { 77 | title: "Account", 78 | authRequired: true, 79 | }, 80 | component: () => import("@/views/Account.vue"), 81 | }, 82 | { 83 | path: "/search", 84 | name: "search", 85 | meta: { 86 | title: "Search", 87 | }, 88 | component: () => import("@/views/Search.vue"), 89 | }, 90 | { 91 | path: "/leaderboard", 92 | name: "leaderboard", 93 | meta: { 94 | title: "Listeners Leaderboard", 95 | }, 96 | component: () => import("@/views/Leaderboard.vue"), 97 | }, 98 | { 99 | path: "/:pathMatch(.*)*", 100 | redirect: { 101 | name: "home", 102 | }, 103 | }, 104 | ] 105 | 106 | const router = createRouter({ 107 | history: createWebHistory(), 108 | routes, 109 | }) 110 | 111 | router.beforeEach((to, from, next) => { 112 | const user = useUserStore() 113 | const title = useTitle() 114 | if (to.meta.authRequired && !user.isAuthenticated) 115 | return next({ name: "home" }) 116 | next() 117 | if (to.meta.title) { 118 | title.value = to.meta.title 119 | } 120 | }) 121 | 122 | export default router 123 | -------------------------------------------------------------------------------- /apps/web/src/views/Album.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 101 | -------------------------------------------------------------------------------- /apps/api/src/routes/users/_username/reports/hourly-activity/index.js: -------------------------------------------------------------------------------- 1 | export default async function (fastify) { 2 | fastify.get( 3 | "", 4 | { 5 | schema: { 6 | params: { 7 | type: "object", 8 | required: ["username"], 9 | properties: { username: { type: "string" } }, 10 | }, 11 | querystring: { 12 | type: "object", 13 | properties: { 14 | firstDate: { type: "string", format: "date" }, 15 | lastDate: { type: "string", format: "date" }, 16 | }, 17 | }, 18 | response: { 19 | 200: { 20 | type: "array", 21 | items: { 22 | type: "object", 23 | required: ["time", "playtime", "plays"], 24 | properties: { 25 | time: { type: "number" }, 26 | playtime: { type: "number" }, 27 | plays: { type: "number" }, 28 | }, 29 | }, 30 | }, 31 | }, 32 | tags: ["user"], 33 | }, 34 | preHandler: [fastify.getUserInfo], 35 | }, 36 | async function (req, reply) { 37 | const user = req.userInfo 38 | const { firstDate, lastDate } = req.query 39 | const options = { _id: user._id, firstDate, lastDate } 40 | 41 | const activity = await fastify.db.User.aggregate(getAgg(options)) 42 | 43 | const hourlyActivity = await userHourlyActivity(activity) 44 | reply.send(hourlyActivity) 45 | } 46 | ) 47 | 48 | const userHourlyActivity = async (activity) => { 49 | const hourlyActivity = [] 50 | for (let i = 1; i <= 24; i++) { 51 | hourlyActivity.push( 52 | activity.find((hour) => hour.time === i) || { 53 | time: i, 54 | playtime: 0, 55 | plays: 0, 56 | } 57 | ) 58 | } 59 | return hourlyActivity 60 | } 61 | } 62 | 63 | const getAgg = ({ _id, firstDate, lastDate }) => { 64 | const agg = [ 65 | { $match: { _id } }, 66 | { $project: { listeningHistory: 1 } }, 67 | { $unwind: { path: "$listeningHistory" } }, 68 | ] 69 | if (firstDate) 70 | agg.push({ 71 | $match: { 72 | "listeningHistory.played_at": { $gte: new Date(firstDate) }, 73 | }, 74 | }) 75 | if (lastDate) 76 | agg.push({ 77 | $match: { 78 | "listeningHistory.played_at": { $lte: new Date(lastDate) }, 79 | }, 80 | }) 81 | agg.push( 82 | { 83 | $project: { 84 | _id: "$listeningHistory.track", 85 | time: { 86 | $dateToString: { 87 | format: "%H", 88 | date: "$listeningHistory.played_at", 89 | }, 90 | }, 91 | }, 92 | }, 93 | { 94 | $lookup: { 95 | from: "tracks", 96 | localField: "_id", 97 | foreignField: "_id", 98 | as: "tracks", 99 | }, 100 | }, 101 | { 102 | $group: { 103 | _id: "$time", 104 | plays: { $sum: 1 }, 105 | playtime: { $sum: { $first: "$tracks.duration_ms" } }, 106 | }, 107 | }, 108 | { 109 | $project: { 110 | time: { $toInt: "$_id" }, 111 | plays: 1, 112 | playtime: { $round: [{ $divide: ["$playtime", 60000] }, 0] }, 113 | }, 114 | }, 115 | { $sort: { time: -1 } } 116 | ) 117 | return agg 118 | } 119 | -------------------------------------------------------------------------------- /apps/web/src/components/base/TrackRows/TrackRow.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 109 | --------------------------------------------------------------------------------