diff --git a/src/assets/sources/funkwhale-logo.png b/src/assets/sources/funkwhale-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..725dcc8acfcc2165751caf98b6fee964832b4677 Binary files /dev/null and b/src/assets/sources/funkwhale-logo.png differ diff --git a/src/components/FunkwhaleForm.vue b/src/components/FunkwhaleForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..5e5f86771b1875cbfa0c1cbab40f2bf18fda0e2d --- /dev/null +++ b/src/components/FunkwhaleForm.vue @@ -0,0 +1,50 @@ +<template> + + <form @submit.prevent="submit"> + <div class="row"> + <div class="input-field col s12 l6"> + <input v-model="domain" id="domain" placeholder="funkwhale.domain" type="text" class="validate"> + <label class="active" for="domain">Funkwhale domain</label> + </div> + </div> + <button type="submit" class="waves-effect waves-light btn">Authenticate</button> + </form> +</template> + +<script> +import axios from 'axios' + +import {createApp, getAxios, SCOPES} from '@/sources/funkwhale' +import {getBaseUrl} from '@/utils' + +export default { + props: ["source"], + data () { + return { + domain: '', + } + }, + mounted () { + M.updateTextFields(); + }, + methods: { + async submit () { + let instanceUrl = `https://${this.domain}/` + let baseUrl = getBaseUrl(window.location.toString(), this.$router.currentRoute.path) + // create an application + let ax = axios.create({ + baseURL: instanceUrl, + timeout: 3000, + }) + let response = await createApp(ax, {baseUrl}) + const appData = response.data + const redirectUri = encodeURIComponent(`${baseUrl}/connect/funkwhale/callback`) + this.$store.commit('setRecursiveState', {key: "sources.funkwhale.appCredentials", suffix: this.domain, value: appData}) + let params = `response_type=code&scope=${encodeURIComponent(SCOPES)}&redirect_uri=${redirectUri}&state=${this.domain}&client_id=${appData.client_id}` + const authorizeUrl = `${instanceUrl}authorize?${params}` + console.log('Redirecting user...', authorizeUrl) + window.location = authorizeUrl + } + } +} +</script> diff --git a/src/components/Suggestions.vue b/src/components/Suggestions.vue index 241550ee6bc4c0e8f4dddd8a6b911fd75f17be32..422106c19d239f6e6d77c3ece40488c7744ebbec 100644 --- a/src/components/Suggestions.vue +++ b/src/components/Suggestions.vue @@ -43,8 +43,11 @@ <div v-if="isLoading" class="progress"> <div class="indeterminate"></div> </div> - <p> - {{ filteredSuggestions.length }} profiles found with retribute information ({{ allSuggestions.length }} in total) + <p v-if="filters.retributeOnly"> + {{ filteredSuggestions.length }} profiles found with retribute information (<a @click.prevent="filters.retributeOnly = null">show all {{ allSuggestions.length }} profiles</a>) + </p> + <p v-else> + {{ filteredSuggestions.length }} profiles found (<a @click.prevent="filters.retributeOnly = true">restrict to profiles with retribute information</a>) </p> <ul class="collection"> <li class="collection-item avatar" v-for="suggestion in filteredSuggestions" :key="suggestion.fullId"> @@ -97,7 +100,7 @@ export default { retributeProfiles: this.$store.state.cache.retributeProfiles || {}, loadingRetributeProfiles: [], filters: { - retributeOnly: true, + retributeOnly: null, providers: [], }, providers: this.$store.state.cache.providers || [] diff --git a/src/sources/funkwhale.js b/src/sources/funkwhale.js new file mode 100644 index 0000000000000000000000000000000000000000..dd650911b82778af6679a3cb0a39ec380fa84519 --- /dev/null +++ b/src/sources/funkwhale.js @@ -0,0 +1,174 @@ +import axios from 'axios' +import moment from 'moment' +import Form from '../components/FunkwhaleForm.vue' +import get from 'lodash/get' + +import {getAbsoluteUrl} from "@/utils" + +export const SCOPES = "read:profile read:listenings" + +export function listeningsToRetribute (listenings, cache) { + if (!cache.profiles) { + cache.profiles = {} + } + listenings.forEach(f => { + let mbid = f.track.artist.mbid + if (!mbid) { + return + } + const profileId = `musicbrainz:${mbid}` + if (cache.profiles[profileId]) { + cache.profiles[profileId].weight += 1 + } else { + cache.profiles[profileId] = {weight: 1} + } + }); + return cache +} + +export async function createApp(ax, {baseUrl}) { + const payload = { + name: `Retribute ${window.location.hostname}`, + website: baseUrl, + scopes: SCOPES, + redirect_uris: `${baseUrl}/connect/funkwhale/callback` + } + const response = await ax.post('/api/v1/oauth/apps/', payload) + return response +} + + +export default { + id: "funkwhale", + label: "Funkwhale", + url: "https://funkwhale.audio", + description: "Funkwhale servers", + extendedDescription: "Receive suggestions based on your listening history", + imageBackground: "grey lighten-5", + form: Form, + getLogo () { + return require("../assets/sources/funkwhale-logo.png") + }, + authDataToKey ({username, domain}) { + return `funkwhale ${domain} ${username}` + }, + getAvatar (data) { + + if (!data.raw || !data.raw.avatar) { + return null + } + let url = data.raw.avatar.small_square_crop + return getAbsoluteUrl(`https://${data.domain}`, url) + }, + getDisplayName ({username, domain}) { + return `${username}@${domain}` + }, + async handleCallback({query, router, store, baseUrl}) { + const domain = query.state + console.log(`Received connect callback for funkwhale domain ${domain}`) + console.log('Fetching token...') + const app = store.state.sources.funkwhale.appCredentials[domain] + const ax = axios.create() + const payload = { + client_id: app.client_id, + client_secret: app.client_secret, + grant_type: "authorization_code", + code: query.code, + redirect_uri: `${baseUrl}/connect/funkwhale/callback` + } + let data = new FormData() + Object.entries(payload).forEach((e) => { + data.set(e[0], e[1]) + }) + const response = await ax.post(`https://${domain}/api/v1/oauth/token/`, data, {headers: {'Content-Type': 'multipart/form-data'}}) + const token = response.data.access_token + const client = axios.create({ + baseURL: `https://${domain}`, + headers: {'Authorization': `Bearer ${token}`}, + }) + const accountResponse = await client.get('/api/v1/users/users/me/') + const username = `${accountResponse.data.username}@${domain}` + store.commit('setRecursiveState', {key: "sources.funkwhale.appTokens", suffix: username, value: response.data}) + store.commit('addAccount', {source: 'funkwhale', raw: accountResponse.data, username: accountResponse.data.username, domain}) + router.push({path: '/'}) + }, + async fetch ({account, store, results, maxDays}) { + results.status = `Fetching listenings...` + const token = store.state.sources.funkwhale.appTokens[`${account.username}@${account.domain}`].access_token + const client = axios.create({ + baseURL: `https://${account.domain}`, + headers: {'Authorization': `Bearer ${token}`}, + }) + const dateLimit = moment().subtract(maxDays, 'days') + let url = null + let handledListenings = 0 + // results.progress = 0 + // results.progressCount + results.accounts = {} + let cont = true + while (cont) { + let response + if (!url) { + response = await client.get('/api/v1/history/listenings/', {params: {page_size: 100, username: account.username, domain: account.domain}}) + } else { + response = await client.get(url) + } + response.data.results.forEach((l) => { + let date = moment(l.creation_date) + if (date.isBefore(dateLimit)) { + cont = false + return + } + if (account.username != l.user.username) { + // we don't have a ?user= filter on this API, so we exclude listenings by hand :s + return + } + + + handledListenings += 1 + results.progressCount += 1 + let artist = l.track.artist + if (!artist.mbid) { + return + } + let artistId = `musicbrainz:${artist.mbid}` + if (results.accounts[artistId]) { + results.accounts[artistId].weight += 1 + let detail = get(results.accounts[artistId], 'detail', {}) + let mastodonDetail = get(detail, 'funkwhale', {listenings: 0}) + mastodonDetail.listenings += 1 + } else { + results.accounts[artistId] = { + weight: 1, + source: 'musicbrainz', + id: artistId, + avatar: null, + name: artist.name, + url: `https://musicbrainz.org/artist/${artist.mbid}`, + detail: { + funkwhale: { + listenings: 1 + } + } + } + } + // results.progress = Math.min(100, handledListenings / maxFavorites * 100) + }) + if (response.data.next) { + url = response.data.next + } else { + break + } + results.status = `Fetched ${handledListenings} listenings` + } + results.isLoading = false + return results + }, + getDetailMessage (detail) { + if (!detail) { + return + } + let listenings = detail.listenings || 0 + return `Funkwhale: ${listenings} listenings` + } +} diff --git a/src/sources/index.js b/src/sources/index.js index 43e6b9f00d4c72b6fb973cb4b9e9da27717b6294..299f3abac5ba1144e5e014c158178c3bf474f178 100644 --- a/src/sources/index.js +++ b/src/sources/index.js @@ -1,4 +1,5 @@ import Mastodon from "./mastodon" +import Funkwhale from "./funkwhale" import sortBy from "lodash/sortBy" @@ -7,6 +8,7 @@ export default { return sortBy(Object.values(this.sources), ["id"]) }, sources: { + funkwhale: Funkwhale, mastodon: Mastodon } } diff --git a/src/utils.js b/src/utils.js index 0351fbd4860f20c264d792520878970704d97850..633721e12ba94bd2a90defa11779e91bdc38b280 100644 --- a/src/utils.js +++ b/src/utils.js @@ -11,3 +11,14 @@ export function getBaseUrl (windowUrl, currentPath) { } return url } + +export function getAbsoluteUrl (base, relative) { + if (relative.startsWith('http')) { + return relative + } + if (base.endsWith('/') && relative.startsWith('/')) { + relative = relative.slice(1) + } + + return base + relative +}