Fix frontend federation search

This commit is contained in:
jon r 2025-05-03 11:32:24 +00:00 committed by petitminion
parent 40cb7aadb3
commit f6faed0691
17 changed files with 338 additions and 215 deletions

View File

@ -20659,18 +20659,32 @@ components:
format: date-time
readOnly: true
nullable: true
type:
type: string
readOnly: true
object:
oneOf:
- $ref: '#/components/schemas/Artist'
- $ref: '#/components/schemas/Album'
- $ref: '#/components/schemas/Track'
- $ref: '#/components/schemas/APIActor'
- $ref: '#/components/schemas/Channel'
- $ref: '#/components/schemas/Playlist'
readOnly: true
required:
- actor
- creation_date
- detail
- fetch_date
- id
- object
- status
- type
- url
FetchRequest:
type: object
properties:
object:
object_uri:
type: string
writeOnly: true
minLength: 1
@ -20679,7 +20693,7 @@ components:
writeOnly: true
default: false
required:
- object
- object_uri
FetchStatusEnum:
enum:
- pending

View File

@ -10,9 +10,10 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from funkwhale_api.audio import models as audio_models
from funkwhale_api.common import fields as common_fields
from funkwhale_api.audio import serializers as audio_serializers
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.music import models as music_models
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.users import serializers as users_serializers
from . import filters, models
@ -192,19 +193,17 @@ class InboxItemActionSerializer(common_serializers.ActionSerializer):
return objects.update(is_read=True)
FETCH_OBJECT_CONFIG = {
"artist": {"queryset": music_models.Artist.objects.all()},
"album": {"queryset": music_models.Album.objects.all()},
"track": {"queryset": music_models.Track.objects.all()},
"library": {"queryset": music_models.Library.objects.all(), "id_attr": "uuid"},
"upload": {"queryset": music_models.Upload.objects.all(), "id_attr": "uuid"},
"account": {"queryset": models.Actor.objects.all(), "id_attr": "full_username"},
"channel": {"queryset": audio_models.Channel.objects.all(), "id_attr": "uuid"},
OBJECT_SERIALIZER_MAPPING = {
music_models.Artist: federation_serializers.ArtistSerializer,
music_models.Album: federation_serializers.AlbumSerializer,
music_models.Track: federation_serializers.TrackSerializer,
models.Actor: federation_serializers.APIActorSerializer,
audio_models.Channel: audio_serializers.ChannelSerializer,
playlists_models.Playlist: federation_serializers.PlaylistSerializer,
}
FETCH_OBJECT_FIELD = common_fields.GenericRelation(FETCH_OBJECT_CONFIG)
def convert_url_to_webginfer(url):
def convert_url_to_webfinger(url):
parsed_url = urlparse(url)
domain = parsed_url.netloc # e.g., "node1.funkwhale.test"
path_parts = parsed_url.path.strip("/").split("/")
@ -217,7 +216,9 @@ def convert_url_to_webginfer(url):
class FetchSerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer(read_only=True)
object = serializers.CharField(write_only=True)
object_uri = serializers.CharField(required=True, write_only=True)
object = serializers.SerializerMethodField(read_only=True)
type = serializers.SerializerMethodField(read_only=True)
force = serializers.BooleanField(default=False, required=False, write_only=True)
class Meta:
@ -230,8 +231,10 @@ class FetchSerializer(serializers.ModelSerializer):
"detail",
"creation_date",
"fetch_date",
"object",
"object_uri",
"force",
"type",
"object",
]
read_only_fields = [
"id",
@ -241,14 +244,36 @@ class FetchSerializer(serializers.ModelSerializer):
"detail",
"creation_date",
"fetch_date",
"type",
"object",
]
def validate_object(self, value):
def get_type(self, fetch):
obj = fetch.object
if obj is None:
return None
# Return the type as a string
if isinstance(obj, music_models.Artist):
return "artist"
elif isinstance(obj, music_models.Album):
return "album"
elif isinstance(obj, music_models.Track):
return "track"
elif isinstance(obj, models.Actor):
return "account"
elif isinstance(obj, audio_models.Channel):
return "channel"
elif isinstance(obj, playlists_models.Playlist):
return "playlist"
else:
return None
def validate_object_uri(self, value):
if value.startswith("https://"):
converted = convert_url_to_webginfer(value)
converted = convert_url_to_webfinger(value)
if converted:
value = converted
# if value is a webginfer lookup, we craft a special url
if value.startswith("@"):
value = value.lstrip("@")
validator = validators.EmailValidator()
@ -256,9 +281,30 @@ class FetchSerializer(serializers.ModelSerializer):
validator(value)
except validators.ValidationError:
return value
return f"webfinger://{value}"
@extend_schema_field(
{
"oneOf": [
{"$ref": "#/components/schemas/Artist"},
{"$ref": "#/components/schemas/Album"},
{"$ref": "#/components/schemas/Track"},
{"$ref": "#/components/schemas/APIActor"},
{"$ref": "#/components/schemas/Channel"},
{"$ref": "#/components/schemas/Playlist"},
]
}
)
def get_object(self, fetch):
obj = fetch.object
if obj is None:
return None
serializer_class = OBJECT_SERIALIZER_MAPPING.get(type(obj))
if serializer_class:
return serializer_class(obj).data
return None
def create(self, validated_data):
check_duplicates = not validated_data.get("force", False)
if check_duplicates:
@ -267,7 +313,7 @@ class FetchSerializer(serializers.ModelSerializer):
validated_data["actor"]
.fetches.filter(
status="finished",
url=validated_data["object"],
url=validated_data["object_uri"],
creation_date__gte=timezone.now()
- datetime.timedelta(
seconds=settings.FEDERATION_DUPLICATE_FETCH_DELAY
@ -280,18 +326,10 @@ class FetchSerializer(serializers.ModelSerializer):
return duplicate
fetch = models.Fetch.objects.create(
actor=validated_data["actor"], url=validated_data["object"]
actor=validated_data["actor"], url=validated_data["object_uri"]
)
return fetch
def to_representation(self, obj):
repr = super().to_representation(obj)
object_data = None
if obj.object:
object_data = FETCH_OBJECT_FIELD.to_representation(obj.object)
repr["object"] = object_data
return repr
class FullActorSerializer(serializers.Serializer):
fid = serializers.URLField()

View File

@ -1,5 +1,6 @@
import pytest
from funkwhale_api.audio.serializers import ChannelSerializer
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.federation import api_serializers, serializers
from funkwhale_api.users import serializers as users_serializers
@ -128,6 +129,7 @@ def test_fetch_serializer_no_obj(factories, to_api_date):
"status": fetch.status,
"detail": fetch.detail,
"object": None,
"type": None,
"actor": serializers.APIActorSerializer(fetch.actor).data,
}
@ -135,22 +137,28 @@ def test_fetch_serializer_no_obj(factories, to_api_date):
@pytest.mark.parametrize(
"object_factory, expected_type, expected_id",
"object_factory, expected_type, serializer_class",
[
("music.Album", "album", "id"),
("music.Artist", "artist", "id"),
("music.Track", "track", "id"),
("music.Library", "library", "uuid"),
("music.Upload", "upload", "uuid"),
("audio.Channel", "channel", "uuid"),
("federation.Actor", "account", "full_username"),
("music.Album", "album", serializers.AlbumSerializer),
("music.Artist", "artist", serializers.ArtistSerializer),
("music.Track", "track", serializers.TrackSerializer),
("audio.Channel", "channel", ChannelSerializer),
("federation.Actor", "account", serializers.APIActorSerializer),
("playlists.Playlist", "playlist", serializers.PlaylistSerializer),
],
)
def test_fetch_serializer_with_object(
object_factory, expected_type, expected_id, factories, to_api_date
object_factory, expected_type, serializer_class, factories, to_api_date
):
obj = factories[object_factory]()
fetch = factories["federation.Fetch"](object=obj)
# Serialize the object
if serializer_class:
object_data = serializer_class(obj).data
else:
object_data = {"uuid": getattr(obj, "uuid", None)}
expected = {
"id": fetch.pk,
"url": fetch.url,
@ -158,7 +166,10 @@ def test_fetch_serializer_with_object(
"fetch_date": None,
"status": fetch.status,
"detail": fetch.detail,
"object": {"type": expected_type, expected_id: getattr(obj, expected_id)},
"object": {
**object_data,
},
"type": expected_type,
"actor": serializers.APIActorSerializer(fetch.actor).data,
}
@ -175,6 +186,7 @@ def test_fetch_serializer_unhandled_obj(factories, to_api_date):
"status": fetch.status,
"detail": fetch.detail,
"object": None,
"type": None,
"actor": serializers.APIActorSerializer(fetch.actor).data,
}

View File

@ -243,7 +243,7 @@ def test_can_fetch_using_url_synchronous(
fetch_task = mocker.patch.object(tasks, "fetch", side_effect=fake_task)
url = reverse("api:v1:federation:fetches-list")
data = {"object": object_id}
data = {"object_uri": object_id}
response = logged_in_api_client.post(url, data)
assert response.status_code == 201
@ -266,7 +266,7 @@ def test_fetch_duplicate(factories, logged_in_api_client, settings, now):
creation_date=now - datetime.timedelta(seconds=59),
)
url = reverse("api:v1:federation:fetches-list")
data = {"object": object_id}
data = {"object_uri": object_id}
response = logged_in_api_client.post(url, data)
assert response.status_code == 201
assert response.data == api_serializers.FetchSerializer(duplicate).data
@ -286,7 +286,7 @@ def test_fetch_duplicate_bypass_with_force(
creation_date=now - datetime.timedelta(seconds=59),
)
url = reverse("api:v1:federation:fetches-list")
data = {"object": object_id, "force": True}
data = {"object_uri": object_id, "force": True}
response = logged_in_api_client.post(url, data)
fetch = actor.fetches.latest("id")

View File

@ -0,0 +1 @@
Entering an input in the global search in the sidebar opens up a modal that show all possible results at once with collapseable sections for each category, including federated searches for other users, channels and rss feeds. (#2910)

View File

@ -6638,9 +6638,11 @@ export interface components {
readonly creation_date: string;
/** Format: date-time */
readonly fetch_date: string | null;
readonly type: string;
readonly object: components["schemas"]["Artist"] | components["schemas"]["Album"] | components["schemas"]["Track"] | components["schemas"]["APIActor"] | components["schemas"]["Channel"] | components["schemas"]["Playlist"];
};
FetchRequest: {
object: string;
object_uri: string;
/** @default false */
force: boolean;
};

View File

@ -4,7 +4,7 @@
},
"vui": {
"tracks": "{n}টি গান | {n}টি গান",
"by-user": "{'@'}{username} থেকে",
"by-user": "{username} থেকে",
"aria": {
"pagination": {
"gotoPage": "{n} পৃষ্ঠায় যাও",

View File

@ -2,6 +2,27 @@
"App": {
"loading": "Carregant…"
},
"vui": {
"radio": "Ràdio",
"albums": "{n} àlbum | {n} àlbums",
"tracks": "{n} pista | {n} pistes",
"episodes": "{n} episodi | {n} episodis",
"by-user": "per {username}",
"go-to": "Anar a",
"pagination": {
"previous": "Prèvia",
"next": "Següent"
},
"aria": {
"pagination": {
"nav": "Navegació per Paginació",
"gotoPage": "Anar a Pàgina {n}",
"gotoPrevious": "Anar a la Pàgina Prèvia",
"gotoNext": "Anar a la Pàgina Següent",
"currentPage": "Pàgina actual, Pàgina {n}"
}
}
},
"components": {
"About": {
"description": {
@ -4629,26 +4650,5 @@
"title": "Ràdio"
}
}
},
"vui": {
"albums": "{n} àlbum | {n} àlbums",
"aria": {
"pagination": {
"currentPage": "Pàgina actual, Pàgina {n}",
"gotoNext": "Anar a la Pàgina Següent",
"gotoPage": "Anar a Pàgina {n}",
"gotoPrevious": "Anar a la Pàgina Prèvia",
"nav": "Navegació per Paginació"
}
},
"by-user": "per {'@'}{username}",
"episodes": "{n} episodi | {n} episodis",
"go-to": "Anar a",
"pagination": {
"next": "Següent",
"previous": "Prèvia"
},
"radio": "Ràdio",
"tracks": "{n} pista | {n} pistes"
}
}

View File

@ -6,7 +6,7 @@
"radio": "Ràdio",
"albums": "{n} àlbum | {n} àlbums",
"episodes": "{n} episodi | {n} episodis",
"by-user": "per {'@'}{username}",
"by-user": "per {username}",
"aria": {
"pagination": {
"gotoPrevious": "Retornar a la pàgina anterior",

View File

@ -4623,7 +4623,7 @@
"nav": "Nummerierte Navigation"
}
},
"by-user": "von {'@'}{username}",
"by-user": "von {username}",
"episodes": "{n} Episode | {n} Episoden",
"go-to": "Gehe zu",
"pagination": {

View File

@ -7,7 +7,7 @@
"albums": "{n} album | {n} albums",
"tracks": "{n} track | {n} tracks",
"episodes": "{n} episode | {n} episodes",
"by-user": "by {'@'}{username}",
"by-user": "by {username}",
"go-to": "Go to",
"pagination": {
"previous": "Previous",

View File

@ -2,6 +2,27 @@
"App": {
"loading": "Cargando…"
},
"vui": {
"radio": "Radio",
"albums": "{n} álbum | {n} álbumes",
"tracks": "{n} pista | {n} pistas",
"episodes": "{n} episodio | {n} episodios",
"by-user": "de {username}",
"go-to": "Vaya a",
"pagination": {
"previous": "Anterior",
"next": "Siguiente"
},
"aria": {
"pagination": {
"nav": "Paginación Navegación",
"currentPage": "Página corriente, Página {n}",
"gotoPage": "Ir a la página {n}",
"gotoPrevious": "Ir a la página anterior",
"gotoNext": "Ir a la página siguiente"
}
}
},
"components": {
"About": {
"description": {
@ -4422,26 +4443,5 @@
"title": "Radio"
}
}
},
"vui": {
"albums": "{n} álbum | {n} álbumes",
"aria": {
"pagination": {
"currentPage": "Página corriente, Página {n}",
"gotoNext": "Ir a la página siguiente",
"gotoPage": "Ir a la página {n}",
"gotoPrevious": "Ir a la página anterior",
"nav": "Paginación Navegación"
}
},
"by-user": "de {'@'}{username}",
"episodes": "{n} episodio | {n} episodios",
"go-to": "Vaya a",
"pagination": {
"next": "Siguiente",
"previous": "Anterior"
},
"radio": "Radio",
"tracks": "{n} pista | {n} pistas"
}
}

View File

@ -2,6 +2,27 @@
"App": {
"loading": "Kargatzen…"
},
"vui": {
"tracks": "Pista {n} | {n} pista",
"episodes": "Atal {n} | {n} atal",
"by-user": "honen eskutik: {username}",
"go-to": "Joan hona:",
"pagination": {
"previous": "Aurrekoa",
"next": "Hurrengoa"
},
"aria": {
"pagination": {
"nav": "Orrikatzearen nabigazioa",
"gotoPage": "Joan orri honetara: {n}",
"gotoPrevious": "Joan aurreko orrira",
"gotoNext": "Joan hurrengo orrira",
"currentPage": "Uneko orria: {n}"
}
},
"albums": "Album {n} | {n} album",
"radio": "Irratia"
},
"components": {
"About": {
"description": {
@ -4611,26 +4632,5 @@
"title": "Irratia"
}
}
},
"vui": {
"albums": "Album {n} | {n} album",
"aria": {
"pagination": {
"currentPage": "Uneko orria: {n}",
"gotoNext": "Joan hurrengo orrira",
"gotoPage": "Joan orri honetara: {n}",
"gotoPrevious": "Joan aurreko orrira",
"nav": "Orrikatzearen nabigazioa"
}
},
"by-user": "honen eskutik: {'@'}{username}",
"episodes": "Atal {n} | {n} atal",
"go-to": "Joan hona:",
"pagination": {
"next": "Hurrengoa",
"previous": "Aurrekoa"
},
"radio": "Irratia",
"tracks": "Pista {n} | {n} pista"
}
}

View File

@ -2,6 +2,32 @@
"App": {
"loading": "Laden…"
},
"vui": {
"radio": "Radio",
"albums": "{n} album | {n} albums",
"tracks": "{n} nummer | {n} nummers",
"episodes": "{n} aflevering | {n} afleveringen",
"by-user": "door {username}",
"go-to": "Ga naar",
"pagination": {
"previous": "Vorige",
"next": "Volgende"
},
"privacy-level": {
"private": "Privé",
"public": "Openbaar",
"pod": "pod"
},
"aria": {
"pagination": {
"nav": "Pagina navigatie",
"gotoPage": "Ga naar pagina {n}",
"gotoPrevious": "Ga naar Vorige Pagina",
"gotoNext": "Ga naar Volgende Pagina",
"currentPage": "Huidige pagina, pagina {n}"
}
}
},
"components": {
"About": {
"description": {
@ -4611,31 +4637,5 @@
"title": "Radio"
}
}
},
"vui": {
"albums": "{n} album | {n} albums",
"aria": {
"pagination": {
"currentPage": "Huidige pagina, pagina {n}",
"gotoNext": "Ga naar Volgende Pagina",
"gotoPage": "Ga naar pagina {n}",
"gotoPrevious": "Ga naar Vorige Pagina",
"nav": "Pagina navigatie"
}
},
"by-user": "door {'@'}{username}",
"episodes": "{n} aflevering | {n} afleveringen",
"go-to": "Ga naar",
"pagination": {
"next": "Volgende",
"previous": "Vorige"
},
"privacy-level": {
"pod": "pod",
"private": "Privé",
"public": "Openbaar"
},
"radio": "Radio",
"tracks": "{n} nummer | {n} nummers"
}
}

View File

@ -2,6 +2,27 @@
"App": {
"loading": "Yükleniyor…"
},
"vui": {
"radio": "Radyo",
"albums": "{n} albüm | {n} albümler",
"tracks": "{n} parça | {n} parçalar",
"episodes": "{n} bölüm | {n} bölümler",
"by-user": "by {username}",
"go-to": "Git",
"pagination": {
"previous": "Önceki",
"next": "Sonraki"
},
"aria": {
"pagination": {
"nav": "Sayfalandırma Navigasyonu",
"gotoPage": "Sayfaya Git {n}",
"gotoPrevious": "Önceki Sayfaya Git",
"gotoNext": "Sonraki Sayfaya Git",
"currentPage": "Geçerli Sayfa, Sayfa {n}"
}
}
},
"components": {
"About": {
"description": {
@ -2236,26 +2257,5 @@
}
}
}
},
"vui": {
"albums": "{n} albüm | {n} albümler",
"aria": {
"pagination": {
"currentPage": "Geçerli Sayfa, Sayfa {n}",
"gotoNext": "Sonraki Sayfaya Git",
"gotoPage": "Sayfaya Git {n}",
"gotoPrevious": "Önceki Sayfaya Git",
"nav": "Sayfalandırma Navigasyonu"
}
},
"by-user": "by {'@'}{username}",
"episodes": "{n} bölüm | {n} bölümler",
"go-to": "Git",
"pagination": {
"next": "Sonraki",
"previous": "Önceki"
},
"radio": "Radyo",
"tracks": "{n} parça | {n} parçalar"
}
}

View File

@ -2,6 +2,27 @@
"App": {
"loading": "加载中…"
},
"vui": {
"tracks": "{n} 歌曲 | {n} 歌曲",
"episodes": "{n} 节目 | {n} 节目",
"by-user": "由 {username}",
"go-to": "去",
"pagination": {
"previous": "以前的",
"next": "下一个"
},
"aria": {
"pagination": {
"nav": "分页导航",
"gotoPage": "转到页面 {n}",
"gotoPrevious": "转到上一页",
"gotoNext": "转到下一页",
"currentPage": "当前页面,第 {n} 页"
}
},
"radio": "电台",
"albums": "{n} 专辑 | {n} 专辑"
},
"components": {
"About": {
"description": {
@ -4517,26 +4538,5 @@
"title": "电台"
}
}
},
"vui": {
"albums": "{n} 专辑 | {n} 专辑",
"aria": {
"pagination": {
"currentPage": "当前页面,第 {n} 页",
"gotoNext": "转到下一页",
"gotoPage": "转到页面 {n}",
"gotoPrevious": "转到上一页",
"nav": "分页导航"
}
},
"by-user": "由 {'@'}{username}",
"episodes": "{n} 节目 | {n} 节目",
"go-to": "去",
"pagination": {
"next": "下一个",
"previous": "以前的"
},
"radio": "电台",
"tracks": "{n} 歌曲 | {n} 歌曲"
}
}

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { paths } from '~/generated/types.ts'
import type { paths, components } from '~/generated/types.ts'
import type { RadioConfig } from '~/store/radios'
import axios from 'axios'
import { ref, watch, computed } from 'vue'
@ -9,9 +9,12 @@ import { trim, uniqBy } from 'lodash-es'
import useErrorHandler from '~/composables/useErrorHandler'
import { useI18n } from 'vue-i18n'
import { useModal } from '~/ui/composables/useModal.ts'
import { useStore } from '~/store'
import ArtistCard from '~/components/artist/Card.vue'
import PlaylistCard from '~/components/playlists/Card.vue'
import ChannelCard from '~/components/audio/ChannelCard.vue'
import ActorLink from '~/components/common/ActorLink.vue'
import TrackTable from '~/components/audio/track/Table.vue'
import AlbumCard from '~/components/album/Card.vue'
import RadioCard from '~/components/radios/Card.vue'
@ -98,7 +101,8 @@ type Results = {
podcasts: Response['podcasts']['results'],
series: Response['series']['results'],
rss: [Response['rss']],
federation: [Response['federation']]
federation: [Response['federation']],
type: Category
}
const responses = ref<Partial<Response>>({})
@ -201,7 +205,7 @@ const categories = computed(() => [
endpoint: '/federation/fetches/',
post: true,
params: {
object: trimmedQuery.value
object_uri: trimmedQuery.value
}
}
] as const satisfies {
@ -222,7 +226,8 @@ const availableCategories = computed(() =>
isFetch.value ? type === 'federation'
: isRss.value ? type === 'rss'
: type !== 'federation' && type !== 'rss'
))
)
)
// Whenever available categories change, if there is exactly one, open it
watch(availableCategories, () => {
@ -245,7 +250,7 @@ const resultsPerCategory = <C extends Category>(category: { type: C }) =>
*/
const count = <C extends Category>(category: { type: C }) => (
response => response && 'count' in response ? response.count : resultsPerCategory(category).length
) (responses.value[category.type])
)(responses.value[category.type])
/**
* Find out whether a category has been queried before
@ -278,6 +283,26 @@ watch(results, () => {
openSections.value = new Set(categoriesWithResults.map(({ type }) => type))
})
// Subscribe to an RSS feed
const store = useStore()
/**
* Subscribe to an RSS feed and return the route for the subscribed channel
* @param url The RSS feed URL
* @returns The route object for the subscribed channel
*/
const rssSubscribe = async (url: string) => {
try {
const response = await axios.post('channels/rss-subscribe/', { url })
store.commit('channels/subscriptions', { uuid: response.data.channel.uuid, value: true })
return response.data.channel
} catch (error) {
useErrorHandler(error as Error)
return null
}
}
// Search
const search = async () => {
@ -321,18 +346,18 @@ const search = async () => {
}
responses.value[category.type] = response.data
} else {
// TODO: add (@)type key to Response type
if (category.type === 'rss') {
const response = await axios.post<Response['rss']>(
category.endpoint,
{ url: trimmedQuery.value }
)
results.value.rss = [response.data]
responses.value[category.type] = response.data
const channel = await rssSubscribe(trimmedQuery.value)
if (channel) {
results.value.rss = [channel] // Store the subscribed channel
}
} else if (category.type === 'federation') {
const response = await axios.post<Response['federation']>(
category.endpoint,
{ params }
{ object_uri: trimmedQuery.value }
)
results.value.type = category.type
results.value.federation = [response.data]
responses.value[category.type] = response.data
} else if (category.type === 'playlists') {
@ -340,6 +365,7 @@ const search = async () => {
category.endpoint,
{ params }
)
results.value.type = category.type
results.value.playlists = response.data.results
responses.value[category.type] = response.data
} else if (category.type === 'podcasts') {
@ -347,6 +373,7 @@ const search = async () => {
category.endpoint,
{ params }
)
results.value.type = category.type
results.value.podcasts = response.data.results
responses.value[category.type] = response.data
} else if (category.type === 'radios') {
@ -354,6 +381,7 @@ const search = async () => {
category.endpoint,
{ params }
)
results.value.type = category.type
results.value.radios = response.data.results
responses.value[category.type] = response.data
} else if (category.type === 'series') {
@ -361,6 +389,7 @@ const search = async () => {
category.endpoint,
{ params }
)
results.value.type = category.type
results.value.series = response.data.results
responses.value[category.type] = response.data
}
@ -378,20 +407,20 @@ const search = async () => {
const radioConfig = computed<RadioConfig | null>(() =>
count({ type: 'tags' }) > 0
? ({
type: 'tag',
names: resultsPerCategory({ type: 'tags' })
.map((({ name }) => name))
})
type: 'tag',
names: resultsPerCategory({ type: 'tags' })
.map((({ name }) => name))
})
: count({ type: 'playlists' }) > 0
? ({
type: 'playlist',
ids: resultsPerCategory({ type: 'playlists' }).map(({ id }) => id.toString())
})
type: 'playlist',
ids: resultsPerCategory({ type: 'playlists' }).map(({ id }) => id.toString())
})
: count({ type: 'artists' }) > 0
? ({
type: 'artist',
ids: resultsPerCategory({ type: 'artists' }).map(({ id }) => id.toString())
})
type: 'artist',
ids: resultsPerCategory({ type: 'artists' }).map(({ id }) => id.toString())
})
: null
)
@ -489,23 +518,50 @@ watch(queryDebounced, search, { immediate: true })
/>
</template>
<!-- If response has "url": "webfinger://node1@node1.funkwhale.test" -> Link to go directly to the federation page -->
<span v-if="category.type === 'rss' && count(category) > 0">
<Alert>{{ t('modals.search.tryAgain') }}</Alert>
<Link
v-for="channel in resultsPerCategory(category)"
:key="channel.artist.fid"
:to="channel.artist.fid"
autofocus
<template v-if="category.type === 'rss' && count(category) > 0">
<Alert
blue
style="grid-column: 1 / -1"
>
{{ channel.artist.name }}
</Link>
</span>
{{ t('modals.search.tryAgain') }}
</Alert>
<channel-card
v-if="results.rss && results.rss[0]"
:key="results.rss[0].uuid"
:object="results.rss[0]"
/>
</template>
<span v-else-if="category.type === 'federation'">
<!-- TODO: Federation search: backend adapter + display, fix results_per_category query -->
<!-- {{ resultsPerCategory(category) }} -->
<span v-else-if="category.type === 'federation' && count(category) > 0">
<template
v-for="result in resultsPerCategory(category)"
:key="result.id"
>
<ActorLink
v-if="result.object && result.type === 'account'"
:actor="result.object as components['schemas']['APIActor']"
/>
<ChannelCard
v-else-if="result.object && result.type === 'channel'"
:object="result.object as components['schemas']['Channel']"
/>
<ArtistCard
v-else-if="result.object && result.type === 'artist'"
:artist="result.object as components['schemas']['Artist']"
/>
<AlbumCard
v-else-if="result.object && result.type === 'album'"
:album="result.object as components['schemas']['Album']"
/>
<PlaylistCard
v-else-if="result.object && result.type === 'playlist'"
:playlist="result.object as components['schemas']['Playlist']"
/>
<TrackTable
v-else-if="result.object && result.type === 'track'"
:tracks="[result.object] as components['schemas']['Track'][]"
/>
</template>
</span>
<EmptyState