diff --git a/app/api/__init__.py b/app/api/__init__.py index 25ab982f..db14c491 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -18,11 +18,11 @@ from .plugins import mixes as mixes_plugin from app.api import ( album, artist, + collections, colors, favorites, folder, imgserver, - pages, playlist, search, settings, @@ -112,7 +112,7 @@ def create_api(): app.register_api(colors.api) app.register_api(lyrics.api) app.register_api(backup_and_restore.api) - app.register_api(pages.api) + app.register_api(collections.api) # Plugins app.register_api(plugins.api) app.register_api(lyrics_plugin.api) diff --git a/app/api/collections.py b/app/api/collections.py new file mode 100644 index 00000000..d464b670 --- /dev/null +++ b/app/api/collections.py @@ -0,0 +1,182 @@ +""" +Contains all the collection routes. +""" + +from typing import Any + +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint +from pydantic import BaseModel, Field + +from app.db.userdata import CollectionTable +from app.lib.pagelib import recover_page_items, remove_page_items, validate_page_items +from app.utils.auth import get_current_userid + +bp_tag = Tag(name="Collections", description="Collections") +api = APIBlueprint( + "collections", __name__, url_prefix="/collections", abp_tags=[bp_tag] +) + + +class CreateCollectionBody(BaseModel): + name: str = Field(description="The name of the collection") + description: str = Field(description="The description of the collection") + items: list[dict[str, Any]] = Field( + description="The items to add to the collection", + json_schema_extra={"example": [{"type": "album", "hash": "1234567890"}]}, + ) + + +@api.post("") +def create_collection(body: CreateCollectionBody): + """ + Create a new collection. + """ + items = validate_page_items(body.items, existing=[]) + + if len(items) == 0: + return {"error": "No items to add"}, 400 + + payload = { + "name": body.name, + "items": items, + "userid": get_current_userid(), + "extra": { + "description": body.description, + }, + } + + CollectionTable.insert_one(payload) + + return {"message": "collection created"}, 201 + + +@api.get("") +def get_collections(): + """ + Get all collections. + """ + return [collection for collection in CollectionTable.get_all()] + + +class AddCollectionItemBody(BaseModel): + item: dict[str, Any] = Field( + description="The item to add to the collection", + json_schema_extra={"example": {"type": "album", "hash": "1234567890"}}, + ) + + +class AddCollectionItemPath(BaseModel): + collection_id: int = Field( + description="The ID of the collection to add items to", + json_schema_extra={"example": 1}, + ) + + +@api.post("//items") +def add_collection_item(path: AddCollectionItemPath, body: AddCollectionItemBody): + """ + Add an item to a collection. + """ + collection = CollectionTable.get_by_id(path.collection_id) + + if collection is None: + return {"error": "Collection not found"}, 404 + + new_items = validate_page_items([body.item], existing=collection["items"]) + + if len(new_items) == 0: + return {"error": "items already in collection"}, 400 + + collection["items"].extend(new_items) + CollectionTable.update_items(collection["id"], collection["items"]) + + return {"message": "Items added to collection"} + + +class RemoveCollectionItemBody(BaseModel): + item: dict[str, Any] = Field( + description="The item to remove from the collection", + json_schema_extra={"example": {"type": "album", "hash": "1234567890"}}, + ) + + +class RemoveCollectionItemPath(BaseModel): + collection_id: int = Field( + description="The ID of the collection to remove items from" + ) + + +@api.delete("//items") +def remove_collection_item( + path: RemoveCollectionItemPath, body: RemoveCollectionItemBody +): + """ + Remove an item from a collection. + """ + collection = CollectionTable.get_by_id(path.collection_id) + + if collection is None: + return {"error": "Collection not found"}, 404 + + remaining = remove_page_items(collection["items"], body.item) + CollectionTable.update_items(collection["id"], remaining) + + return {"message": "Item removed from collection"} + + +class GetCollectionBody(BaseModel): + collection_id: int = Field(description="The ID of the collection to get") + + +@api.get("/") +def get_collection(path: GetCollectionBody): + """ + Get a collection. + """ + collection = CollectionTable.get_by_id(path.collection_id) + if not collection: + return {"error": "Collection not found"}, 404 + + items = recover_page_items(collection["items"]) + return { + "id": collection["id"], + "name": collection["name"], + "items": items, + "extra": collection["extra"], + } + + +class UpdateCollectionBody(BaseModel): + name: str = Field(description="The name of the collection") + description: str = Field( + description="The description of the collection", default="" + ) + + +@api.put("/") +def update_collection(path: GetCollectionBody, body: UpdateCollectionBody): + """ + Update a collection. + """ + payload = { + "id": path.collection_id, + "name": body.name, + "extra": {"description": body.description}, + } + + CollectionTable.update_one(payload) + return payload + + +class DeleteCollectionPath(BaseModel): + collection_id: int = Field(description="The ID of the collection to delete") + + +@api.delete("/") +def delete_collection(path: DeleteCollectionPath): + """ + Delete a collection. + """ + CollectionTable.delete_by_id(path.collection_id) + return {"message": "Collection deleted"} diff --git a/app/api/favorites.py b/app/api/favorites.py index 5f9e684a..fe21758f 100644 --- a/app/api/favorites.py +++ b/app/api/favorites.py @@ -39,9 +39,8 @@ class FavoritesAddBody(BaseModel): description="The hash of the item", min_length=Defaults.HASH_LENGTH, max_length=Defaults.HASH_LENGTH, - example=Defaults.API_ALBUMHASH, ) - type: str = Field(description="The type of the item", example=FavType.album) + type: str = Field(description="The type of the item") def toggle_fav(type: str, hash: str): @@ -110,7 +109,6 @@ class GetAllOfTypeQuery(GenericLimitSchema): start: int = Field( description="Where to start from", - example=Defaults.API_CARD_LIMIT, default=Defaults.API_CARD_LIMIT, ) @@ -167,19 +165,16 @@ class GetAllFavoritesQuery(BaseModel): track_limit: int = Field( description="The number of tracks to return", - example=Defaults.API_CARD_LIMIT, default=Defaults.API_CARD_LIMIT, ) album_limit: int = Field( description="The number of albums to return", - example=Defaults.API_CARD_LIMIT, default=Defaults.API_CARD_LIMIT, ) artist_limit: int = Field( description="The number of artists to return", - example=Defaults.API_CARD_LIMIT, default=Defaults.API_CARD_LIMIT, ) diff --git a/app/api/pages.py b/app/api/pages.py deleted file mode 100644 index 45290f66..00000000 --- a/app/api/pages.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -Contains all the page routes. -""" - -from typing import Any - -from flask_openapi3 import Tag -from flask_openapi3 import APIBlueprint -from pydantic import BaseModel, Field - -from app.db.userdata import PageTable -from app.lib.pagelib import recover_page_items, remove_page_items, validate_page_items -from app.utils.auth import get_current_userid - -bp_tag = Tag(name="Pages", description="Pages") -api = APIBlueprint("pages", __name__, url_prefix="/pages", abp_tags=[bp_tag]) - - -class CreatePageBody(BaseModel): - name: str = Field(description="The name of the page", example="My Page") - description: str = Field( - description="The description of the page", example="My Page" - ) - items: list[dict[str, Any]] = Field( - description="The items to add to the page", - example=[{"type": "album", "hash": "1234567890"}], - ) - - -@api.post("") -def create_page(body: CreatePageBody): - """ - Create a new page. - """ - items = validate_page_items(body.items, existing=[]) - - if len(items) == 0: - return {"error": "No items to add"}, 400 - - payload = { - "name": body.name, - "items": items, - "userid": get_current_userid(), - "extra": { - "description": body.description, - }, - } - - PageTable.insert_one(payload) - - return {"message": "Page created"}, 201 - - -@api.get("") -def get_pages(): - """ - Get all pages. - """ - return [page for page in PageTable.get_all()] - - -class AddPageItemBody(BaseModel): - item: dict[str, Any] = Field( - description="The item to add to the page", - example={"type": "album", "hash": "1234567890"}, - ) - - -class AddPageItemPath(BaseModel): - page_id: int = Field(description="The ID of the page to add items to", example=1) - - -@api.post("//items") -def add_page_item(path: AddPageItemPath, body: AddPageItemBody): - """ - Add an item to a page. - """ - page = PageTable.get_by_id(path.page_id) - - if page is None: - return {"error": "Page not found"}, 404 - - new_items = validate_page_items([body.item], existing=page["items"]) - - if len(new_items) == 0: - return {"error": "items already in page"}, 400 - - page["items"].extend(new_items) - PageTable.update_items(page["id"], page["items"]) - - return {"message": "Items added to page"} - - -class RemovePageItemBody(BaseModel): - item: dict[str, Any] = Field( - description="The item to remove from the page", - example={"type": "album", "hash": "1234567890"}, - ) - - -class RemovePageItemPath(BaseModel): - page_id: int = Field(description="The ID of the page to remove items from") - - -@api.delete("//items") -def remove_page_item(path: RemovePageItemPath, body: RemovePageItemBody): - """ - Remove an item from a page. - """ - page = PageTable.get_by_id(path.page_id) - - if page is None: - return {"error": "Page not found"}, 404 - - remaining = remove_page_items(page["items"], body.item) - PageTable.update_items(page["id"], remaining) - - return {"message": "Item removed from page"} - - -class GetPageBody(BaseModel): - page_id: int = Field(description="The ID of the page to get", example=1) - - -@api.get("/") -def get_page(path: GetPageBody): - """ - Get a page. - """ - page = PageTable.get_by_id(path.page_id) - if not page: - return {"error": "Page not found"}, 404 - - items = recover_page_items(page["items"]) - return { - "id": page["id"], - "name": page["name"], - "items": items, - "extra": page["extra"], - } - - -class UpdatePageBody(BaseModel): - name: str = Field(description="The name of the page") - description: str = Field(description="The description of the page", default="") - - -@api.put("/") -def update_page(path: GetPageBody, body: UpdatePageBody): - """ - Update a page. - """ - payload = { - "id": path.page_id, - "name": body.name, - "extra": {"description": body.description}, - } - - PageTable.update_one(payload) - return {"page": payload} - - -class DeletePagePath(BaseModel): - page_id: int = Field(description="The ID of the page to delete") - - -@api.delete("/") -def delete_page(path: DeletePagePath): - """ - Delete a page. - """ - PageTable.delete_by_id(path.page_id) - return {"message": "Page deleted"} diff --git a/app/crons/mixes.py b/app/crons/mixes.py index 8683b6f0..35eb0693 100644 --- a/app/crons/mixes.py +++ b/app/crons/mixes.py @@ -9,7 +9,7 @@ class Mixes(CronJob): """ name: str = "mixes" - hours: int = 6 + hours: int = 12 def __init__(self): super().__init__() diff --git a/app/db/userdata.py b/app/db/userdata.py index c0884b15..ac74eb5d 100644 --- a/app/db/userdata.py +++ b/app/db/userdata.py @@ -222,6 +222,9 @@ class FavoritesTable(Base): @classmethod def insert_item(cls, item: dict[str, Any]): + # guard against hash collisions for different item types + item["hash"] = f"{item['type']}_{item['hash']}" + item["timestamp"] = int(datetime.datetime.now().timestamp()) item["userid"] = get_current_userid() @@ -232,16 +235,29 @@ class FavoritesTable(Base): return next( cls.execute( delete(cls).where( - (cls.hash == item["hash"]) & (cls.type == item["type"]) - ) + (cls.hash == item["hash"]) + | (cls.hash == f"{item['type']}_{item['hash']}") + ), + commit=True, ) ) @classmethod def check_exists(cls, hash: str, type: str): - result = cls.execute(select(cls).where((cls.hash == hash) & (cls.type == type))) + result = cls.execute( + select(cls).where((cls.hash == hash) | (cls.hash == f"{type}_{hash}")) + ) + return next(result).scalar() is not None + @classmethod + def get_by_hash(cls, hash: str, type: str): + result = cls.execute( + select(cls).where((cls.hash == hash) | (cls.hash == f"{type}_{hash}")) + ) + + return next(result).scalars().all() + @classmethod def get_all_of_type(cls, type: str, start: int, limit: int): result = cls.execute( @@ -647,7 +663,8 @@ class MixTable(Base): return mix.extra["trackmix_saved"] -class PageTable(Base): +class CollectionTable(Base): + # INFO: table name was kept as page to avoid breaking existing data __tablename__ = "page" id: Mapped[int] = mapped_column(primary_key=True) diff --git a/app/models/favorite.py b/app/models/favorite.py index ab02d593..07858bb1 100644 --- a/app/models/favorite.py +++ b/app/models/favorite.py @@ -8,4 +8,8 @@ class Favorite: type: Literal["album", "track", "artist"] timestamp: int userid: int - extra: dict[str, Any] \ No newline at end of file + extra: dict[str, Any] + + def __post_init__(self): + # remove the type prefix from the hash + self.hash = self.hash.replace(f"{self.type}_", "") diff --git a/app/store/homepage.py b/app/store/homepage.py index 2b0f66a9..587d817c 100644 --- a/app/store/homepage.py +++ b/app/store/homepage.py @@ -1,6 +1,6 @@ from typing import Any -from app.db.userdata import PageTable +from app.db.userdata import CollectionTable from app.lib.pagelib import recover_page_items from app.store.homepageentries import ( BecauseYouListenedToArtistHomepageEntry, @@ -65,7 +65,7 @@ class HomepageStore: @classmethod def get_homepage_items(cls, limit: int): # return a dict of entry name to entry items - pages = PageTable.get_all() + pages = CollectionTable.get_all() pagedata = [] for page in pages: @@ -76,7 +76,7 @@ class HomepageStore: "title": page["name"], "description": page["extra"]["description"], "items": recover_page_items(page["items"], for_homepage=True), - "url": f"pages/{page['id']}", + "url": f"collections/{page['id']}", } } )