diff --git a/backend/app/database/albums.py b/backend/app/database/albums.py index b9e5b149a..71a8111c6 100644 --- a/backend/app/database/albums.py +++ b/backend/app/database/albums.py @@ -1,226 +1,296 @@ import sqlite3 import bcrypt +from typing import List, Tuple, Optional from app.config.settings import DATABASE_PATH -from app.database.connection import get_db_connection +from app.logging.setup_logging import get_logger +# Initialize logger for this module +logger = get_logger(__name__) + +def _connect() -> sqlite3.Connection: + """Helper to establish database connection with foreign keys enabled.""" + conn = sqlite3.connect(DATABASE_PATH) + conn.execute("PRAGMA foreign_keys = ON") + return conn def db_create_albums_table() -> None: - conn = None + """ + Creates the albums and album_media tables. + Refactored to support cover images, timestamps, and mixed media (video/image). + """ + conn = _connect() + cursor = conn.cursor() try: - conn = sqlite3.connect(DATABASE_PATH) - cursor = conn.cursor() - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS albums ( - album_id TEXT PRIMARY KEY, - album_name TEXT UNIQUE, - description TEXT, - is_hidden BOOLEAN DEFAULT 0, - password_hash TEXT - ) - """ - ) - conn.commit() - finally: - if conn is not None: - conn.close() - + # 1. Create Albums Table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS albums ( + album_id TEXT PRIMARY KEY, + album_name TEXT UNIQUE NOT NULL, + description TEXT, + cover_image_id TEXT, + is_hidden BOOLEAN DEFAULT 0, + password_hash TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (cover_image_id) REFERENCES images(id) ON DELETE SET NULL + ); + """) -def db_create_album_images_table() -> None: - conn = None - try: - conn = sqlite3.connect(DATABASE_PATH) - cursor = conn.cursor() - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS album_images ( - album_id TEXT, - image_id TEXT, - PRIMARY KEY (album_id, image_id), - FOREIGN KEY (album_id) REFERENCES albums(album_id) ON DELETE CASCADE, - FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE - ) - """ - ) + # 2. Create Album Media Junction Table (Replaces album_images) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS album_media ( + id TEXT PRIMARY KEY, + album_id TEXT NOT NULL, + media_id TEXT NOT NULL, + media_type TEXT NOT NULL CHECK(media_type IN ('image', 'video')), + added_at TEXT DEFAULT CURRENT_TIMESTAMP, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (album_id) REFERENCES albums(album_id) ON DELETE CASCADE, + FOREIGN KEY (media_id) REFERENCES images(id) ON DELETE CASCADE, + UNIQUE(album_id, media_id) + ); + """) + conn.commit() + logger.info("Albums and Album_Media tables checked/created successfully.") + except Exception as e: + logger.error(f"Error creating albums table: {e}") finally: - if conn is not None: - conn.close() + conn.close() +# --- CRUD Operations --- -def db_get_all_albums(show_hidden: bool = False): - conn = sqlite3.connect(DATABASE_PATH) +def db_get_all_albums(show_hidden: bool = False) -> List[dict]: + conn = _connect() + conn.row_factory = sqlite3.Row # Allows accessing columns by name cursor = conn.cursor() try: - if show_hidden: - cursor.execute("SELECT * FROM albums") - else: - cursor.execute("SELECT * FROM albums WHERE is_hidden = 0") - albums = cursor.fetchall() - return albums + query = "SELECT * FROM albums" + if not show_hidden: + query += " WHERE is_hidden = 0" + + query += " ORDER BY created_at DESC" + + cursor.execute(query) + rows = cursor.fetchall() + return [dict(row) for row in rows] finally: conn.close() - -def db_get_album_by_name(name: str): - conn = sqlite3.connect(DATABASE_PATH) +def db_get_album(album_id: str) -> Optional[dict]: + conn = _connect() + conn.row_factory = sqlite3.Row cursor = conn.cursor() try: - cursor.execute("SELECT * FROM albums WHERE album_name = ?", (name,)) - album = cursor.fetchone() - return album if album else None + cursor.execute("SELECT * FROM albums WHERE album_id = ?", (album_id,)) + row = cursor.fetchone() + return dict(row) if row else None finally: conn.close() - -def db_get_album(album_id: str): - conn = sqlite3.connect(DATABASE_PATH) +def db_get_album_by_name(name: str) -> Optional[dict]: + conn = _connect() + conn.row_factory = sqlite3.Row cursor = conn.cursor() try: - cursor.execute("SELECT * FROM albums WHERE album_id = ?", (album_id,)) - album = cursor.fetchone() - return album if album else None + cursor.execute("SELECT * FROM albums WHERE album_name = ?", (name,)) + row = cursor.fetchone() + return dict(row) if row else None finally: conn.close() - def db_insert_album( album_id: str, album_name: str, description: str = "", + cover_image_id: Optional[str] = None, is_hidden: bool = False, password: str = None, -): - conn = sqlite3.connect(DATABASE_PATH) +) -> bool: + conn = _connect() cursor = conn.cursor() try: + if cover_image_id: + # We add "AND media_type = 'image'" to ensure it's not a video + cursor.execute("SELECT 1 FROM images WHERE id = ? AND media_type = 'image'", (cover_image_id,)) + if not cursor.fetchone(): + logger.error(f"Invalid cover_image_id (or not an image): {cover_image_id}") + return False password_hash = None if password: password_hash = bcrypt.hashpw( password.encode("utf-8"), bcrypt.gensalt() ).decode("utf-8") + cursor.execute( """ - INSERT INTO albums (album_id, album_name, description, is_hidden, password_hash) - VALUES (?, ?, ?, ?, ?) + INSERT INTO albums (album_id, album_name, description, cover_image_id, is_hidden, password_hash) + VALUES (?, ?, ?, ?, ?, ?) """, - (album_id, album_name, description, int(is_hidden), password_hash), + (album_id, album_name, description, cover_image_id, int(is_hidden), password_hash), ) conn.commit() + return True + except sqlite3.IntegrityError as e: + logger.warning(f"Album creation failed (duplicate name?): {e}") + return False finally: conn.close() - def db_update_album( - album_id: str, - album_name: str, - description: str, - is_hidden: bool, - password: str = None, -): - conn = sqlite3.connect(DATABASE_PATH) + album_id: str, + album_name: str, + description: str, + cover_image_id: Optional[str], + is_hidden: bool, + password: Optional[str] = None +) -> bool: + """ + Updates album details. + - password=None: Do not change the password. + - password="": Remove the password (make public). + - password="str": Update/Set the password. + """ + conn = _connect() cursor = conn.cursor() + + if cover_image_id: + cursor.execute("SELECT 1 FROM images WHERE id = ? AND media_type = 'image'", (cover_image_id,)) + if not cursor.fetchone(): + logger.error(f"Cannot update album {album_id}: Invalid cover_image_id or not an image {cover_image_id}") + conn.close() + return False + # Base fields to update + updates = [ + "album_name = ?", + "description = ?", + "cover_image_id = ?", + "is_hidden = ?", + "updated_at = CURRENT_TIMESTAMP" + ] + params = [album_name, description, cover_image_id, is_hidden] + + # Handle Password Logic + if password == "": + # Case: Remove password protection + updates.append("password_hash = NULL") + elif password: + # Case: Update/Set new password + hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + updates.append("password_hash = ?") + params.append(hashed) + # Case: password is None -> Do nothing (keep existing) + + params.append(album_id) # Add ID for the WHERE clause + + query = f"UPDATE albums SET {', '.join(updates)} WHERE album_id = ?" + try: - if password is not None: - # Update with new password - password_hash = bcrypt.hashpw( - password.encode("utf-8"), bcrypt.gensalt() - ).decode("utf-8") - cursor.execute( - """ - UPDATE albums - SET album_name = ?, description = ?, is_hidden = ?, password_hash = ? - WHERE album_id = ? - """, - (album_name, description, int(is_hidden), password_hash, album_id), - ) - else: - # Update without changing password - cursor.execute( - """ - UPDATE albums - SET album_name = ?, description = ?, is_hidden = ? - WHERE album_id = ? - """, - (album_name, description, int(is_hidden), album_id), - ) + cursor.execute(query, tuple(params)) conn.commit() + return True + except Exception as e: + logger.error(f"Error updating album {album_id}: {e}") + return False finally: conn.close() - -def db_delete_album(album_id: str): - with get_db_connection() as conn: +def db_delete_album(album_id: str) -> bool: + conn = _connect() + try: cursor = conn.cursor() cursor.execute("DELETE FROM albums WHERE album_id = ?", (album_id,)) + rows_affected = cursor.rowcount + conn.commit() + return rows_affected > 0 + finally: + conn.close() +# --- Media Operations (Images & Videos) --- -def db_get_album_images(album_id: str): - conn = sqlite3.connect(DATABASE_PATH) +def db_get_album_media(album_id: str) -> List[dict]: + """Returns a list of media items (id, type) for the album.""" + conn = _connect() + conn.row_factory = sqlite3.Row cursor = conn.cursor() try: cursor.execute( - "SELECT image_id FROM album_images WHERE album_id = ?", (album_id,) + """ + SELECT media_id, media_type, added_at + FROM album_media + WHERE album_id = ? + ORDER BY sort_order ASC, added_at DESC + """, + (album_id,) ) - images = cursor.fetchall() - return [img[0] for img in images] + rows = cursor.fetchall() + return [dict(row) for row in rows] finally: conn.close() +def db_add_media_to_album(album_id: str, media_items: List[Tuple[str, str]]) -> int: + """Adds a list of (media_id, media_type) to the album.""" + conn = _connect() + cursor = conn.cursor() + count = 0 + try: + # 1. FIX: Explicitly check if album exists first + cursor.execute("SELECT 1 FROM albums WHERE album_id = ?", (album_id,)) + if not cursor.fetchone(): + logger.error(f"Album {album_id} not found.") + return 0 -def db_add_images_to_album(album_id: str, image_ids: list[str]): - with get_db_connection() as conn: - cursor = conn.cursor() - - query = ( - f"SELECT id FROM images WHERE id IN ({','.join('?' for _ in image_ids)})" - ) - cursor.execute(query, image_ids) - valid_images = [row[0] for row in cursor.fetchall()] + # Get current max sort order + cursor.execute("SELECT MAX(sort_order) FROM album_media WHERE album_id = ?", (album_id,)) + result = cursor.fetchone() + current_max_sort = result[0] if result[0] is not None else 0 - if valid_images: - cursor.executemany( - "INSERT OR IGNORE INTO album_images (album_id, image_id) VALUES (?, ?)", - [(album_id, img_id) for img_id in valid_images], - ) - else: - raise ValueError("None of the provided image IDs exist in the database.") + for i, (media_id, media_type) in enumerate(media_items): + if media_type not in ('image', 'video'): + logger.warning(f"Skipping invalid media_type: {media_type}") + continue + cursor.execute("SELECT 1 FROM images WHERE id = ? AND media_type = ?", (media_id, media_type)) + + if not cursor.fetchone(): + logger.warning(f"Skipping invalid media_id or mismatched type: {media_id} ({media_type})") + continue + junction_id = f"{album_id}_{media_id}" + + try: + cursor.execute( + "INSERT INTO album_media (id, album_id, media_id, media_type, sort_order) VALUES (?, ?, ?, ?, ?)", + (junction_id, album_id, media_id, media_type, current_max_sort + i + 1) + ) + count += 1 + except sqlite3.IntegrityError: + # 3. FIX: Now we know this is strictly a DUPLICATE entry (Unique Constraint), + # because we already verified the Album and Media IDs exist. + continue + + conn.commit() + except Exception as e: + logger.error(f"Error adding media to album: {e}") + return 0 + finally: + conn.close() + + return count -def db_remove_image_from_album(album_id: str, image_id: str): - with get_db_connection() as conn: +def db_remove_media_from_album(album_id: str, media_id: str) -> None: + conn = _connect() + try: cursor = conn.cursor() - cursor.execute( - "SELECT 1 FROM album_images WHERE album_id = ? AND image_id = ?", - (album_id, image_id), - ) - exists = cursor.fetchone() - - if exists: - cursor.execute( - "DELETE FROM album_images WHERE album_id = ? AND image_id = ?", - (album_id, image_id), - ) - else: - raise ValueError("Image not found in the specified album") - - -def db_remove_images_from_album(album_id: str, image_ids: list[str]): - conn = sqlite3.connect(DATABASE_PATH) - cursor = conn.cursor() - try: - cursor.executemany( - "DELETE FROM album_images WHERE album_id = ? AND image_id = ?", - [(album_id, img_id) for img_id in image_ids], + "DELETE FROM album_media WHERE album_id = ? AND media_id = ?", + (album_id, media_id), ) conn.commit() finally: conn.close() - def verify_album_password(album_id: str, password: str) -> bool: - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: cursor.execute( @@ -231,4 +301,4 @@ def verify_album_password(album_id: str, password: str) -> bool: return False return bcrypt.checkpw(password.encode("utf-8"), row[0].encode("utf-8")) finally: - conn.close() + conn.close() \ No newline at end of file diff --git a/backend/app/database/images.py b/backend/app/database/images.py index ec9541a56..26f459327 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -27,6 +27,7 @@ class ImageRecord(TypedDict): thumbnailPath: str metadata: Union[Mapping[str, Any], str] isTagged: bool + media_type: str class UntaggedImageRecord(TypedDict): @@ -64,6 +65,7 @@ def db_create_images_table() -> None: metadata TEXT, isTagged BOOLEAN DEFAULT 0, isFavourite BOOLEAN DEFAULT 0, + media_type TEXT DEFAULT 'image' CHECK(media_type IN ('image', 'video')), FOREIGN KEY (folder_id) REFERENCES folders(folder_id) ON DELETE CASCADE ) """ @@ -97,12 +99,13 @@ def db_bulk_insert_images(image_records: List[ImageRecord]) -> bool: try: cursor.executemany( """ - INSERT INTO images (id, path, folder_id, thumbnailPath, metadata, isTagged) - VALUES (:id, :path, :folder_id, :thumbnailPath, :metadata, :isTagged) + INSERT INTO images (id, path, folder_id, thumbnailPath, metadata, isTagged, media_type) + VALUES (:id, :path, :folder_id, :thumbnailPath, :metadata, :isTagged, :media_type) ON CONFLICT(path) DO UPDATE SET folder_id=excluded.folder_id, thumbnailPath=excluded.thumbnailPath, metadata=excluded.metadata, + media_type=excluded.media_type, isTagged=CASE WHEN excluded.isTagged THEN 1 ELSE images.isTagged @@ -123,13 +126,6 @@ def db_bulk_insert_images(image_records: List[ImageRecord]) -> bool: def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]: """ Get all images from the database with their tags. - - Args: - tagged: Optional filter for tagged status. If None, returns all images. - If True, returns only tagged images. If False, returns only untagged images. - - Returns: - List of dictionaries containing all image data including tags """ conn = _connect() cursor = conn.cursor() @@ -145,6 +141,7 @@ def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]: i.metadata, i.isTagged, i.isFavourite, + i.media_type, m.name as tag_name FROM images i LEFT JOIN image_classes ic ON i.id = ic.image_id @@ -172,6 +169,7 @@ def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]: metadata, is_tagged, is_favourite, + media_type, tag_name, ) in results: if image_id not in images_dict: @@ -188,6 +186,7 @@ def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]: "metadata": metadata_dict, "isTagged": bool(is_tagged), "isFavourite": bool(is_favourite), + "media_type": media_type, "tags": [], } @@ -418,4 +417,4 @@ def db_toggle_image_favourite_status(image_id: str) -> bool: conn.rollback() return False finally: - conn.close() + conn.close() \ No newline at end of file diff --git a/backend/app/routes/albums.py b/backend/app/routes/albums.py index ae0408613..6870aaefe 100644 --- a/backend/app/routes/albums.py +++ b/backend/app/routes/albums.py @@ -1,18 +1,22 @@ from fastapi import APIRouter, HTTPException, status, Query, Body, Path import uuid +from typing import List + +# Updated Schema Imports from app.schemas.album import ( GetAlbumsResponse, CreateAlbumRequest, CreateAlbumResponse, GetAlbumResponse, - GetAlbumImagesRequest, - GetAlbumImagesResponse, UpdateAlbumRequest, SuccessResponse, ErrorResponse, - ImageIdsRequest, Album, + AddMediaRequest, + GetAlbumMediaResponse ) + +# Updated Database Imports from app.database.albums import ( db_get_all_albums, db_get_album_by_name, @@ -20,28 +24,30 @@ db_insert_album, db_update_album, db_delete_album, - db_get_album_images, - db_add_images_to_album, - db_remove_image_from_album, - db_remove_images_from_album, + db_get_album_media, + db_add_media_to_album, + db_remove_media_from_album, verify_album_password, ) router = APIRouter() - # GET /albums/ - Get all albums @router.get("/", response_model=GetAlbumsResponse) def get_albums(show_hidden: bool = Query(False)): - albums = db_get_all_albums(show_hidden) + albums_data = db_get_all_albums(show_hidden) album_list = [] - for album in albums: + + for album in albums_data: album_list.append( Album( - album_id=album[0], - album_name=album[1], - description=album[2] or "", - is_hidden=bool(album[3]), + album_id=album["album_id"], + album_name=album["album_name"], + description=album["description"] or "", + cover_image_id=album["cover_image_id"], # Fixed: correctly mapping new column + is_hidden=bool(album["is_hidden"]), + created_at=str(album["created_at"]), + updated_at=str(album["updated_at"]) ) ) return GetAlbumsResponse(success=True, albums=album_list) @@ -63,8 +69,14 @@ def create_album(body: CreateAlbumRequest): album_id = str(uuid.uuid4()) try: + # FIXED: Passing all 6 arguments correctly db_insert_album( - album_id, body.name, body.description, body.is_hidden, body.password + album_id=album_id, + album_name=body.name, + description=body.description, + cover_image_id=body.cover_image_id, + is_hidden=body.is_hidden, + password=body.password ) return CreateAlbumResponse(success=True, album_id=album_id) except Exception as e: @@ -92,10 +104,13 @@ def get_album(album_id: str = Path(...)): try: album_obj = Album( - album_id=album[0], - album_name=album[1], - description=album[2] or "", - is_hidden=bool(album[3]), + album_id=album["album_id"], + album_name=album["album_name"], + description=album["description"] or "", + cover_image_id=album["cover_image_id"], + is_hidden=bool(album["is_hidden"]), + created_at=str(album["created_at"]), + updated_at=str(album["updated_at"]) ) return GetAlbumResponse(success=True, data=album_obj) except Exception as e: @@ -123,15 +138,13 @@ def update_album(album_id: str = Path(...), body: UpdateAlbumRequest = Body(...) ).model_dump(), ) - album_dict = { - "album_id": album[0], - "album_name": album[1], - "description": album[2], - "is_hidden": bool(album[3]), - "password_hash": album[4], - } + # Use existing values if not provided in body + current_name = body.name if body.name is not None else album["album_name"] + current_desc = body.description if body.description is not None else album["description"] + current_cover = body.cover_image_id if body.cover_image_id is not None else album["cover_image_id"] + current_hidden = body.is_hidden if body.is_hidden is not None else bool(album["is_hidden"]) - if album_dict["password_hash"]: + if album["password_hash"]: if not body.current_password: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -153,8 +166,14 @@ def update_album(album_id: str = Path(...), body: UpdateAlbumRequest = Body(...) ) try: + # FIXED: Passing all arguments correctly db_update_album( - album_id, body.name, body.description, body.is_hidden, body.password + album_id=album_id, + album_name=current_name, + description=current_desc, + cover_image_id=current_cover, + is_hidden=current_hidden, + password=body.password ) return SuccessResponse(success=True, msg="Album updated successfully") except Exception as e: @@ -169,8 +188,7 @@ def update_album(album_id: str = Path(...), body: UpdateAlbumRequest = Body(...) # DELETE /albums/{album_id} - Delete an album @router.delete("/{album_id}", response_model=SuccessResponse) def delete_album(album_id: str = Path(...)): - album = db_get_album(album_id) - if not album: + if not db_get_album(album_id): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ErrorResponse( @@ -192,35 +210,23 @@ def delete_album(album_id: str = Path(...)): ) -# GET /albums/{album_id}/images - Get all images in an album -@router.post("/{album_id}/images/get", response_model=GetAlbumImagesResponse) -# GET requests do not accept a body by default. -# Since we need to send a password securely, switching this to POST -- necessary. -# Open to suggestions if better approach possible. -def get_album_images( - album_id: str = Path(...), body: GetAlbumImagesRequest = Body(...) +# GET /albums/{album_id}/media - Get all images/videos +@router.get("/{album_id}/media", response_model=GetAlbumMediaResponse) +def get_album_media( + album_id: str = Path(...), + password: str = Query(None) # Fixed: Using Query param for GET request ): album = db_get_album(album_id) if not album: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ErrorResponse( - success=False, - error="Album Not Found", - message="No album exists with the provided ID.", + success=False, error="Album Not Found", message="Album not found" ).model_dump(), ) - album_dict = { - "album_id": album[0], - "album_name": album[1], - "description": album[2], - "is_hidden": bool(album[3]), - "password_hash": album[4], - } - - if album_dict["is_hidden"]: - if not body.password: + if album["is_hidden"]: + if not password: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorResponse( @@ -229,7 +235,7 @@ def get_album_images( message="Password is required to access this hidden album.", ).model_dump(), ) - if not verify_album_password(album_id, body.password): + if not verify_album_password(album_id, password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorResponse( @@ -240,22 +246,21 @@ def get_album_images( ) try: - image_ids = db_get_album_images(album_id) - return GetAlbumImagesResponse(success=True, image_ids=image_ids) + media_items = db_get_album_media(album_id) + return GetAlbumMediaResponse(success=True, media_items=media_items) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ErrorResponse( - success=False, error="Failed to Retrieve Images", message=str(e) + success=False, error="Failed to Retrieve Media", message=str(e) ).model_dump(), ) -# POST /albums/{album_id}/images - Add images to an album -@router.post("/{album_id}/images", response_model=SuccessResponse) -def add_images_to_album(album_id: str = Path(...), body: ImageIdsRequest = Body(...)): - album = db_get_album(album_id) - if not album: +# POST /albums/{album_id}/media - Add images/videos +@router.post("/{album_id}/media", response_model=SuccessResponse) +def add_media_to_album(album_id: str = Path(...), body: AddMediaRequest = Body(...)): + if not db_get_album(album_id): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ErrorResponse( @@ -265,93 +270,52 @@ def add_images_to_album(album_id: str = Path(...), body: ImageIdsRequest = Body( ).model_dump(), ) - if not body.image_ids: + if not body.media_items: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ErrorResponse( success=False, - error="No Image IDs", - message="You must provide a list of image IDs to add.", + error="No Media Items", + message="You must provide at least one item to add.", ).model_dump(), ) try: - db_add_images_to_album(album_id, body.image_ids) + items_to_add = [(item.media_id, item.media_type) for item in body.media_items] + + count = db_add_media_to_album(album_id, items_to_add) return SuccessResponse( - success=True, msg=f"Added {len(body.image_ids)} images to album" + success=True, msg=f"Added {count} items to album" ) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ErrorResponse( - success=False, error="Failed to Add Images", message=str(e) + success=False, error="Failed to Add Media", message=str(e) ).model_dump(), ) -# DELETE /albums/{album_id}/images/{image_id} - Remove image from album -@router.delete("/{album_id}/images/{image_id}", response_model=SuccessResponse) -def remove_image_from_album(album_id: str = Path(...), image_id: str = Path(...)): - album = db_get_album(album_id) - if not album: +# DELETE /albums/{album_id}/media/{media_id} - Remove item +@router.delete("/{album_id}/media/{media_id}", response_model=SuccessResponse) +def remove_media_from_album(album_id: str = Path(...), media_id: str = Path(...)): + if not db_get_album(album_id): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ErrorResponse( - success=False, - error="Album Not Found", - message="No album exists with the provided ID.", - ).model_dump(), - ) - - try: - db_remove_image_from_album(album_id, image_id) - return SuccessResponse( - success=True, msg="Image removed from album successfully" - ) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=ErrorResponse( - success=False, error="Failed to Remove Image", message=str(e) - ).model_dump(), - ) - - -# DELETE /albums/{album_id}/images - Remove multiple images from album -@router.delete("/{album_id}/images", response_model=SuccessResponse) -def remove_images_from_album( - album_id: str = Path(...), body: ImageIdsRequest = Body(...) -): - album = db_get_album(album_id) - if not album: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ErrorResponse( - success=False, - error="Album Not Found", - message="No album exists with the provided ID.", - ).model_dump(), - ) - - if not body.image_ids: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ErrorResponse( - success=False, - error="No Image IDs Provided", - message="You must provide at least one image ID to remove.", + success=False, error="Album Not Found", message="Album not found" ).model_dump(), ) try: - db_remove_images_from_album(album_id, body.image_ids) + db_remove_media_from_album(album_id, media_id) return SuccessResponse( - success=True, msg=f"Removed {len(body.image_ids)} images from album" + success=True, msg="Media removed from album successfully" ) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ErrorResponse( - success=False, error="Failed to Remove Images", message=str(e) + success=False, error="Failed to Remove Media", message=str(e) ).model_dump(), - ) + ) \ No newline at end of file diff --git a/backend/app/schemas/album.py b/backend/app/schemas/album.py index cae98e650..873e0cafb 100644 --- a/backend/app/schemas/album.py +++ b/backend/app/schemas/album.py @@ -2,22 +2,29 @@ from typing import Optional, List from pydantic_core.core_schema import ValidationInfo +# --- Core Models --- class Album(BaseModel): album_id: str album_name: str description: str + cover_image_id: Optional[str] = None # <--- NEW is_hidden: bool + created_at: Optional[str] = None # <--- NEW + updated_at: Optional[str] = None # <--- NEW +class MediaItem(BaseModel): # <--- NEW (For mixed media) + media_id: str + media_type: str # 'image' or 'video' # ############################## # Request Handler # ############################## - class CreateAlbumRequest(BaseModel): name: str = Field(..., min_length=1) description: Optional[str] = "" + cover_image_id: Optional[str] = None # <--- NEW is_hidden: bool = False password: Optional[str] = None @@ -27,10 +34,10 @@ def check_password(cls, value, info: ValidationInfo): raise ValueError("Password is required for hidden albums") return value - class UpdateAlbumRequest(BaseModel): name: str description: Optional[str] = "" + cover_image_id: Optional[str] = None # <--- NEW is_hidden: bool current_password: Optional[str] = None password: Optional[str] = None @@ -41,46 +48,34 @@ def check_password(cls, value, info: ValidationInfo): raise ValueError("Password is required for hidden albums") return value - -class GetAlbumImagesRequest(BaseModel): - password: Optional[str] = None - - -class ImageIdsRequest(BaseModel): - image_ids: List[str] - +class AddMediaRequest(BaseModel): # <--- NEW (Replaces ImageIdsRequest) + media_items: List[MediaItem] # ############################## # Response Handler # ############################## - class GetAlbumsResponse(BaseModel): success: bool albums: List[Album] - class CreateAlbumResponse(BaseModel): success: bool album_id: str - class GetAlbumResponse(BaseModel): success: bool data: Album - -class GetAlbumImagesResponse(BaseModel): +class GetAlbumMediaResponse(BaseModel): # <--- UPDATED (was GetAlbumImagesResponse) success: bool - image_ids: List[str] - + media_items: List[MediaItem] class SuccessResponse(BaseModel): success: bool msg: str - class ErrorResponse(BaseModel): success: bool = False message: str - error: str + error: str \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 2c1f39e44..5c330c4e4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -16,7 +16,6 @@ from app.database.face_clusters import db_create_clusters_table from app.database.yolo_mapping import db_create_YOLO_classes_table from app.database.albums import db_create_albums_table -from app.database.albums import db_create_album_images_table from app.database.folders import db_create_folders_table from app.database.metadata import db_create_metadata_table from app.utils.microservice import microservice_util_start_sync_service @@ -50,7 +49,6 @@ async def lifespan(app: FastAPI): db_create_clusters_table() # Create clusters table first since faces references it db_create_faces_table() db_create_albums_table() - db_create_album_images_table() db_create_metadata_table() microservice_util_start_sync_service() # Create ProcessPoolExecutor and attach it to app.state diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3b7716121..59fbd522c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -6,7 +6,7 @@ from app.database.images import db_create_images_table from app.database.face_clusters import db_create_clusters_table from app.database.yolo_mapping import db_create_YOLO_classes_table -from app.database.albums import db_create_albums_table, db_create_album_images_table +from app.database.albums import db_create_albums_table from app.database.folders import db_create_folders_table from app.database.metadata import db_create_metadata_table @@ -26,7 +26,6 @@ def setup_before_all_tests(): db_create_faces_table() db_create_folders_table() db_create_albums_table() - db_create_album_images_table() db_create_images_table() db_create_metadata_table() print("All database tables created successfully") diff --git a/backend/tests/test_albums.py b/backend/tests/test_albums.py index cec9f670e..54e0fe985 100644 --- a/backend/tests/test_albums.py +++ b/backend/tests/test_albums.py @@ -26,8 +26,11 @@ def mock_db_album(): "album_id": str(uuid.uuid4()), "album_name": "Summer Vacation", "description": "Photos from our 2023 summer trip.", + "cover_image_id": None, "is_hidden": False, "password_hash": None, + "created_at": "2023-01-01 12:00:00", + "updated_at": "2023-01-01 12:00:00" } @@ -37,8 +40,11 @@ def mock_db_hidden_album(): "album_id": str(uuid.uuid4()), "album_name": "Secret Party", "description": "Don't tell anyone.", + "cover_image_id": None, "is_hidden": True, "password_hash": "a_very_secure_hash", + "created_at": "2023-01-01 12:00:00", + "updated_at": "2023-01-01 12:00:00" } @@ -56,12 +62,14 @@ class TestAlbumRoutes: { "name": "New Year's Eve", "description": "Party photos from 2024.", + "cover_image_id": None, "is_hidden": False, "password": None, }, { "name": "Secret Vault", "description": "Hidden memories.", + "cover_image_id": None, "is_hidden": True, "password": "supersecret", }, @@ -72,7 +80,7 @@ def test_create_album_variants(self, album_data): "app.routes.albums.db_insert_album" ) as mock_insert: mock_get_by_name.return_value = None # No existing album - mock_insert.return_value = None + mock_insert.return_value = True response = client.post("/albums/", json=album_data) assert response.status_code == 200 @@ -84,7 +92,7 @@ def test_create_album_variants(self, album_data): mock_insert.assert_called_once() # Verify that the album_id is a valid UUID album_id = json_response["album_id"] - uuid.UUID(album_id) # This will raise ValueError if not a valid UUID + uuid.UUID(album_id) def test_create_album_duplicate_name(self): """Test creating album with duplicate name.""" @@ -96,13 +104,8 @@ def test_create_album_duplicate_name(self): } with patch("app.routes.albums.db_get_album_by_name") as mock_get_by_name: - mock_get_by_name.return_value = ( - "existing-id", - "Existing Album", - "desc", - 0, - None, - ) + # Mock must return a dict (or truthy object), not tuple + mock_get_by_name.return_value = {"album_id": "existing"} response = client.post("/albums/", json=album_data) assert response.status_code == 409 @@ -116,14 +119,8 @@ def test_get_all_albums_public_only(self, mock_db_album): Test fetching only public albums (default behavior). """ with patch("app.routes.albums.db_get_all_albums") as mock_get_all: - mock_get_all.return_value = [ - ( - mock_db_album["album_id"], - mock_db_album["album_name"], - mock_db_album["description"], - mock_db_album["is_hidden"], - ) - ] + # Return list of dicts + mock_get_all.return_value = [mock_db_album] response = client.get("/albums/") assert response.status_code == 200 @@ -133,15 +130,7 @@ def test_get_all_albums_public_only(self, mock_db_album): assert isinstance(json_response["albums"], list) assert len(json_response["albums"]) == 1 assert json_response["albums"][0]["album_id"] == mock_db_album["album_id"] - assert ( - json_response["albums"][0]["album_name"] == mock_db_album["album_name"] - ) - assert ( - json_response["albums"][0]["description"] - == mock_db_album["description"] - ) - assert json_response["albums"][0]["is_hidden"] == mock_db_album["is_hidden"] - + mock_get_all.assert_called_once_with(False) def test_get_all_albums_include_hidden(self, mock_db_album, mock_db_hidden_album): @@ -149,27 +138,13 @@ def test_get_all_albums_include_hidden(self, mock_db_album, mock_db_hidden_album Test fetching all albums including hidden ones. """ with patch("app.routes.albums.db_get_all_albums") as mock_get_all: - mock_get_all.return_value = [ - ( - mock_db_album["album_id"], - mock_db_album["album_name"], - mock_db_album["description"], - mock_db_album["is_hidden"], - ), - ( - mock_db_hidden_album["album_id"], - mock_db_hidden_album["album_name"], - mock_db_hidden_album["description"], - mock_db_hidden_album["is_hidden"], - ), - ] + mock_get_all.return_value = [mock_db_album, mock_db_hidden_album] response = client.get("/albums/?show_hidden=true") assert response.status_code == 200 json_response = response.json() assert json_response["success"] is True - assert isinstance(json_response["albums"], list) assert len(json_response["albums"]) == 2 ids = {album["album_id"] for album in json_response["albums"]} @@ -192,19 +167,12 @@ def test_get_all_albums_empty_list(self): assert json_response["success"] is True assert json_response["albums"] == [] - mock_get_all.assert_called_once_with(False) - def test_get_album_by_id_success(self, mock_db_album): """ Test fetching a single album by its ID successfully. """ with patch("app.routes.albums.db_get_album") as mock_get_album: - mock_get_album.return_value = ( - mock_db_album["album_id"], - mock_db_album["album_name"], - mock_db_album["description"], - mock_db_album["is_hidden"], - ) + mock_get_album.return_value = mock_db_album response = client.get(f"/albums/{mock_db_album['album_id']}") assert response.status_code == 200 @@ -213,8 +181,7 @@ def test_get_album_by_id_success(self, mock_db_album): assert json_response["success"] is True assert json_response["data"]["album_id"] == mock_db_album["album_id"] assert json_response["data"]["album_name"] == mock_db_album["album_name"] - assert json_response["data"]["description"] == mock_db_album["description"] - assert json_response["data"]["is_hidden"] == mock_db_album["is_hidden"] + mock_get_album.assert_called_once_with(mock_db_album["album_id"]) def test_get_album_by_id_not_found(self): @@ -231,19 +198,17 @@ def test_get_album_by_id_not_found(self): json_response = response.json() assert json_response["detail"]["error"] == "Album Not Found" - assert json_response["detail"]["message"] == "Album not found" - assert json_response["detail"]["success"] is False - mock_get_album.assert_called_once_with(non_existent_id) @pytest.mark.parametrize( - "album_data, request_data, verify_password_return, expected_status", + "album_data_override, request_data, verify_password_return, expected_status", [ - # Case 1: Public album (no password protection) + # Case 1: Public album ( - ("abc-123", "Old Name", "Old Desc", 0, None), + {"is_hidden": False, "password_hash": None}, { "name": "Updated Public Album", "description": "Updated description", + "cover_image_id": None, "is_hidden": False, "password": None, "current_password": None, @@ -251,18 +216,13 @@ def test_get_album_by_id_not_found(self): True, 200, ), - # Case 2: Hidden album with correct current password + # Case 2: Hidden album, correct password ( - ( - "abc-456", - "Hidden Album", - "Secret", - 1, - bcrypt.hashpw("oldpass".encode(), bcrypt.gensalt()).decode(), - ), + {"is_hidden": True, "password_hash": "hashed_pw"}, { "name": "Updated Hidden Album", "description": "Updated hidden description", + "cover_image_id": None, "is_hidden": True, "password": "newpass123", "current_password": "oldpass", @@ -270,18 +230,13 @@ def test_get_album_by_id_not_found(self): True, 200, ), - # Case 3: Hidden album with incorrect current password + # Case 3: Hidden album, wrong password ( - ( - "abc-789", - "Hidden Album", - "Secret", - 1, - bcrypt.hashpw("correctpass".encode(), bcrypt.gensalt()).decode(), - ), + {"is_hidden": True, "password_hash": "hashed_pw"}, { "name": "Invalid Attempt", "description": "Wrong password used", + "cover_image_id": None, "is_hidden": True, "password": "newpass123", "current_password": "wrongpass", @@ -292,22 +247,25 @@ def test_get_album_by_id_not_found(self): ], ) def test_update_album( - self, album_data, request_data, verify_password_return, expected_status + self, mock_db_album, album_data_override, request_data, verify_password_return, expected_status ): + # Update fixture data with parametrization overrides + current_album_data = mock_db_album.copy() + current_album_data.update(album_data_override) + with patch("app.routes.albums.db_get_album") as mock_get_album, patch( "app.routes.albums.db_update_album" ) as mock_update_album, patch( "app.routes.albums.verify_album_password" ) as mock_verify: - mock_get_album.return_value = album_data + mock_get_album.return_value = current_album_data mock_verify.return_value = verify_password_return - response = client.put(f"/albums/{album_data[0]}", json=request_data) + response = client.put(f"/albums/{current_album_data['album_id']}", json=request_data) assert response.status_code == expected_status if expected_status == 200: assert response.json()["success"] is True - assert "msg" in response.json() mock_update_album.assert_called_once() else: mock_update_album.assert_not_called() @@ -317,18 +275,11 @@ def test_delete_album_success(self, mock_db_album): Test successfully deleting an existing album. """ album_id = mock_db_album["album_id"] - album_tuple = ( - album_id, - mock_db_album["album_name"], - mock_db_album["description"], - int(mock_db_album["is_hidden"]), - mock_db_album["password_hash"], - ) - + with patch("app.routes.albums.db_get_album") as mock_get_album, patch( "app.routes.albums.db_delete_album" ) as mock_delete_album: - mock_get_album.return_value = album_tuple + mock_get_album.return_value = mock_db_album mock_delete_album.return_value = None response = client.delete(f"/albums/{album_id}") @@ -341,134 +292,88 @@ def test_delete_album_success(self, mock_db_album): mock_delete_album.assert_called_once_with(album_id) -class TestAlbumImageManagement: +class TestAlbumMediaManagement: """ - Test suite for routes managing images within albums. + Test suite for routes managing media (images/videos) within albums. """ - def test_add_images_to_album_success(self, mock_db_album): + def test_add_media_to_album_success(self, mock_db_album): """ - Test adding valid images to an existing album. + Test adding valid media items to an existing album. """ album_id = mock_db_album["album_id"] + # Updated request body format for media items request_body = { - "image_ids": [ - "71abff29-27b4-43a4-9e76-b78504bea325", - "2d4bff29-1111-43a4-9e76-b78504bea999", + "media_items": [ + {"media_id": "img-1", "media_type": "image"}, + {"media_id": "vid-1", "media_type": "video"}, ] } - album_tuple = ( - album_id, - mock_db_album["album_name"], - mock_db_album["description"], - int(mock_db_album["is_hidden"]), - mock_db_album["password_hash"], - ) - with patch("app.routes.albums.db_get_album") as mock_get_album, patch( - "app.routes.albums.db_add_images_to_album" - ) as mock_add_images: - mock_get_album.return_value = album_tuple - mock_add_images.return_value = None + "app.routes.albums.db_add_media_to_album" + ) as mock_add_media: + mock_get_album.return_value = mock_db_album + mock_add_media.return_value = 2 - response = client.post(f"/albums/{album_id}/images", json=request_body) + # Updated endpoint: /media + response = client.post(f"/albums/{album_id}/media", json=request_body) assert response.status_code == 200 json_response = response.json() assert json_response["success"] is True assert "msg" in json_response - assert f"{len(request_body['image_ids'])} images" in json_response["msg"] + assert "Added 2 items" in json_response["msg"] - mock_get_album.assert_called_once_with(album_id) - mock_add_images.assert_called_once_with(album_id, request_body["image_ids"]) + mock_add_media.assert_called_once() - def test_get_album_images_success(self, mock_db_album): + def test_get_album_media_success(self, mock_db_album): """ - Test retrieving image IDs from an existing album. + Test retrieving media items from an existing album. """ album_id = mock_db_album["album_id"] - expected_image_ids = [ - "71abff29-27b4-43a4-9e76-b78504bea325", - "2d4bff29-1111-43a4-9e76-b78504bea999", + expected_media = [ + {"media_id": "img-1", "media_type": "image"}, + {"media_id": "vid-1", "media_type": "video"}, ] - album_tuple = ( - album_id, - mock_db_album["album_name"], - mock_db_album["description"], - int(mock_db_album["is_hidden"]), - mock_db_album["password_hash"], - ) - with patch("app.routes.albums.db_get_album") as mock_get_album, patch( - "app.routes.albums.db_get_album_images" - ) as mock_get_images: - mock_get_album.return_value = album_tuple - mock_get_images.return_value = expected_image_ids + "app.routes.albums.db_get_album_media" + ) as mock_get_media: + mock_get_album.return_value = mock_db_album + mock_get_media.return_value = expected_media - response = client.post(f"/albums/{album_id}/images/get", json={}) + # GET request (not POST) to /media + response = client.get(f"/albums/{album_id}/media") assert response.status_code == 200 json_response = response.json() assert json_response["success"] is True - assert "image_ids" in json_response - assert set(json_response["image_ids"]) == set(expected_image_ids) + assert "media_items" in json_response + assert len(json_response["media_items"]) == 2 - mock_get_album.assert_called_once_with(album_id) - mock_get_images.assert_called_once_with(album_id) + mock_get_media.assert_called_once_with(album_id) - def test_remove_image_from_album_success(self, mock_db_album): + def test_remove_media_from_album_success(self, mock_db_album): """ - Test successfully removing an image from an album. + Test successfully removing a media item from an album. """ album_id = mock_db_album["album_id"] - image_id = "71abff29-27b4-43a4-9e76-b78504bea325" - - album_tuple = ( - album_id, - mock_db_album["album_name"], - mock_db_album["description"], - int(mock_db_album["is_hidden"]), - mock_db_album["password_hash"], - ) + media_id = "img-1" with patch("app.routes.albums.db_get_album") as mock_get_album, patch( - "app.routes.albums.db_remove_image_from_album" + "app.routes.albums.db_remove_media_from_album" ) as mock_remove: - mock_get_album.return_value = album_tuple + mock_get_album.return_value = mock_db_album mock_remove.return_value = None - response = client.delete(f"/albums/{album_id}/images/{image_id}") + # Updated endpoint: /media/{media_id} + response = client.delete(f"/albums/{album_id}/media/{media_id}") assert response.status_code == 200 json_response = response.json() assert json_response["success"] is True assert "msg" in json_response - assert "successfully" in json_response["msg"].lower() - - mock_get_album.assert_called_once_with(album_id) - mock_remove.assert_called_once_with(album_id, image_id) + assert "removed" in json_response["msg"].lower() - def test_remove_multiple_images_from_album(self, mock_db_album): - """ - Test removing multiple images from an album using the bulk delete endpoint. - """ - album_id = mock_db_album["album_id"] - image_ids_to_remove = {"image_ids": [str(uuid.uuid4()), str(uuid.uuid4())]} - - with patch("app.routes.albums.db_get_album") as mock_get, patch( - "app.routes.albums.db_remove_images_from_album" - ) as mock_remove_bulk: - mock_get.return_value = tuple(mock_db_album.values()) - response = client.request( - "DELETE", f"/albums/{album_id}/images", json=image_ids_to_remove - ) - assert response.status_code == 200 - json_response = response.json() - assert json_response["success"] is True - assert str(len(image_ids_to_remove["image_ids"])) in json_response["msg"] - mock_get.assert_called_once_with(album_id) - mock_remove_bulk.assert_called_once_with( - album_id, image_ids_to_remove["image_ids"] - ) + mock_remove.assert_called_once_with(album_id, media_id) \ No newline at end of file diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index 44eb908b1..73a7bdc86 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -620,13 +620,13 @@ } } }, - "/albums/{album_id}/images/get": { - "post": { + "/albums/{album_id}/media": { + "get": { "tags": [ "Albums" ], - "summary": "Get Album Images", - "operationId": "get_album_images_albums__album_id__images_get_post", + "summary": "Get Album Media", + "operationId": "get_album_media_albums__album_id__media_get", "parameters": [ { "name": "album_id", @@ -636,77 +636,24 @@ "type": "string", "title": "Album Id" } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetAlbumImagesRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetAlbumImagesResponse" - } - } - } }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/albums/{album_id}/images": { - "post": { - "tags": [ - "Albums" - ], - "summary": "Add Images To Album", - "operationId": "add_images_to_album_albums__album_id__images_post", - "parameters": [ { - "name": "album_id", - "in": "path", - "required": true, + "name": "password", + "in": "query", + "required": false, "schema": { "type": "string", - "title": "Album Id" + "title": "Password" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ImageIdsRequest" - } - } - } - }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuccessResponse" + "$ref": "#/components/schemas/GetAlbumMediaResponse" } } } @@ -723,12 +670,12 @@ } } }, - "delete": { + "post": { "tags": [ "Albums" ], - "summary": "Remove Images From Album", - "operationId": "remove_images_from_album_albums__album_id__images_delete", + "summary": "Add Media To Album", + "operationId": "add_media_to_album_albums__album_id__media_post", "parameters": [ { "name": "album_id", @@ -745,7 +692,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImageIdsRequest" + "$ref": "#/components/schemas/AddMediaRequest" } } } @@ -774,13 +721,13 @@ } } }, - "/albums/{album_id}/images/{image_id}": { + "/albums/{album_id}/media/{media_id}": { "delete": { "tags": [ "Albums" ], - "summary": "Remove Image From Album", - "operationId": "remove_image_from_album_albums__album_id__images__image_id__delete", + "summary": "Remove Media From Album", + "operationId": "remove_media_from_album_albums__album_id__media__media_id__delete", "parameters": [ { "name": "album_id", @@ -792,12 +739,12 @@ } }, { - "name": "image_id", + "name": "media_id", "in": "path", "required": true, "schema": { "type": "string", - "title": "Image Id" + "title": "Media Id" } } ], @@ -1117,9 +1064,14 @@ "in": "query", "required": false, "schema": { - "$ref": "#/components/schemas/InputType", + "allOf": [ + { + "$ref": "#/components/schemas/InputType" + } + ], "description": "Choose input type: 'path' or 'base64'", - "default": "path" + "default": "path", + "title": "Input Type" }, "description": "Choose input type: 'path' or 'base64'" } @@ -1402,6 +1354,22 @@ ], "title": "AddFolderResponse" }, + "AddMediaRequest": { + "properties": { + "media_items": { + "items": { + "$ref": "#/components/schemas/MediaItem" + }, + "type": "array", + "title": "Media Items" + } + }, + "type": "object", + "required": [ + "media_items" + ], + "title": "AddMediaRequest" + }, "Album": { "properties": { "album_id": { @@ -1416,9 +1384,42 @@ "type": "string", "title": "Description" }, + "cover_image_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cover Image Id" + }, "is_hidden": { "type": "boolean", "title": "Is Hidden" + }, + "created_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated At" } }, "type": "object", @@ -1491,6 +1492,17 @@ "title": "Description", "default": "" }, + "cover_image_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cover Image Id" + }, "is_hidden": { "type": "boolean", "title": "Is Hidden", @@ -1692,43 +1704,26 @@ ], "title": "FolderDetails" }, - "GetAlbumImagesRequest": { - "properties": { - "password": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Password" - } - }, - "type": "object", - "title": "GetAlbumImagesRequest" - }, - "GetAlbumImagesResponse": { + "GetAlbumMediaResponse": { "properties": { "success": { "type": "boolean", "title": "Success" }, - "image_ids": { + "media_items": { "items": { - "type": "string" + "$ref": "#/components/schemas/MediaItem" }, "type": "array", - "title": "Image Ids" + "title": "Media Items" } }, "type": "object", "required": [ "success", - "image_ids" + "media_items" ], - "title": "GetAlbumImagesResponse" + "title": "GetAlbumMediaResponse" }, "GetAlbumResponse": { "properties": { @@ -2159,22 +2154,6 @@ ], "title": "ImageData" }, - "ImageIdsRequest": { - "properties": { - "image_ids": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Image Ids" - } - }, - "type": "object", - "required": [ - "image_ids" - ], - "title": "ImageIdsRequest" - }, "ImageInCluster": { "properties": { "id": { @@ -2199,7 +2178,6 @@ "metadata": { "anyOf": [ { - "additionalProperties": true, "type": "object" }, { @@ -2262,6 +2240,24 @@ ], "title": "InputType" }, + "MediaItem": { + "properties": { + "media_id": { + "type": "string", + "title": "Media Id" + }, + "media_type": { + "type": "string", + "title": "Media Type" + } + }, + "type": "object", + "required": [ + "media_id", + "media_type" + ], + "title": "MediaItem" + }, "MetadataModel": { "properties": { "name": { @@ -2659,6 +2655,17 @@ "title": "Description", "default": "" }, + "cover_image_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cover Image Id" + }, "is_hidden": { "type": "boolean", "title": "Is Hidden"