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>