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 format: date-time
readOnly: true readOnly: true
nullable: 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: required:
- actor - actor
- creation_date - creation_date
- detail - detail
- fetch_date - fetch_date
- id - id
- object
- status - status
- type
- url - url
FetchRequest: FetchRequest:
type: object type: object
properties: properties:
object: object_uri:
type: string type: string
writeOnly: true writeOnly: true
minLength: 1 minLength: 1
@ -20679,7 +20693,7 @@ components:
writeOnly: true writeOnly: true
default: false default: false
required: required:
- object - object_uri
FetchStatusEnum: FetchStatusEnum:
enum: enum:
- pending - pending

View File

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

View File

@ -1,5 +1,6 @@
import pytest import pytest
from funkwhale_api.audio.serializers import ChannelSerializer
from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.federation import api_serializers, serializers from funkwhale_api.federation import api_serializers, serializers
from funkwhale_api.users import serializers as users_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, "status": fetch.status,
"detail": fetch.detail, "detail": fetch.detail,
"object": None, "object": None,
"type": None,
"actor": serializers.APIActorSerializer(fetch.actor).data, "actor": serializers.APIActorSerializer(fetch.actor).data,
} }
@ -135,22 +137,28 @@ def test_fetch_serializer_no_obj(factories, to_api_date):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"object_factory, expected_type, expected_id", "object_factory, expected_type, serializer_class",
[ [
("music.Album", "album", "id"), ("music.Album", "album", serializers.AlbumSerializer),
("music.Artist", "artist", "id"), ("music.Artist", "artist", serializers.ArtistSerializer),
("music.Track", "track", "id"), ("music.Track", "track", serializers.TrackSerializer),
("music.Library", "library", "uuid"), ("audio.Channel", "channel", ChannelSerializer),
("music.Upload", "upload", "uuid"), ("federation.Actor", "account", serializers.APIActorSerializer),
("audio.Channel", "channel", "uuid"), ("playlists.Playlist", "playlist", serializers.PlaylistSerializer),
("federation.Actor", "account", "full_username"),
], ],
) )
def test_fetch_serializer_with_object( 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]() obj = factories[object_factory]()
fetch = factories["federation.Fetch"](object=obj) 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 = { expected = {
"id": fetch.pk, "id": fetch.pk,
"url": fetch.url, "url": fetch.url,
@ -158,7 +166,10 @@ def test_fetch_serializer_with_object(
"fetch_date": None, "fetch_date": None,
"status": fetch.status, "status": fetch.status,
"detail": fetch.detail, "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, "actor": serializers.APIActorSerializer(fetch.actor).data,
} }
@ -175,6 +186,7 @@ def test_fetch_serializer_unhandled_obj(factories, to_api_date):
"status": fetch.status, "status": fetch.status,
"detail": fetch.detail, "detail": fetch.detail,
"object": None, "object": None,
"type": None,
"actor": serializers.APIActorSerializer(fetch.actor).data, "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) fetch_task = mocker.patch.object(tasks, "fetch", side_effect=fake_task)
url = reverse("api:v1:federation:fetches-list") url = reverse("api:v1:federation:fetches-list")
data = {"object": object_id} data = {"object_uri": object_id}
response = logged_in_api_client.post(url, data) response = logged_in_api_client.post(url, data)
assert response.status_code == 201 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), creation_date=now - datetime.timedelta(seconds=59),
) )
url = reverse("api:v1:federation:fetches-list") url = reverse("api:v1:federation:fetches-list")
data = {"object": object_id} data = {"object_uri": object_id}
response = logged_in_api_client.post(url, data) response = logged_in_api_client.post(url, data)
assert response.status_code == 201 assert response.status_code == 201
assert response.data == api_serializers.FetchSerializer(duplicate).data 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), creation_date=now - datetime.timedelta(seconds=59),
) )
url = reverse("api:v1:federation:fetches-list") 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) response = logged_in_api_client.post(url, data)
fetch = actor.fetches.latest("id") 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; readonly creation_date: string;
/** Format: date-time */ /** Format: date-time */
readonly fetch_date: string | null; 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: { FetchRequest: {
object: string; object_uri: string;
/** @default false */ /** @default false */
force: boolean; force: boolean;
}; };

View File

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

View File

@ -2,6 +2,27 @@
"App": { "App": {
"loading": "Carregant…" "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": { "components": {
"About": { "About": {
"description": { "description": {
@ -4629,26 +4650,5 @@
"title": "Ràdio" "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", "radio": "Ràdio",
"albums": "{n} àlbum | {n} àlbums", "albums": "{n} àlbum | {n} àlbums",
"episodes": "{n} episodi | {n} episodis", "episodes": "{n} episodi | {n} episodis",
"by-user": "per {'@'}{username}", "by-user": "per {username}",
"aria": { "aria": {
"pagination": { "pagination": {
"gotoPrevious": "Retornar a la pàgina anterior", "gotoPrevious": "Retornar a la pàgina anterior",

View File

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

View File

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

View File

@ -2,6 +2,27 @@
"App": { "App": {
"loading": "Cargando…" "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": { "components": {
"About": { "About": {
"description": { "description": {
@ -4422,26 +4443,5 @@
"title": "Radio" "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": { "App": {
"loading": "Kargatzen…" "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": { "components": {
"About": { "About": {
"description": { "description": {
@ -4611,26 +4632,5 @@
"title": "Irratia" "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": { "App": {
"loading": "Laden…" "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": { "components": {
"About": { "About": {
"description": { "description": {
@ -4611,31 +4637,5 @@
"title": "Radio" "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": { "App": {
"loading": "Yükleniyor…" "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": { "components": {
"About": { "About": {
"description": { "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": { "App": {
"loading": "加载中…" "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": { "components": {
"About": { "About": {
"description": { "description": {
@ -4517,26 +4538,5 @@
"title": "电台" "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"> <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 type { RadioConfig } from '~/store/radios'
import axios from 'axios' import axios from 'axios'
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'
@ -9,9 +9,12 @@ import { trim, uniqBy } from 'lodash-es'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useModal } from '~/ui/composables/useModal.ts' import { useModal } from '~/ui/composables/useModal.ts'
import { useStore } from '~/store'
import ArtistCard from '~/components/artist/Card.vue' import ArtistCard from '~/components/artist/Card.vue'
import PlaylistCard from '~/components/playlists/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 TrackTable from '~/components/audio/track/Table.vue'
import AlbumCard from '~/components/album/Card.vue' import AlbumCard from '~/components/album/Card.vue'
import RadioCard from '~/components/radios/Card.vue' import RadioCard from '~/components/radios/Card.vue'
@ -98,7 +101,8 @@ type Results = {
podcasts: Response['podcasts']['results'], podcasts: Response['podcasts']['results'],
series: Response['series']['results'], series: Response['series']['results'],
rss: [Response['rss']], rss: [Response['rss']],
federation: [Response['federation']] federation: [Response['federation']],
type: Category
} }
const responses = ref<Partial<Response>>({}) const responses = ref<Partial<Response>>({})
@ -201,7 +205,7 @@ const categories = computed(() => [
endpoint: '/federation/fetches/', endpoint: '/federation/fetches/',
post: true, post: true,
params: { params: {
object: trimmedQuery.value object_uri: trimmedQuery.value
} }
} }
] as const satisfies { ] as const satisfies {
@ -222,7 +226,8 @@ const availableCategories = computed(() =>
isFetch.value ? type === 'federation' isFetch.value ? type === 'federation'
: isRss.value ? type === 'rss' : isRss.value ? type === 'rss'
: type !== 'federation' && type !== 'rss' : type !== 'federation' && type !== 'rss'
)) )
)
// Whenever available categories change, if there is exactly one, open it // Whenever available categories change, if there is exactly one, open it
watch(availableCategories, () => { watch(availableCategories, () => {
@ -245,7 +250,7 @@ const resultsPerCategory = <C extends Category>(category: { type: C }) =>
*/ */
const count = <C extends Category>(category: { type: C }) => ( const count = <C extends Category>(category: { type: C }) => (
response => response && 'count' in response ? response.count : resultsPerCategory(category).length 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 * Find out whether a category has been queried before
@ -278,6 +283,26 @@ watch(results, () => {
openSections.value = new Set(categoriesWithResults.map(({ type }) => type)) 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 // Search
const search = async () => { const search = async () => {
@ -321,18 +346,18 @@ const search = async () => {
} }
responses.value[category.type] = response.data responses.value[category.type] = response.data
} else { } else {
// TODO: add (@)type key to Response type
if (category.type === 'rss') { if (category.type === 'rss') {
const response = await axios.post<Response['rss']>( const channel = await rssSubscribe(trimmedQuery.value)
category.endpoint, if (channel) {
{ url: trimmedQuery.value } results.value.rss = [channel] // Store the subscribed channel
) }
results.value.rss = [response.data]
responses.value[category.type] = response.data
} else if (category.type === 'federation') { } else if (category.type === 'federation') {
const response = await axios.post<Response['federation']>( const response = await axios.post<Response['federation']>(
category.endpoint, category.endpoint,
{ params } { object_uri: trimmedQuery.value }
) )
results.value.type = category.type
results.value.federation = [response.data] results.value.federation = [response.data]
responses.value[category.type] = response.data responses.value[category.type] = response.data
} else if (category.type === 'playlists') { } else if (category.type === 'playlists') {
@ -340,6 +365,7 @@ const search = async () => {
category.endpoint, category.endpoint,
{ params } { params }
) )
results.value.type = category.type
results.value.playlists = response.data.results results.value.playlists = response.data.results
responses.value[category.type] = response.data responses.value[category.type] = response.data
} else if (category.type === 'podcasts') { } else if (category.type === 'podcasts') {
@ -347,6 +373,7 @@ const search = async () => {
category.endpoint, category.endpoint,
{ params } { params }
) )
results.value.type = category.type
results.value.podcasts = response.data.results results.value.podcasts = response.data.results
responses.value[category.type] = response.data responses.value[category.type] = response.data
} else if (category.type === 'radios') { } else if (category.type === 'radios') {
@ -354,6 +381,7 @@ const search = async () => {
category.endpoint, category.endpoint,
{ params } { params }
) )
results.value.type = category.type
results.value.radios = response.data.results results.value.radios = response.data.results
responses.value[category.type] = response.data responses.value[category.type] = response.data
} else if (category.type === 'series') { } else if (category.type === 'series') {
@ -361,6 +389,7 @@ const search = async () => {
category.endpoint, category.endpoint,
{ params } { params }
) )
results.value.type = category.type
results.value.series = response.data.results results.value.series = response.data.results
responses.value[category.type] = response.data responses.value[category.type] = response.data
} }
@ -378,20 +407,20 @@ const search = async () => {
const radioConfig = computed<RadioConfig | null>(() => const radioConfig = computed<RadioConfig | null>(() =>
count({ type: 'tags' }) > 0 count({ type: 'tags' }) > 0
? ({ ? ({
type: 'tag', type: 'tag',
names: resultsPerCategory({ type: 'tags' }) names: resultsPerCategory({ type: 'tags' })
.map((({ name }) => name)) .map((({ name }) => name))
}) })
: count({ type: 'playlists' }) > 0 : count({ type: 'playlists' }) > 0
? ({ ? ({
type: 'playlist', type: 'playlist',
ids: resultsPerCategory({ type: 'playlists' }).map(({ id }) => id.toString()) ids: resultsPerCategory({ type: 'playlists' }).map(({ id }) => id.toString())
}) })
: count({ type: 'artists' }) > 0 : count({ type: 'artists' }) > 0
? ({ ? ({
type: 'artist', type: 'artist',
ids: resultsPerCategory({ type: 'artists' }).map(({ id }) => id.toString()) ids: resultsPerCategory({ type: 'artists' }).map(({ id }) => id.toString())
}) })
: null : null
) )
@ -489,23 +518,50 @@ watch(queryDebounced, search, { immediate: true })
/> />
</template> </template>
<!-- If response has "url": "webfinger://node1@node1.funkwhale.test" -> Link to go directly to the federation page --> <template v-if="category.type === 'rss' && count(category) > 0">
<Alert
<span v-if="category.type === 'rss' && count(category) > 0"> blue
<Alert>{{ t('modals.search.tryAgain') }}</Alert> style="grid-column: 1 / -1"
<Link
v-for="channel in resultsPerCategory(category)"
:key="channel.artist.fid"
:to="channel.artist.fid"
autofocus
> >
{{ channel.artist.name }} {{ t('modals.search.tryAgain') }}
</Link> </Alert>
</span> <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'"> <span v-else-if="category.type === 'federation' && count(category) > 0">
<!-- TODO: Federation search: backend adapter + display, fix results_per_category query --> <template
<!-- {{ resultsPerCategory(category) }} --> 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> </span>
<EmptyState <EmptyState