Merge branch 'feature/scrobbling' into 'develop'

media scrobbling ("now playing" API)

See merge request pleroma/pleroma!1732
This commit is contained in:
kaniini 2019-09-30 10:45:00 +00:00
commit ed176e7402
16 changed files with 458 additions and 3 deletions

View File

@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added ### Added
- Refreshing poll results for remote polls - Refreshing poll results for remote polls
- Admin API: Add ability to require password reset - Admin API: Add ability to require password reset
- Mastodon API: Account entities now include `follow_requests_count` (planned Mastodon 3.x addition)
- Pleroma API: `GET /api/v1/pleroma/accounts/:id/scrobbles` to get a list of recently scrobbled items
- Pleroma API: `POST /api/v1/pleroma/scrobble` to scrobble a media item
### Changed ### Changed
- **Breaking:** Elixir >=1.8 is now required (was >= 1.7) - **Breaking:** Elixir >=1.8 is now required (was >= 1.7)

View File

@ -439,3 +439,35 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa
* Params: None * Params: None
* Response: the archive of the pack with a 200 status code, 403 if the pack is not set as shared, * Response: the archive of the pack with a 200 status code, 403 if the pack is not set as shared,
404 if the pack does not exist 404 if the pack does not exist
## `GET /api/v1/pleroma/accounts/:id/scrobbles`
### Requests a list of current and recent Listen activities for an account
* Method `GET`
* Authentication: not required
* Params: None
* Response: An array of media metadata entities.
* Example response:
```json
[
{
"account": {...},
"id": "1234",
"title": "Some Title",
"artist": "Some Artist",
"album": "Some Album",
"length": 180000,
"created_at": "2019-09-28T12:40:45.000Z"
}
]
```
## `POST /api/v1/pleroma/scrobble`
### Creates a new Listen activity for an account
* Method `POST`
* Authentication: required
* Params:
* `title`: the title of the media playing
* `album`: the album of the media playing [optional]
* `artist`: the artist of the media playing [optional]
* `length`: the length of the media playing [optional]
* Response: the newly created media metadata entity representing the Listen activity

View File

@ -248,6 +248,26 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
end end
end end
def listen(%{to: to, actor: actor, context: context, object: object} = params) do
additional = params[:additional] || %{}
# only accept false as false value
local = !(params[:local] == false)
published = params[:published]
with listen_data <-
make_listen_data(
%{to: to, actor: actor, published: published, context: context, object: object},
additional
),
{:ok, activity} <- insert(listen_data, local),
:ok <- maybe_federate(activity) do
{:ok, activity}
else
{:error, message} ->
{:error, message}
end
end
def accept(%{to: to, actor: actor, object: object} = params) do def accept(%{to: to, actor: actor, object: object} = params) do
# only accept false as false value # only accept false as false value
local = !(params[:local] == false) local = !(params[:local] == false)
@ -588,6 +608,23 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
defp restrict_thread_visibility(query, _, _), do: query defp restrict_thread_visibility(query, _, _), do: query
def fetch_user_abstract_activities(user, reading_user, params \\ %{}) do
params =
params
|> Map.put("user", reading_user)
|> Map.put("actor_id", user.ap_id)
|> Map.put("whole_db", true)
recipients =
user_activities_recipients(%{
"godmode" => params["godmode"],
"reading_user" => reading_user
})
fetch_activities(recipients, params)
|> Enum.reverse()
end
def fetch_user_activities(user, reading_user, params \\ %{}) do def fetch_user_activities(user, reading_user, params \\ %{}) do
params = params =
params params

View File

@ -431,6 +431,36 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
end end
def handle_incoming( def handle_incoming(
%{"type" => "Listen", "object" => %{"type" => "Audio"} = object} = data,
options
) do
actor = Containment.get_actor(data)
data =
Map.put(data, "actor", actor)
|> fix_addressing
with {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do
options = Keyword.put(options, :depth, (options[:depth] || 0) + 1)
object = fix_object(object, options)
params = %{
to: data["to"],
object: object,
actor: user,
context: nil,
local: false,
published: data["published"],
additional: Map.take(data, ["cc", "id"])
}
ActivityPub.listen(params)
else
_e -> :error
end
end
def handle_incoming(
%{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data, %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data,
_options _options
) do ) do
@ -765,7 +795,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
# internal -> Mastodon # internal -> Mastodon
# """ # """
def prepare_outgoing(%{"type" => "Create", "object" => object_id} = data) do def prepare_outgoing(%{"type" => activity_type, "object" => object_id} = data)
when activity_type in ["Create", "Listen"] do
object = object =
object_id object_id
|> Object.normalize() |> Object.normalize()

View File

@ -20,7 +20,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do
require Logger require Logger
require Pleroma.Constants require Pleroma.Constants
@supported_object_types ["Article", "Note", "Video", "Page", "Question", "Answer"] @supported_object_types ["Article", "Note", "Video", "Page", "Question", "Answer", "Audio"]
@supported_report_states ~w(open closed resolved) @supported_report_states ~w(open closed resolved)
@valid_visibilities ~w(public unlisted private direct) @valid_visibilities ~w(public unlisted private direct)
@ -581,6 +581,21 @@ defmodule Pleroma.Web.ActivityPub.Utils do
|> Map.merge(additional) |> Map.merge(additional)
end end
#### Listen-related helpers
def make_listen_data(params, additional) do
published = params.published || make_date()
%{
"type" => "Listen",
"to" => params.to |> Enum.uniq(),
"actor" => params.actor.ap_id,
"object" => params.object,
"published" => published,
"context" => params.context
}
|> Map.merge(additional)
end
#### Flag-related helpers #### Flag-related helpers
@spec make_flag_data(map(), map()) :: map() @spec make_flag_data(map(), map()) :: map()
def make_flag_data(%{actor: actor, context: context, content: content} = params, additional) do def make_flag_data(%{actor: actor, context: context, content: content} = params, additional) do

View File

@ -15,7 +15,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do
Map.merge(base, additional) Map.merge(base, additional)
end end
def render("object.json", %{object: %Activity{data: %{"type" => "Create"}} = activity}) do def render("object.json", %{object: %Activity{data: %{"type" => activity_type}} = activity})
when activity_type in ["Create", "Listen"] do
base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header() base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header()
object = Object.normalize(activity) object = Object.normalize(activity)

View File

@ -212,6 +212,27 @@ defmodule Pleroma.Web.CommonAPI do
|> check_expiry_date() |> check_expiry_date()
end end
def listen(user, %{"title" => _} = data) do
with visibility <- data["visibility"] || "public",
{to, cc} <- get_to_and_cc(user, [], nil, visibility, nil),
listen_data <-
Map.take(data, ["album", "artist", "title", "length"])
|> Map.put("type", "Audio")
|> Map.put("to", to)
|> Map.put("cc", cc)
|> Map.put("actor", user.ap_id),
{:ok, activity} <-
ActivityPub.listen(%{
actor: user,
to: to,
object: listen_data,
context: Utils.generate_context_id(),
additional: %{"cc" => cc}
}) do
{:ok, activity}
end
end
def post(user, %{"status" => _} = data) do def post(user, %{"status" => _} = data) do
with {:ok, draft} <- Pleroma.Web.CommonAPI.ActivityDraft.create(user, data) do with {:ok, draft} <- Pleroma.Web.CommonAPI.ActivityDraft.create(user, data) do
draft.changes draft.changes

View File

@ -368,6 +368,27 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
} }
end end
def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
object = Object.normalize(activity)
user = get_user(activity.data["actor"])
created_at = Utils.to_masto_date(activity.data["published"])
%{
id: activity.id,
account: AccountView.render("account.json", %{user: user, for: opts[:for]}),
created_at: created_at,
title: object.data["title"] |> HTML.strip_tags(),
artist: object.data["artist"] |> HTML.strip_tags(),
album: object.data["album"] |> HTML.strip_tags(),
length: object.data["length"]
}
end
def render("listens.json", opts) do
safe_render_many(opts.activities, StatusView, "listen.json", opts)
end
def render("poll.json", %{object: object} = opts) do def render("poll.json", %{object: object} = opts) do
{multiple, options} = {multiple, options} =
case object.data do case object.data do

View File

@ -0,0 +1,52 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.ScrobbleController do
use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, fetch_integer_param: 2]
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MastodonAPI.StatusView
def new_scrobble(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do
params =
if !params["length"] do
params
else
params
|> Map.put("length", fetch_integer_param(params, "length"))
end
with {:ok, activity} <- CommonAPI.listen(user, params) do
conn
|> put_view(StatusView)
|> render("listen.json", %{activity: activity, for: user})
else
{:error, message} ->
conn
|> put_status(:bad_request)
|> json(%{"error" => message})
end
end
def user_scrobbles(%{assigns: %{user: reading_user}} = conn, params) do
with %User{} = user <- User.get_cached_by_nickname_or_id(params["id"], for: reading_user) do
params = Map.put(params, "type", ["Listen"])
activities = ActivityPub.fetch_user_abstract_activities(user, reading_user, params)
conn
|> add_link_headers(activities)
|> put_view(StatusView)
|> render("listens.json", %{
activities: activities,
for: reading_user,
as: :activity
})
end
end
end

View File

@ -300,6 +300,17 @@ defmodule Pleroma.Web.Router do
patch("/conversations/:id", PleromaAPIController, :update_conversation) patch("/conversations/:id", PleromaAPIController, :update_conversation)
post("/notifications/read", PleromaAPIController, :read_notification) post("/notifications/read", PleromaAPIController, :read_notification)
end end
scope [] do
pipe_through(:oauth_write)
post("/scrobble", ScrobbleController, :new_scrobble)
end
end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
pipe_through([:api, :oauth_read_or_public])
get("/accounts/:id/scrobbles", ScrobbleController, :user_scrobbles)
end end
scope "/api/v1", Pleroma.Web.MastodonAPI do scope "/api/v1", Pleroma.Web.MastodonAPI do

View File

@ -71,6 +71,47 @@ defmodule Pleroma.Factory do
} }
end end
def audio_factory(attrs \\ %{}) do
text = sequence(:text, &"lain radio episode #{&1}")
user = attrs[:user] || insert(:user)
data = %{
"type" => "Audio",
"id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(),
"artist" => "lain",
"title" => text,
"album" => "lain radio",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"published" => DateTime.utc_now() |> DateTime.to_iso8601(),
"actor" => user.ap_id,
"length" => 180_000
}
%Pleroma.Object{
data: merge_attributes(data, Map.get(attrs, :data, %{}))
}
end
def listen_factory do
audio = insert(:audio)
data = %{
"id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(),
"type" => "Listen",
"actor" => audio.data["actor"],
"to" => audio.data["to"],
"object" => audio.data,
"published" => audio.data["published"]
}
%Pleroma.Activity{
data: data,
actor: data["actor"],
recipients: data["to"]
}
end
def direct_note_factory do def direct_note_factory do
user2 = insert(:user) user2 = insert(:user)

View File

@ -257,6 +257,42 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
end end
end end
describe "listen activities" do
test "does not increase user note count" do
user = insert(:user)
{:ok, activity} =
ActivityPub.listen(%{
to: ["https://www.w3.org/ns/activitystreams#Public"],
actor: user,
context: "",
object: %{
"actor" => user.ap_id,
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"artist" => "lain",
"title" => "lain radio episode 1",
"length" => 180_000,
"type" => "Audio"
}
})
assert activity.actor == user.ap_id
user = User.get_cached_by_id(user.id)
assert user.info.note_count == 0
end
test "can be fetched into a timeline" do
_listen_activity_1 = insert(:listen)
_listen_activity_2 = insert(:listen)
_listen_activity_3 = insert(:listen)
timeline = ActivityPub.fetch_activities([], %{"type" => ["Listen"]})
assert length(timeline) == 3
end
end
describe "create activities" do describe "create activities" do
test "removes doubled 'to' recipients" do test "removes doubled 'to' recipients" do
user = insert(:user) user = insert(:user)

View File

@ -177,6 +177,35 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
end) end)
end end
test "it works for incoming listens" do
data = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"type" => "Listen",
"id" => "http://mastodon.example.org/users/admin/listens/1234/activity",
"actor" => "http://mastodon.example.org/users/admin",
"object" => %{
"type" => "Audio",
"id" => "http://mastodon.example.org/users/admin/listens/1234",
"attributedTo" => "http://mastodon.example.org/users/admin",
"title" => "lain radio episode 1",
"artist" => "lain",
"album" => "lain radio",
"length" => 180_000
}
}
{:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data)
object = Object.normalize(activity)
assert object.data["title"] == "lain radio episode 1"
assert object.data["artist"] == "lain"
assert object.data["album"] == "lain radio"
assert object.data["length"] == 180_000
end
test "it rewrites Note votes to Answers and increments vote counters on question activities" do test "it rewrites Note votes to Answers and increments vote counters on question activities" do
user = insert(:user) user = insert(:user)
@ -1190,6 +1219,20 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
assert is_nil(modified["bcc"]) assert is_nil(modified["bcc"])
end end
test "it can handle Listen activities" do
listen_activity = insert(:listen)
{:ok, modified} = Transmogrifier.prepare_outgoing(listen_activity.data)
assert modified["type"] == "Listen"
user = insert(:user)
{:ok, activity} = CommonAPI.listen(user, %{"title" => "lain radio episode 1"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
end
end end
describe "user upgrade" do describe "user upgrade" do

View File

@ -510,4 +510,43 @@ defmodule Pleroma.Web.CommonAPITest do
assert {:error, "Already voted"} == CommonAPI.vote(other_user, object, [1]) assert {:error, "Already voted"} == CommonAPI.vote(other_user, object, [1])
end end
end end
describe "listen/2" do
test "returns a valid activity" do
user = insert(:user)
{:ok, activity} =
CommonAPI.listen(user, %{
"title" => "lain radio episode 1",
"album" => "lain radio",
"artist" => "lain",
"length" => 180_000
})
object = Object.normalize(activity)
assert object.data["title"] == "lain radio episode 1"
assert Visibility.get_visibility(activity) == "public"
end
test "respects visibility=private" do
user = insert(:user)
{:ok, activity} =
CommonAPI.listen(user, %{
"title" => "lain radio episode 1",
"album" => "lain radio",
"artist" => "lain",
"length" => 180_000,
"visibility" => "private"
})
object = Object.normalize(activity)
assert object.data["title"] == "lain radio episode 1"
assert Visibility.get_visibility(activity) == "private"
end
end
end end

View File

@ -608,4 +608,13 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
assert status.visibility == "list" assert status.visibility == "list"
end end
test "successfully renders a Listen activity (pleroma extension)" do
listen_activity = insert(:listen)
status = StatusView.render("listen.json", activity: listen_activity)
assert status.length == listen_activity.data["object"]["length"]
assert status.title == listen_activity.data["object"]["title"]
end
end end

View File

@ -0,0 +1,63 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.ScrobbleControllerTest do
use Pleroma.Web.ConnCase
alias Pleroma.Web.CommonAPI
import Pleroma.Factory
describe "POST /api/v1/pleroma/scrobble" do
test "works correctly", %{conn: conn} do
user = insert(:user)
conn =
conn
|> assign(:user, user)
|> post("/api/v1/pleroma/scrobble", %{
"title" => "lain radio episode 1",
"artist" => "lain",
"album" => "lain radio",
"length" => "180000"
})
assert %{"title" => "lain radio episode 1"} = json_response(conn, 200)
end
end
describe "GET /api/v1/pleroma/accounts/:id/scrobbles" do
test "works correctly", %{conn: conn} do
user = insert(:user)
{:ok, _activity} =
CommonAPI.listen(user, %{
"title" => "lain radio episode 1",
"artist" => "lain",
"album" => "lain radio"
})
{:ok, _activity} =
CommonAPI.listen(user, %{
"title" => "lain radio episode 2",
"artist" => "lain",
"album" => "lain radio"
})
{:ok, _activity} =
CommonAPI.listen(user, %{
"title" => "lain radio episode 3",
"artist" => "lain",
"album" => "lain radio"
})
conn =
conn
|> get("/api/v1/pleroma/accounts/#{user.id}/scrobbles")
result = json_response(conn, 200)
assert length(result) == 3
end
end
end