Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
90698b7
Move songs query to dedicated file
Neraste Jun 10, 2025
4a6ff4c
Move artists query to dedicated file
Neraste Jun 10, 2025
146bc42
Move works query to dedicated file
Neraste Jun 10, 2025
6d748a7
Make query language use agnostic
Neraste Jun 10, 2025
1a21528
Add query for played playlist entries
Neraste Aug 2, 2025
4658291
Add query for queuing playlist entries
Neraste Aug 3, 2025
6db671f
Move views mixins to internal
Neraste Aug 3, 2025
d30396d
Rename PlayerErrorView into PlayerErrorListView
Neraste Aug 3, 2025
d10e1b5
Add query for player errors
Neraste Aug 3, 2025
f36f56a
Allow free search for entries and errors
Neraste Aug 3, 2025
fb1189a
Add query for users
Neraste Aug 3, 2025
79f49b2
Merge branch 'develop' into feature/search
Neraste Aug 4, 2025
746e44c
Factorize code
Neraste Aug 4, 2025
74dcec5
Test queries return a query dict in representation
Neraste Aug 4, 2025
dad332f
Fix wrong comparison in tests for player errors
Neraste Aug 18, 2025
f7ac951
Allow to search by ID for playlist entries and player errors
Neraste Aug 18, 2025
d6bb4f3
Allow to search by ID for songs
Neraste Aug 18, 2025
9569721
Allow to search by ID for users.
Neraste Aug 18, 2025
80419e2
Pretty up code
Neraste Nov 15, 2025
b2afa2f
Add tests for returned parsed query
Neraste Nov 22, 2025
4ed5360
Allow to search song tags
Neraste Dec 14, 2025
c202fec
Fix test artists doc
Neraste Dec 14, 2025
a3f8358
Update docstrings
Neraste Dec 19, 2025
39a9dd9
Update comments
Neraste Dec 19, 2025
5f72031
Move query language file in internals
Neraste Dec 21, 2025
4a261bd
Factorize tests
Neraste Dec 21, 2025
ff7f528
Merge branch 'develop' into feature/search
Neraste Apr 6, 2026
d1e06ee
Update changelog
Neraste Apr 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Any important notes regarding the update.
### Added

- Allow to fetch full lyrics of a song at URL `api/library/songs/lyrics/<id>/`.
- Allow to search playlist entries, player errors, users, and song tags.
- Allow to search songs, playlist entries, player errors and users by ID.

## 1.9.2 - 2025-03-22

Expand Down
2 changes: 1 addition & 1 deletion dakara_server/dakara_server/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
),
path(
"api/playlist/player/errors/",
playlist_views.PlayerErrorView.as_view(),
playlist_views.PlayerErrorListView.as_view(),
name="playlist-player-errors",
),
path(
Expand Down
78 changes: 78 additions & 0 deletions dakara_server/internal/query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from django.db.models import Q


def q(prefix, name, value):
"""Shorthand to make a query with the Q object and a prefix.

Args:
prefix (str or None): If truthy, prepended to `name`.
name (str): Name of the field.
value (str): Value for the field in the query.

Returns:
django.db.models.Q: Query.
"""
if prefix:
return Q(**{prefix + name: value})

return Q(**{name: value})


def gather_query(query_set, query_list):
"""Filter a query set by conjunction of elements of a query list.

Args:
query_set: Initial query set.
query_list (list of django.db.models.Q): List of queries. They will be
chained using the conjunction (`&=`) operator.

Returns:
New firtered query set.
"""
# now, gather the query objects
filter_query = Q()
for item in query_list:
filter_query &= item

# gather the query objects for usual relations
return query_set.filter(filter_query)


def gather_query_remain(query_set, query_list_remain):
"""Filter a query set by disjunction of elements of a query list.

Args:
query_set: Initial query set.
query_list (list of django.db.models.Q): List of queries. They will be
chained using the disjunction (`|=`) operator.

Returns:
New firtered query set.
"""
# now, gather the query objects
filter_query = Q()
for item in query_list_remain:
filter_query |= item

# gather the query objects for usual relations
return query_set.filter(filter_query)


def gather_query_many(query_set, query_list_many):
"""Chain filter a query set by elements of a query list.

Args:
query_set: Initial query set.
query_list (list of django.db.models.Q): List of queries. They will be
chained to the initial query set by the `filter` method.

Returns:
New firtered query set.
"""
query_set_filtered = query_set

# gather the query objects for custom many to many relation
for item in query_list_many:
query_set_filtered = query_set_filtered.filter(item)

return query_set_filtered
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import re

from library.models import WorkType

KEYWORDS = ["artist", "work", "title"]


class QueryLanguageParser:
"""Parser for search query mini language used to search song."""
"""Parser for search query mini language.

def __init__(self):
self.keywords_work_type = [
work_type.query_name for work_type in WorkType.objects.all()
]
Args:
keywords (list of str): List of keywords to use
for parsing.
"""

self.keywords = KEYWORDS + self.keywords_work_type
def __init__(self, keywords):
self.keywords = keywords

regex = r"""
\b(?P<keyword>{keywords_regex}) # keyword
Expand Down Expand Up @@ -86,33 +83,24 @@ def parse(self, query):
with spaces.

Returns:
dict: Query terms arranged among the following keys:
`artist`:
`contains`: List of list of artists names to match
partially.
`exact`: List of list of artists names to match exactly.
`work`:
`contains`: List of works names to match partially.
`exact`: List of works names to match exactly.
`title:
`contains`: Titles to match partially
`exact`: Titles to match exactly.
dict: Query terms parsed according to the
provided keywords. Each item is a dict
containing two lists:
`contains`: List of partial matches.
`exact`: List of exact matches.
In addition, two extra items are present in
the dict:
`tag`: List of tags to match in uppercase.
`work_type`: Dict with queryname as key and a dict as value
with the keys `contains` and `exact`.

`remaining`: Unparsed text.
"""
# create results structure
# work_type will be filled only if necessary
result = {
"artist": {"contains": [], "exact": []},
"work": {"contains": [], "exact": []},
"title": {"contains": [], "exact": []},
"work_type": {},
"remaining": [],
"tag": [],
}
result = {kw: {"contains": [], "exact": []} for kw in self.keywords}
result.update(
{
"remaining": [],
"tag": [],
}
)

for match in self.language_matcher.finditer(query):
group_index = match.groupdict()
Expand All @@ -126,21 +114,11 @@ def parse(self, query):
.strip()
)

if target in self.keywords_work_type:
# create worktype if not exists
if target not in result["work_type"]:
result["work_type"][target] = {"contains": [], "exact": []}

result_target = result["work_type"][target]

else:
result_target = result[target]

if value_contains and not value_exact:
result_target["contains"].append(value_contains)
result[target]["contains"].append(value_contains)

elif value_exact and not value_contains:
result_target["exact"].append(value_exact)
result[target]["exact"].append(value_exact)

else:
raise ValueError("Inconsistency")
Expand All @@ -158,3 +136,23 @@ def parse(self, query):
result["tag"].append(item_clean.upper())

return result


def regroup(res, key, keys):
"""Regroup non empty keys in a specific key.

Args:
res (dict): Dictionary where to regroup keys.
key (str): Key where to regroup `keys`.
keys (list of str): Keys to regroup in `key`.
Any key with no items in `exact` and
`contains` will just be removed.
"""
res_copy = res.copy()
res_copy[key] = {}
for k in keys:
val = res_copy.pop(k)
if len(val["exact"]) or len(val["contains"]):
res_copy[key][k] = val

return res_copy
27 changes: 27 additions & 0 deletions dakara_server/internal/tests/base_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.contrib.auth import get_user_model
from django.utils import timezone
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase

Expand Down Expand Up @@ -66,6 +67,32 @@ def create_user(
user.save()
return user

def check_query(self, query, expected, remaining=None):
"""Method to check a query.

Returned entries should be the same as `expected`, in the same order.

Args:
query (str): Terms of the query, to be parsed.
expected_items (list): List of expected objects, that must have an
`id`.

Returns:
Response of the client, for furthur analysis.
"""
response = self.client.get(self.url, {"query": query})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], len(expected))
results = response.data["results"]
self.assertEqual(len(results), len(expected))
for item, expected_item in zip(results, expected, strict=False):
self.assertEqual(item["id"], expected_item.id)

if remaining is not None:
self.assertEqual(response.data["query"]["remaining"], remaining)

return response


class BaseAPITestCase(APITestCase, BaseProvider):
"""Base test class for Unittest."""
Expand Down
Loading
Loading