diff --git a/src/assets/sources/peertube-logo.svg b/src/assets/sources/peertube-logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..b4a609963349ef1b5f06e679d5d6e975b1572f7a --- /dev/null +++ b/src/assets/sources/peertube-logo.svg @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg height="682.68799" viewBox="2799 -911 512 682.688" width="512" xmlns="http://www.w3.org/2000/svg"><g stroke-width="32"><path d="m2799-911v341.344l256-170.656" fill="#211f20"/><path d="m2799-569.656v341.344l256-170.656" fill="#737373"/><path d="m3055-740.344v341.344l256-170.656" fill="#f1680d"/></g></svg> \ No newline at end of file diff --git a/src/components/PeerTubeForm.vue b/src/components/PeerTubeForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..02053605fa97aaffaeaf378ed916920e81f51c3f --- /dev/null +++ b/src/components/PeerTubeForm.vue @@ -0,0 +1,65 @@ +<template> + + <form @submit.prevent="submit"> + <div class="row"> + <div class="input-field col s12 l4"> + <input v-model="domain" id="domain" placeholder="peertube.domain" type="text" class="validate"> + <label class="active" for="domain">PeerTube domain</label> + </div> + <div class="input-field col s12 l4"> + <input v-model="username" id="username" type="text" class="validate"> + <label for="username">Username</label> + </div> + <div class="input-field col s12 l4"> + <input v-model="password" id="password" type="password" class="validate"> + <label for="password">Password</label> + </div> + </div> + <p>You're password is only used to obtain the first authentication token and isn't stored.</p> + <button type="submit" class="waves-effect waves-light btn">Authenticate</button> + </form> +</template> + +<script> +import axios from 'axios' + +import {createApp, getAxios, connect} from '@/sources/peertube' +import {getBaseUrl} from '@/utils' + +export default { + props: ["source"], + data () { + return { + domain: '', + username: '', + password: '', + } + }, + mounted () { + M.updateTextFields(); + }, + methods: { + async submit () { + let instanceUrl = `https://${this.domain}/` + // create an application + let ax = axios.create({ + baseURL: instanceUrl, + timeout: 3000, + }) + let response = await createApp(ax) + const appData = response.data + this.$store.commit( + 'setRecursiveState', + {key: "sources.peertube.appCredentials", suffix: this.domain, value: appData} + ) + await connect({ + domain: this.domain, + router: this.$router, + store: this.$store, + username: this.username, + password: this.password + }) + } + } +} +</script> diff --git a/src/components/Suggestions.vue b/src/components/Suggestions.vue index efb7658ea4d8c189826e6cb7fc1f16a1ce98e797..7e136e4ace37ba6e88f427265cc1492c85867160 100644 --- a/src/components/Suggestions.vue +++ b/src/components/Suggestions.vue @@ -62,7 +62,6 @@ <a v-if="retributeProfiles[suggestion.fullId] === undefined" @click="lookup(suggestion.fullId)" class="secondary-content"><i class="material-icons">search</i></a> <div v-else-if="retributeProfiles[suggestion.fullId]"> <h6>Donation platforms</h6> - <!-- {{ retributeProfiles[suggestion.fullId] }} --> <template v-for="mean in retributeProfiles[suggestion.fullId].means"> <a :href="mean.url" @@ -95,6 +94,7 @@ import config from '@/config' import orderBy from 'lodash/orderBy' import defaults from 'lodash/defaults' import pull from 'lodash/pull' +import merge from 'lodash/merge' import chunk from 'lodash/chunk' import axios from 'axios' @@ -104,7 +104,7 @@ export default { filters = defaults(filters, {providers: [], retributeOnly: true}) return { sources: sources.sources, - maxDays: 60, + maxDays: this.$store.state.cache.maxDays || 60, isLoadingSources: false, isLoadingRetribute: false, results: {}, @@ -171,9 +171,11 @@ export default { Object.keys(r.results.accounts).forEach((key) => { if (aggregated[key]) { aggregated[key].weight += r.results.accounts[key].weight + merge(aggregated[key].detail,r.results.accounts[key].detail) aggregated[key].accounts.push(account) } else { - aggregated[key] = {...r.results.accounts[key], accounts: [account], fullId: key} + aggregated[key] = { + ...r.results.accounts[key], accounts: [account], fullId: key} } }) } @@ -234,7 +236,6 @@ export default { await this.lookups(ids) } this.isLoadingRetribute = false - }, async lookups(ids) { let self = this @@ -308,6 +309,12 @@ export default { }, deep: true }, + "maxDays": { + handler (v) { + this.$store.commit('setRecursiveState', {key: 'cache.maxDays', value: this.maxDays}) + }, + deep: true + }, providers: { handler (v) { this.$store.commit('setRecursiveState', {key: 'cache.providers', value: this.providers}) diff --git a/src/sources/index.js b/src/sources/index.js index 5903c083433ac26d8e0fc3bf7433a09db9030d33..3a3e3a2ca7c58240b4b5f2e1483c034ff6a1e640 100644 --- a/src/sources/index.js +++ b/src/sources/index.js @@ -1,5 +1,6 @@ import Mastodon from "./mastodon" import Funkwhale from "./funkwhale" +import PeerTube from "./peertube" import sortBy from "lodash/sortBy" @@ -12,6 +13,7 @@ export default { sources: { funkwhale: Funkwhale, mastodon: Mastodon, + peertube: PeerTube, musicbrainz: { defaultAvatarIcon: "music_note", connect: false diff --git a/src/sources/mastodon.js b/src/sources/mastodon.js index beed072daed5abfdd49c60411131716af12af49d..ad3f9d94b184e3808810c79408c795d01803de8b 100644 --- a/src/sources/mastodon.js +++ b/src/sources/mastodon.js @@ -116,14 +116,7 @@ export default { } handledFavorites += 1 results.progressCount += 1 - let accountId - if (f.account.acct.indexOf('@') > -1) { - // the account has full id already - accountId = `webfinger:${f.account.acct}` - } else { - // it's probably a local account, we add the domain by hand - accountId = `webfinger:${f.account.acct}@${account.domain}` - } + let accountId = `activitypub:${f.account.url}` if (results.accounts[accountId]) { results.accounts[accountId].weight += 1 @@ -133,8 +126,8 @@ export default { } else { results.accounts[accountId] = { weight: 1, - source: 'webfinger', - id: f.account.acct, + source: 'activitypub', + id: f.account.url, avatar: f.account.avatar, name: f.account.display_name, url: f.account.url, diff --git a/src/sources/peertube.js b/src/sources/peertube.js new file mode 100644 index 0000000000000000000000000000000000000000..b508749fdb9f9a54d98129707b9be238b8002d2a --- /dev/null +++ b/src/sources/peertube.js @@ -0,0 +1,217 @@ +import axios from 'axios' +import createAuthRefreshInterceptor from 'axios-auth-refresh'; +import moment from 'moment' +import Form from '../components/PeerTubeForm.vue' +import get from 'lodash/get' +import {getAbsoluteUrl} from "@/utils" + +export const SCOPES = "user" + +export async function createApp(ax) { + return await ax.get('/api/v1/oauth-clients/local') +} + +function getAuthorizationHeader (account, state) { + const token = state.sources.peertube.appTokens[`${account.username}@${account.domain}`].access_token + return `Bearer ${token}` + +} + +export function getOauthAxios ({axiosParams, account, store}) { + // an axios instance with autorefreshing for oauth tokens + let client = axios.create(axiosParams || {}) + client.interceptors.request.use(request => { + request.headers['Authorization'] = getAuthorizationHeader(account, store.state) + return request + }) + createAuthRefreshInterceptor(client, (failedRequest) => { + console.log('Request failed, OAuth token probably expired, fetching a new oneā¦') + const refreshToken = store.state.sources.peertube.appTokens[`${account.username}@${account.domain}`].refresh_token + const app = store.state.sources.peertube.appCredentials[account.domain] + const payload = { + client_id: app.client_id, + client_secret: app.client_secret, + grant_type: "refresh_token", + refresh_token: refreshToken, + } + return client.post( + `/api/v1/users/token`, + asForm(payload), + {headers: {'Content-Type': 'multipart/form-data'}} + ).then((tokenRefreshResponse) => { + store.commit('setRecursiveState', {key: "sources.peertube.appTokens", suffix: `${account.username}@${account.domain}`, value: tokenRefreshResponse.data}) + let newHeader = getAuthorizationHeader(account, store.state) + failedRequest.response.config.headers['Authorization'] = newHeader + return Promise.resolve() + }) + }) + return client +} + +function asForm (obj) { + let data = new FormData() + Object.entries(obj).forEach((e) => { + data.set(e[0], e[1]) + }) + return data +} + +function objectToQs (obj) { + let str = []; + for (let p in obj) + if (obj.hasOwnProperty(p)) { + str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p])); + } + return str.join("&"); +} + +export async function connect({domain, router, store, username, password}) { + console.log(`Received connect callback for peertube domain ${domain}`) + console.log('Fetching token...') + const app = store.state.sources.peertube.appCredentials[domain] + const ax = axios.create() + const payload = { + client_id: app.client_id, + client_secret: app.client_secret, + grant_type: "password", + response_type: "code", + username, + password, + } + const response = await ax.post( + `https://${domain}/api/v1/users/token`, + objectToQs(payload), + {headers: {'Content-Type': 'application/x-www-form-urlencoded'}} + ) + 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/me/') + const fullUsername = `${accountResponse.data.username}@${domain}` + store.commit('setRecursiveState', {key: "sources.peertube.appTokens", suffix: fullUsername, value: response.data}) + store.commit('addAccount', {source: 'peertube', raw: accountResponse.data, username: accountResponse.data.username, domain}) + router.push({path: '/'}) +} + + +export default { + id: "peertube", + label: "PeerTube", + url: "https://joinpeertube.org", + description: "PeerTube servers", + extendedDescription: "Receive suggestions based on your viewing history", + imageBackground: "grey lighten-5", + defaultAvatarIcon: "ondemand_video", + form: Form, + connect: true, + getLogo () { + return require("../assets/sources/peertube-logo.svg") + }, + authDataToKey ({username, domain}) { + return `peertube ${domain} ${username}` + }, + getAvatar (data) { + + if (!data.raw || !data.raw.account.avatar) { + return require("../assets/sources/peertube-logo.svg") + } + return data.raw.account.avatar.path + }, + getDisplayName ({username, domain}) { + return `${username}@${domain}` + }, + async fetch ({account, store, results, maxDays}) { + results.status = `Fetching views...` + const token = store.state.sources.peertube.appTokens[`${account.username}@${account.domain}`].access_token + const client = getOauthAxios({ + axiosParams: {baseURL: `https://${account.domain}`}, + store: store, + account: account + }) + const dateLimit = moment().subtract(maxDays, 'days') + let url = null + let handledViews = 0 + // results.progress = 0 + // results.progressCount + results.accounts = {} + let cont = true + let start = 0 + let perPage = 100 + while (cont) { + let response = await client.get('/api/v1/users/me/history/videos', {params: {start, count: perPage}}) + response.data.data.forEach((v) => { + let date = moment(v.publishedAt) + if (date.isBefore(dateLimit)) { + cont = false + return + } + + handledViews += 1 + results.progressCount += 1 + let channel = v.channel + let channelId = `activitypub:${channel.url}` + if (results.accounts[channelId]) { + results.accounts[channelId].weight += 1 + let detail = get(results.accounts[channelId], 'detail', {}) + detail = get(detail, 'peertube', {views: 0}) + detail.views += 1 + } else { + results.accounts[channelId] = { + weight: 1, + source: 'peertube', + id: channelId, + avatar: (channel.avatar || {}).path, + name: channel.displayName, + url: channel.url, + detail: { + peertube: { + views: 1 + } + } + } + } + // on PeerTube, both channels and accounts are AP actors + // and can have descriptions + let account = v.account + let accountId = `activitypub:${account.url}` + if (results.accounts[accountId]) { + results.accounts[accountId].weight += 1 + let detail = get(results.accounts[accountId], 'detail', {}) + detail = get(detail, 'peertube', {views: 0}) + detail.views += 1 + } else { + results.accounts[accountId] = { + weight: 1, + source: 'peertube', + id: accountId, + avatar: (account.avatar || {}).path, + name: account.displayName, + url: account.url, + detail: { + peertube: { + views: 1 + } + } + } + } + // results.progress = Math.min(100, handledViews / maxFavorites * 100) + }) + start += response.data.data.length + if (start >= response.data.total ) { + break + } + results.status = `Fetched ${handledViews} views` + } + results.isLoading = false + return results + }, + getDetailMessage (detail) { + if (!detail) { + return + } + let views = detail.views || 0 + return `peertube: ${views} views` + } +} diff --git a/src/views/Connect.vue b/src/views/Connect.vue index 0a5a7a2880348d141110caa222a05fcce8d8daa2..f973f05d4cf4cf841c26710af65ca9f3ebbec777 100644 --- a/src/views/Connect.vue +++ b/src/views/Connect.vue @@ -37,4 +37,7 @@ export default { .card .card-image { padding: 1em; } +.card-image img { + height: 5em; +} </style>