Skip to content

Commit 819cd2b

Browse files
committed
Add support for video duration, sorting, and entity updates
Introduce a duration field to videos with database migration and UI integration. Enhance search functionalities with sorting options and unreviewed video filters. Implement update endpoints for people and tags to enable renaming, ensuring name uniqueness validation.
1 parent 82c4f41 commit 819cd2b

File tree

10 files changed

+223
-22
lines changed

10 files changed

+223
-22
lines changed

frontend/src/api/client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface Video {
2222
file_size?: number;
2323
thumbnail_path?: string;
2424
rating?: number;
25+
duration?: number;
2526
created_at: string;
2627
updated_at: string;
2728
}
@@ -60,6 +61,8 @@ export interface VideoSearchParams {
6061
limit?: number;
6162
offset?: number;
6263
unreviewed?: boolean;
64+
sort_by?: string;
65+
sort_order?: string;
6366
}
6467

6568
export interface Tag {

frontend/src/components/SearchFilters.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ const SearchFilters: React.FC<SearchFiltersProps> = ({ onFilterChange }) => {
3333
const [selectedPeople, setSelectedPeople] = useState<SelectOption[]>([]);
3434
const [selectedRating, setSelectedRating] = useState<string>('');
3535
const [isUnreviewed, setIsUnreviewed] = useState<boolean>(false);
36+
const [sortBy, setSortBy] = useState<string>('');
37+
const [sortOrder, setSortOrder] = useState<string>('DESC');
3638
const [loading, setLoading] = useState(true);
3739

3840
const bgColor = useColorModeValue('white', 'gray.800');
@@ -76,7 +78,9 @@ const SearchFilters: React.FC<SearchFiltersProps> = ({ onFilterChange }) => {
7678
tags: selectedTags.map(tag => tag.value),
7779
people: selectedPeople.map(person => person.value),
7880
rating: selectedRating ? parseInt(selectedRating, 10) : undefined,
79-
unreviewed: isUnreviewed || undefined
81+
unreviewed: isUnreviewed || undefined,
82+
sort_by: sortBy || undefined,
83+
sort_order: sortOrder || undefined
8084
});
8185
};
8286

@@ -86,11 +90,15 @@ const SearchFilters: React.FC<SearchFiltersProps> = ({ onFilterChange }) => {
8690
setSelectedPeople([]);
8791
setSelectedRating('');
8892
setIsUnreviewed(false);
93+
setSortBy('');
94+
setSortOrder('DESC');
8995
onFilterChange({
9096
tags: undefined,
9197
people: undefined,
9298
rating: undefined,
93-
unreviewed: undefined
99+
unreviewed: undefined,
100+
sort_by: undefined,
101+
sort_order: undefined
94102
});
95103
};
96104

@@ -172,6 +180,35 @@ const SearchFilters: React.FC<SearchFiltersProps> = ({ onFilterChange }) => {
172180
</Box>
173181
</SimpleGrid>
174182

183+
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mt={4}>
184+
<Box>
185+
<Heading size="sm" mb={2}>Sort By</Heading>
186+
<ChakraSelect
187+
value={sortBy}
188+
onChange={(e) => setSortBy(e.target.value)}
189+
placeholder="Default (Created Date)"
190+
>
191+
<option value="">Default (Created Date)</option>
192+
<option value="duration">Duration</option>
193+
<option value="title">Title</option>
194+
<option value="rating">Rating</option>
195+
<option value="file_size">File Size</option>
196+
<option value="created_date">Created Date</option>
197+
</ChakraSelect>
198+
</Box>
199+
200+
<Box>
201+
<Heading size="sm" mb={2}>Sort Order</Heading>
202+
<ChakraSelect
203+
value={sortOrder}
204+
onChange={(e) => setSortOrder(e.target.value)}
205+
>
206+
<option value="ASC">Ascending</option>
207+
<option value="DESC">Descending</option>
208+
</ChakraSelect>
209+
</Box>
210+
</SimpleGrid>
211+
175212
<Box mt={4}>
176213
<Checkbox
177214
isChecked={isUnreviewed}

frontend/src/components/VideoCard.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,28 @@ const VideoCard: React.FC<VideoCardProps> = ({ video }) => {
4040
// Format date
4141
const formatDate = (dateString?: string): string => {
4242
if (!dateString) return 'Unknown date';
43-
const date = new Date(dateString);
44-
return date.toLocaleDateString();
43+
try {
44+
const date = new Date(dateString);
45+
if (isNaN(date.getTime())) return 'Unknown date';
46+
return date.toLocaleDateString();
47+
} catch (e) {
48+
return 'Unknown date';
49+
}
50+
};
51+
52+
// Format duration
53+
const formatDuration = (seconds?: number): string => {
54+
if (!seconds) return '';
55+
56+
const hours = Math.floor(seconds / 3600);
57+
const minutes = Math.floor((seconds % 3600) / 60);
58+
const remainingSeconds = seconds % 60;
59+
60+
if (hours > 0) {
61+
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
62+
} else {
63+
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
64+
}
4565
};
4666

4767
// Render rating stars
@@ -99,9 +119,10 @@ const VideoCard: React.FC<VideoCardProps> = ({ video }) => {
99119
{formatDate(video.created_date)}
100120
</Text>
101121

102-
<Text fontSize="sm" color="gray.500" noOfLines={1}>
103-
{formatFileSize(video.file_size)}
104-
</Text>
122+
<Flex fontSize="sm" color="gray.500" noOfLines={1} justifyContent="space-between">
123+
<Text>{formatFileSize(video.file_size)}</Text>
124+
{video.duration && <Text>{formatDuration(video.duration)}</Text>}
125+
</Flex>
105126

106127
{video.tags.length > 0 && (
107128
<Flex mt={3} flexWrap="wrap" gap={2}>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Add duration column to videos table
2+
-- Up migration
3+
4+
ALTER TABLE videos ADD COLUMN duration INTEGER;
5+
6+
-- Down migration
7+
-- ALTER TABLE videos DROP COLUMN duration;

src/models/video.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub struct Video {
1414
pub file_size: Option<i64>,
1515
pub thumbnail_path: Option<String>,
1616
pub rating: Option<i32>,
17+
pub duration: Option<i64>,
1718
pub created_at: String,
1819
pub updated_at: String,
1920
}
@@ -57,6 +58,9 @@ pub struct VideoSearchParams {
5758
pub rating: Option<i32>,
5859
pub limit: Option<i64>,
5960
pub offset: Option<i64>,
61+
pub unreviewed: Option<bool>,
62+
pub sort_by: Option<String>,
63+
pub sort_order: Option<String>,
6064
}
6165

6266
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -78,6 +82,7 @@ impl Video {
7882
file_size: None,
7983
thumbnail_path: None,
8084
rating: None,
85+
duration: None,
8186
created_at: now.clone(),
8287
updated_at: now,
8388
}

src/routes/person.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use axum::{
22
extract::{Path, State},
3-
routing::{get, post, delete},
3+
routing::{get, post, delete, put},
44
Json, Router,
55
};
66

@@ -16,6 +16,7 @@ pub fn router(app_state: AppState) -> Router {
1616
.route("/usage", get(get_person_usage))
1717
.route("/cleanup", post(cleanup_unused_people))
1818
.route("/{id}", get(get_person))
19+
.route("/{id}", put(update_person))
1920
.route("/{id}", delete(delete_person))
2021
.with_state(app_state)
2122
}
@@ -44,6 +45,16 @@ async fn create_person(
4445
Ok(Json(person))
4546
}
4647

48+
async fn update_person(
49+
State(state): State<AppState>,
50+
Path(id): Path<String>,
51+
Json(new_name): Json<String>,
52+
) -> Result<Json<crate::models::Person>> {
53+
let person_service = PersonService::new(state.db.clone());
54+
let person = person_service.update(&id, &new_name).await?;
55+
Ok(Json(person))
56+
}
57+
4758
async fn delete_person(
4859
State(state): State<AppState>,
4960
Path(id): Path<String>,

src/routes/tag.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use axum::{
22
extract::{Path, State},
3-
routing::{get, post, delete},
3+
routing::{get, post, delete, put},
44
Json, Router,
55
};
66

@@ -16,6 +16,7 @@ pub fn router(app_state: AppState) -> Router {
1616
.route("/usage", get(get_tag_usage))
1717
.route("/cleanup", post(cleanup_unused_tags))
1818
.route("/{id}", get(get_tag))
19+
.route("/{id}", put(update_tag))
1920
.route("/{id}", delete(delete_tag))
2021
.with_state(app_state)
2122
}
@@ -44,6 +45,16 @@ async fn create_tag(
4445
Ok(Json(tag))
4546
}
4647

48+
async fn update_tag(
49+
State(state): State<AppState>,
50+
Path(id): Path<String>,
51+
Json(new_name): Json<String>,
52+
) -> Result<Json<crate::models::Tag>> {
53+
let tag_service = TagService::new(state.db.clone());
54+
let tag = tag_service.update(&id, &new_name).await?;
55+
Ok(Json(tag))
56+
}
57+
4758
async fn delete_tag(
4859
State(state): State<AppState>,
4960
Path(id): Path<String>,

src/services/person.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,40 @@ impl PersonService {
109109
Ok(person)
110110
}
111111

112+
pub async fn update(&self, id: &str, new_name: &str) -> Result<Person> {
113+
// Check if person exists
114+
let person = self.find_by_id(id).await?;
115+
116+
// Check if the new name already exists
117+
let existing = sqlx::query_as::<_, Person>("SELECT * FROM people WHERE name = ? AND id != ?")
118+
.bind(new_name)
119+
.bind(id)
120+
.fetch_optional(&self.db)
121+
.await
122+
.map_err(AppError::Database)?;
123+
124+
if existing.is_some() {
125+
return Err(AppError::BadRequest(format!(
126+
"Person with name '{}' already exists",
127+
new_name
128+
)));
129+
}
130+
131+
// Update person
132+
sqlx::query("UPDATE people SET name = ? WHERE id = ?")
133+
.bind(new_name)
134+
.bind(id)
135+
.execute(&self.db)
136+
.await
137+
.map_err(AppError::Database)?;
138+
139+
info!("Updated person: {} -> {} ({})", person.name, new_name, id);
140+
141+
// Return updated person
142+
let updated_person = self.find_by_id(id).await?;
143+
Ok(updated_person)
144+
}
145+
112146
pub async fn delete(&self, id: &str) -> Result<()> {
113147
// Check if person exists
114148
let person = self.find_by_id(id).await?;

src/services/tag.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,40 @@ impl TagService {
109109
Ok(tag)
110110
}
111111

112+
pub async fn update(&self, id: &str, new_name: &str) -> Result<Tag> {
113+
// Check if tag exists
114+
let tag = self.find_by_id(id).await?;
115+
116+
// Check if the new name already exists
117+
let existing = sqlx::query_as::<_, Tag>("SELECT * FROM tags WHERE name = ? AND id != ?")
118+
.bind(new_name)
119+
.bind(id)
120+
.fetch_optional(&self.db)
121+
.await
122+
.map_err(AppError::Database)?;
123+
124+
if existing.is_some() {
125+
return Err(AppError::BadRequest(format!(
126+
"Tag with name '{}' already exists",
127+
new_name
128+
)));
129+
}
130+
131+
// Update tag
132+
sqlx::query("UPDATE tags SET name = ? WHERE id = ?")
133+
.bind(new_name)
134+
.bind(id)
135+
.execute(&self.db)
136+
.await
137+
.map_err(AppError::Database)?;
138+
139+
info!("Updated tag: {} -> {} ({})", tag.name, new_name, id);
140+
141+
// Return updated tag
142+
let updated_tag = self.find_by_id(id).await?;
143+
Ok(updated_tag)
144+
}
145+
112146
pub async fn delete(&self, id: &str) -> Result<()> {
113147
// Check if tag exists
114148
let tag = self.find_by_id(id).await?;

0 commit comments

Comments
 (0)