Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improved ui #422

Merged
merged 20 commits into from
Jul 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
179 changes: 103 additions & 76 deletions backend/controllers/items.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime
from enum import Enum
from typing import List, Optional

import Levenshtein
Expand Down Expand Up @@ -30,41 +31,30 @@ async def get_states():
}


@router.get("/", summary="Retrieve Media Items", description="Fetch media items with optional filters and pagination.")
@router.get(
"",
summary="Retrieve Media Items",
description="Fetch media items with optional filters and pagination",
)
async def get_items(
request: Request,
fetch_all: Optional[bool] = False,
limit: Optional[int] = 20,
limit: Optional[int] = 50,
page: Optional[int] = 1,
search: Optional[str] = None,
type: Optional[str] = None,
state: Optional[str] = None,
type: Optional[str] = None
sort: Optional[str] = "desc",
search: Optional[str] = None,
):
"""
Fetch media items with optional filters and pagination.

Parameters:
- request: Request object
- fetch_all: Fetch all items without pagination (default: False)
- limit: Number of items per page (default: 20)
- page: Page number (default: 1)
- search: Search term to filter items by title, IMDb ID, or item ID
- state: Filter items by state
- type: Filter items by type (movie, show, season, episode)

Returns:
- JSON response with success status, items, pagination details, and total count

Examples:
- Fetch all items: /items?fetch_all=true
- Fetch first 10 items: /items?limit=10&page=1
- Search items by title: /items?search=inception
- Filter items by state: /items?state=completed
- Filter items by type: /items?type=movie
"""
if page < 1:
raise HTTPException(status_code=400, detail="Page number must be 1 or greater.")

if limit < 1:
raise HTTPException(status_code=400, detail="Limit must be 1 or greater.")

items = list(request.app.program.media_items._items.values())
total_items = len(items)

if search:
if search: # TODO: fix for search
search_lower = search.lower()
filtered_items = []
if search_lower.startswith("tt"):
Expand All @@ -76,25 +66,20 @@ async def get_items(
else:
for item in items:
if isinstance(item, MediaItem):
title_match = item.title and Levenshtein.distance(search_lower, item.title.lower()) <= 0.90
imdb_match = item.imdb_id and Levenshtein.distance(search_lower, item.imdb_id.lower()) <= 1
title_match = (
item.title
and Levenshtein.distance(search_lower, item.title.lower())
<= 0.90
)
imdb_match = (
item.imdb_id
and Levenshtein.distance(search_lower, item.imdb_id.lower())
<= 1
)
if title_match or imdb_match:
filtered_items.append(item)
items = filtered_items

if type:
type_lower = type.lower()
if type_lower == "movie":
items = list(request.app.program.media_items.movies.values())
elif type_lower == "show":
items = list(request.app.program.media_items.shows.values())
elif type_lower == "season":
items = list(request.app.program.media_items.seasons.values())
elif type_lower == "episode":
items = list(request.app.program.media_items.episodes.values())
else:
raise HTTPException(status_code=400, detail=f"Invalid type: {type}. Valid types are: ['movie', 'show', 'season', 'episode']")

if state:
filter_lower = state.lower()
filter_state = None
Expand All @@ -106,28 +91,56 @@ async def get_items(
items = [item for item in items if item.state == filter_state]
else:
valid_states = [state.name for state in States]
raise HTTPException(status_code=400, detail=f"Invalid filter state: {state}. Valid states are: {valid_states}")
raise HTTPException(
status_code=400,
detail=f"Invalid filter state: {state}. Valid states are: {valid_states}",
)

if not fetch_all:
if page < 1:
raise HTTPException(status_code=400, detail="Page number must be 1 or greater.")
if limit < 1:
raise HTTPException(status_code=400, detail="Limit must be 1 or greater.")

start = (page - 1) * limit
end = start + limit
items = items[start:end]
if type:
type_lower = type.lower()
if type_lower == "movie":
items = list(request.app.program.media_items.movies.values())
total_items = len(items)
elif type_lower == "show":
items = list(request.app.program.media_items.shows.values())
total_items = len(items)
elif type_lower == "season":
items = list(request.app.program.media_items.seasons.values())
total_items = len(items)
elif type_lower == "episode":
items = list(request.app.program.media_items.episodes.values())
total_items = len(items)
else:
raise HTTPException(
status_code=400,
detail=f"Invalid type: {type}. Valid types are: ['movie', 'show', 'season', 'episode']",
)

if (
sort and not search
): # we don't want to sort search results as they are already sorted by relevance
if sort.lower() == "asc":
items = sorted(items, key=lambda x: x.requested_at)
elif sort.lower() == "desc":
items = sorted(items, key=lambda x: x.requested_at, reverse=True)
else:
raise HTTPException(
status_code=400,
detail=f"Invalid sort: {sort}. Valid sorts are: ['asc', 'desc']",
)

total_count = len(items)
total_pages = (total_count + limit - 1) // limit
start = (page - 1) * limit
end = start + limit
items = items[start:end]
total_pages = (total_items + limit - 1) // limit

return {
"success": True,
"items": [item.to_dict() for item in items],
"page": page,
"limit": limit,
"total": total_count,
"total_pages": total_pages
"total_items": total_items,
"total_pages": total_pages,
}


Expand All @@ -145,10 +158,14 @@ async def get_extended_item_info(request: Request, item_id: str):

@router.post("/add/imdb/{imdb_id}")
@router.post("/add/imdb/")
async def add_items(request: Request, imdb_id: Optional[str] = None, imdb_ids: Optional[IMDbIDs] = None):
async def add_items(
request: Request, imdb_id: Optional[str] = None, imdb_ids: Optional[IMDbIDs] = None
):
if imdb_id:
imdb_ids = IMDbIDs(imdb_ids=[imdb_id])
elif not imdb_ids or not imdb_ids.imdb_ids or any(not id for id in imdb_ids.imdb_ids):
elif (
not imdb_ids or not imdb_ids.imdb_ids or any(not id for id in imdb_ids.imdb_ids)
):
raise HTTPException(status_code=400, detail="No IMDb ID(s) provided")

valid_ids = []
Expand All @@ -164,21 +181,21 @@ async def add_items(request: Request, imdb_id: Optional[str] = None, imdb_ids: O
for id in valid_ids:
item = MediaItem({"imdb_id": id, "requested_by": "riven"})
request.app.program.add_to_queue(item)

return {"success": True, "message": f"Added {len(valid_ids)} item(s) to the queue"}


@router.delete("/remove/")
async def remove_item(
request: Request,
item_id: Optional[str] = None,
imdb_id: Optional[str] = None
request: Request, item_id: Optional[str] = None, imdb_id: Optional[str] = None
):
if item_id:
item = request.app.program.media_items.get(ItemId(item_id))
id_type = "ID"
elif imdb_id:
item = next((i for i in request.app.program.media_items if i.imdb_id == imdb_id), None)
item = next(
(i for i in request.app.program.media_items if i.imdb_id == imdb_id), None
)
id_type = "IMDb ID"
else:
raise HTTPException(status_code=400, detail="No item ID or IMDb ID provided")
Expand All @@ -187,7 +204,7 @@ async def remove_item(
logger.error(f"Item with {id_type} {item_id or imdb_id} not found")
return {
"success": False,
"message": f"Item with {id_type} {item_id or imdb_id} not found. No action taken."
"message": f"Item with {id_type} {item_id or imdb_id} not found. No action taken.",
}

try:
Expand All @@ -198,22 +215,33 @@ async def remove_item(
# Remove the symlinks associated with the item
symlinker = request.app.program.service[Symlinker]
symlinker.delete_item_symlinks(item)
logger.log("API", f"Removed symlink for item with {id_type} {item_id or imdb_id}")
logger.log(
"API", f"Removed symlink for item with {id_type} {item_id or imdb_id}"
)

# Save and reload the media items to ensure consistency
symlinker.save_and_reload_media_items(request.app.program.media_items)
logger.log("API", f"Saved and reloaded media items after removing item with {id_type} {item_id or imdb_id}")
logger.log(
"API",
f"Saved and reloaded media items after removing item with {id_type} {item_id or imdb_id}",
)

return {
"success": True,
"message": f"Successfully removed item with {id_type} {item_id or imdb_id}."
"message": f"Successfully removed item with {id_type} {item_id or imdb_id}.",
}
except Exception as e:
logger.error(f"Failed to remove item with {id_type} {item_id or imdb_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")


@router.get("/imdb/{imdb_id}")
async def get_imdb_info(request: Request, imdb_id: str, season: Optional[int] = None, episode: Optional[int] = None):
async def get_imdb_info(
request: Request,
imdb_id: str,
season: Optional[int] = None,
episode: Optional[int] = None,
):
"""
Get the item with the given IMDb ID.
If the season and episode are provided, get the item with the given season and episode.
Expand All @@ -223,7 +251,7 @@ async def get_imdb_info(request: Request, imdb_id: str, season: Optional[int] =
item_id = ItemId(str(season), parent_id=item_id)
if episode is not None:
item_id = ItemId(str(episode), parent_id=item_id)

item = request.app.program.media_items.get_item(item_id)
if item is None:
raise HTTPException(status_code=404, detail="Item not found")
Expand All @@ -233,18 +261,17 @@ async def get_imdb_info(request: Request, imdb_id: str, season: Optional[int] =

@router.get("/incomplete")
async def get_incomplete_items(request: Request):
if not hasattr(request.app, 'program') or not hasattr(request.app.program, 'media_items'):
if not hasattr(request.app, "program") or not hasattr(
request.app.program, "media_items"
):
logger.error("Program or media_items not found in the request app")
raise HTTPException(status_code=500, detail="Internal server error")

incomplete_items = request.app.program.media_items.get_incomplete_items()
if not incomplete_items:
return {
"success": True,
"incomplete_items": []
}
return {"success": True, "incomplete_items": []}

return {
"success": True,
"incomplete_items": [item.to_dict() for item in incomplete_items.values()]
"incomplete_items": [item.to_dict() for item in incomplete_items.values()],
}
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"bits-ui": "^0.21.10",
"clsx": "^2.1.1",
"cmdk-sv": "^0.0.17",
"embla-carousel-autoplay": "^8.1.5",
"embla-carousel-svelte": "^8.1.5",
"formsnap": "^1.0.0",
"lucide-svelte": "^0.390.0",
Expand All @@ -56,6 +57,7 @@
"tailwind-merge": "^2.3.0",
"tailwind-variants": "^0.2.1",
"uuid": "^9.0.1",
"vaul-svelte": "^0.3.2",
"zod": "^3.23.8"
}
}
Loading
Loading