validate([ 'title' => 'required|string|max:255', 'caption' => 'nullable|string', 'type' => 'required|in:audio,video', 'category_ids' => 'nullable|array', 'category_ids.*' => 'integer|exists:categories,id', 'subcategory_ids' => 'nullable|array', 'subcategory_ids.*' => 'integer|exists:sub_categories,id', 'image_id' => 'nullable|exists:images,id', 'image' => 'nullable|file|extensions:jpg,jpeg,png,gif,webp,bmp,svg|max:8192', 'detail_image_id' => 'nullable|exists:images,id', 'detail_image' => 'nullable|file|extensions:jpg,jpeg,png,gif,webp,bmp,svg|max:8192', 'duration' => 'nullable|integer', 'is_premium' => 'nullable|boolean', 'file' => 'nullable|file|extensions:mp3,wav,ogg,flac,aac,m4a,opus,mpga,wma,mp4,mov,m4v,webm,mkv,avi,3gp|max:512000', 'external_url' => 'nullable|string', 'visibility' => 'nullable|in:public,private', 'tags' => 'nullable|array', 'tags.*' => 'string', ]); $path = null; if ($request->hasFile('file')) { $path = $this->storeUpload($request->file('file'), 'media'); } // An uploaded image file takes precedence over a provided image_id. $imageId = $this->uploadedImageId($request) ?? ($data['image_id'] ?? null); $detailImageId = $this->uploadedImageId($request, 'detail_image') ?? ($data['detail_image_id'] ?? null); // When a file is uploaded, expose its public URL as external_url too // (older front-end versions read external_url for the playable source). $externalUrl = $path ? asset('storage/' . $path) : ($data['external_url'] ?? null); $media = Media::create([ 'user_id' => auth()->id(), 'title' => $data['title'], 'caption' => $data['caption'] ?? null, 'type' => $data['type'], 'file_path' => $path, 'external_url' => $externalUrl, 'image_id' => $imageId, 'detail_image_id' => $detailImageId, 'duration' => $data['duration'] ?? null, 'visibility' => $data['visibility'] ?? 'public', 'is_premium'=> $data['is_premium'] ?? false ]); $media->categories()->sync($data['category_ids'] ?? []); $media->subCategories()->sync($data['subcategory_ids'] ?? []); if (!empty($data['tags'])) { $tagIds = []; foreach ($data['tags'] as $tagName) { $tag = Tag::firstOrCreate(['name' => $tagName]); $tagIds[] = $tag->id; } $media->tags()->sync($tagIds); } return response()->json([ 'message' => 'Media created successfully', 'media' => $media->load(['image', 'detailImage', 'categories', 'subCategories', 'tags']), ]); } public function index(Request $request) { $query = Media::with(['image', 'detailImage', 'categories', 'subCategories', 'myNote', 'tags','comments']) ->where(function ($q) { $q->where('visibility', 'public') ->orWhere('user_id', auth()->id()); }); /* |-------------------------------------------------------------------------- | 1️⃣ Multi Category |-------------------------------------------------------------------------- */ if ($request->filled('categories')) { $categories = explode(',', $request->categories); $query->whereHas('categories', function ($q) use ($categories) { $q->whereIn('categories.id', $categories); }); } if ($request->filled('subcategories')) { $subcategories = explode(',', $request->subcategories); $query->whereHas('subCategories', function ($q) use ($subcategories) { $q->whereIn('sub_categories.id', $subcategories); }); } /* |-------------------------------------------------------------------------- | 2️⃣ Multi Duration Ranges |-------------------------------------------------------------------------- | duration stored in minutes (integer) |-------------------------------------------------------------------------- */ if ($request->filled('durations')) { $ranges = explode(',', $request->durations); $query->where(function ($q) use ($ranges) { foreach ($ranges as $range) { if ($range === '1-2') { $q->orWhereBetween('duration', [1, 2]); } if ($range === '2-5') { $q->orWhereBetween('duration', [3, 5]); } if ($range === '5-10') { $q->orWhereBetween('duration', [6, 10]); } if ($range === '10-30') { $q->orWhereBetween('duration', [10, 30]); } if ($range === '30-60') { $q->orWhereBetween('duration', [30, 60]); } if ($range === '60-120') { $q->orWhereBetween('duration', [60, 120]); } if ($range === 'other') { $q->orWhere('duration', '>', 120); } } }); } /* |-------------------------------------------------------------------------- | 3️⃣ Tags |-------------------------------------------------------------------------- */ if ($request->filled('tags')) { $tags = explode(',', $request->tags); $query->whereHas('tags', function ($q) use ($tags) { $q->whereIn('name', $tags); }); } /* |-------------------------------------------------------------------------- | 4️⃣ Search |-------------------------------------------------------------------------- */ if ($request->filled('search')) { $search = $request->search; $query->where(function ($q) use ($search) { $q->where('title', 'LIKE', "%$search%") ->orWhere('caption', 'LIKE', "%$search%") ->orWhereHas('categories', function ($c) use ($search) { $c->where('name', 'LIKE', "%$search%"); }) ->orWhereHas('subCategories', function ($s) use ($search) { $s->where('name', 'LIKE', "%$search%"); }) ->orWhereHas('tags', function ($t) use ($search) { $t->where('name', 'LIKE', "%$search%"); }); }); } $query->orderBy('created_at', 'desc'); // Optional cap: ?count=10 returns only the first 10 items. if ($request->filled('count')) { $query->limit(max(1, (int) $request->input('count'))); } return response()->json($query->get()); } // NEWEST media, paginated (?per_page=, ?page=). Same visibility scope as index. public function newest(Request $request) { $perPage = (int) $request->input('per_page', 20); $perPage = max(1, min($perPage, 100)); $media = Media::with(['image', 'detailImage', 'categories', 'subCategories', 'myNote', 'tags']) ->where(function ($q) { $q->where('visibility', 'public') ->orWhere('user_id', auth()->id()); }) ->orderBy('created_at', 'desc') ->paginate($perPage); return response()->json($media); } public function submitFeedback(Request $request, $mediaId) { $request->validate([ 'stars' => 'nullable|integer|min:1|max:5', 'content' => 'nullable|string|max:2000', ]); // حداقل یکی باید ارسال شود if (!$request->filled('stars') && !$request->filled('content')) { return response()->json([ 'message' => 'Stars or comment is required' ], 422); } $media = Media::where('id', $mediaId) ->where(function ($q) { $q->where('visibility', 'public') ->orWhere('user_id', auth()->id()); }) ->firstOrFail(); DB::beginTransaction(); try { $rating = null; $comment = null; /* |-------------------------------------------------------------------------- | ⭐ Handle Rating (update or create) |-------------------------------------------------------------------------- */ if ($request->filled('stars')) { $rating = $media->ratings()->updateOrCreate( ['user_id' => auth()->id()], ['stars' => $request->stars] ); } /* |-------------------------------------------------------------------------- | 💬 Handle Comment (create only if exists) |-------------------------------------------------------------------------- */ if ($request->filled('content')) { $comment = $media->comments()->create([ 'user_id' => auth()->id(), 'content' => $request->content, ]); } DB::commit(); return response()->json([ 'message' => 'Feedback submitted successfully', 'average_rating' => $media->fresh()->average_rating, 'your_rating' => optional($rating)->stars, 'comment' => $comment ? $comment->load('user') : null, ]); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'message' => 'Something went wrong', 'error' => $e->getMessage() ], 500); } } public function filters(Request $request) { $baseQuery = Media::query() ->where(function ($q) { $q->where('visibility', 'public') ->orWhere('user_id', auth()->id()); }); /* |-------------------------------------------------------------------------- | 1️⃣ Categories with media count |-------------------------------------------------------------------------- */ $visibleMedia = function ($q) { $q->where(function ($inner) { $inner->where('media.visibility', 'public') ->orWhere('media.user_id', auth()->id()); }); }; $categories = Category::query() ->withCount(['media as media_count' => $visibleMedia]) ->orderByDesc('media_count') ->get(['id', 'name', 'description', 'icon']); /* |-------------------------------------------------------------------------- | 1️⃣.5 Subcategories with media count |-------------------------------------------------------------------------- */ $subcategories = SubCategory::query() ->withCount(['media as media_count' => $visibleMedia]) ->orderByDesc('media_count') ->get(['id', 'category_id', 'name', 'description']); /* |-------------------------------------------------------------------------- | 2️⃣ Duration ranges |-------------------------------------------------------------------------- | duration is stored in seconds |-------------------------------------------------------------------------- */ $durations = Media::select( DB::raw(" CASE WHEN duration BETWEEN 1 AND 2 THEN '1-2' WHEN duration BETWEEN 3 AND 5 THEN '2-5' WHEN duration BETWEEN 5 AND 10 THEN '5-10' WHEN duration BETWEEN 10 AND 20 THEN '10-20' WHEN duration BETWEEN 20 AND 30 THEN '20-30' WHEN duration BETWEEN 60 AND 120 THEN '60-120' ELSE 'other' END as duration_range "), DB::raw('COUNT(*) as total') ) ->whereNotNull('duration') ->where(function ($q) { $q->where('visibility', 'public') ->orWhere('user_id', auth()->id()); }) ->groupBy('duration_range') ->get(); return response()->json([ 'categories' => $categories, 'subcategories' => $subcategories, 'durations' => $durations, ]); } public function show($id) { $media = Media::with([ 'image', 'detailImage', 'categories', 'subCategories', 'myNote', 'tags', 'comments' => function($query) { $query->with('user')->latest()->limit(10); }, 'ratings' ]) ->where('id', $id) ->where(function($query) { $query->where('visibility', 'public') ->orWhere('user_id', auth()->id()); }) ->firstOrFail(); // Get user's specific comment $userComment = $media->userComment(); // Get paginated comments $comments = $media->comments() ->with('user') ->latest() ->paginate(15); $similarMedia = $this->similarMedia($media); return response()->json([ 'id' => $media->id, 'title' => $media->title, 'caption' => $media->caption, 'type' => $media->type, 'file_path' => $media->file_path, 'external_url' => $media->external_url, 'duration' => $media->duration, 'visibility' => $media->visibility, 'created_at' => $media->created_at, 'updated_at' => $media->updated_at, 'is_premium' => $media->is_premium, 'image' => $media->image, 'detail_image' => $media->detailImage, 'categories' => $media->categories, 'sub_categories' => $media->subCategories, 'tags' => $media->tags, 'myNote' => $media->myNote, 'is_saved' => $media->is_saved, 'statistics' => [ 'average_rating' => $media->average_rating, 'total_ratings' => $media->ratings_count, 'total_comments' => $media->comments_count, 'rating_distribution' => $media->rating_distribution, ], 'user_interaction' => [ 'has_rated' => $media->has_user_rated, 'user_rating' => $media->user_rating, 'has_commented' => $media->has_user_commented, 'user_comment' => $media->user_comment, 'user_comment_id' => $media->user_comment_id, ], 'comments' => $comments, 'similar_media' => $similarMedia, ]); } // Media that shares categories / subcategories / tags with the given one, // ranked by how much they overlap. Excludes the media itself. private function similarMedia(Media $media, int $limit = 5) { $categoryIds = $media->categories->pluck('id')->all(); $subCategoryIds = $media->subCategories->pluck('id')->all(); $tagIds = $media->tags->pluck('id')->all(); if (!$categoryIds && !$subCategoryIds && !$tagIds) { return collect(); } $userId = auth()->id(); return Media::query() ->where('media.id', '!=', $media->id) ->where('media.type', $media->type) ->where(function ($q) use ($userId) { $q->where('visibility', 'public')->orWhere('user_id', $userId); }) ->where(function ($q) use ($categoryIds, $subCategoryIds, $tagIds) { if ($categoryIds) { $q->orWhereHas('categories', fn ($c) => $c->whereIn('categories.id', $categoryIds)); } if ($subCategoryIds) { $q->orWhereHas('subCategories', fn ($s) => $s->whereIn('sub_categories.id', $subCategoryIds)); } if ($tagIds) { $q->orWhereHas('tags', fn ($t) => $t->whereIn('tags.id', $tagIds)); } }) ->withCount([ 'categories as category_matches' => fn ($c) => $c->whereIn('categories.id', $categoryIds ?: [0]), 'subCategories as subcategory_matches' => fn ($s) => $s->whereIn('sub_categories.id', $subCategoryIds ?: [0]), 'tags as tag_matches' => fn ($t) => $t->whereIn('tags.id', $tagIds ?: [0]), ]) ->with(['image', 'detailImage', 'categories', 'subCategories', 'tags']) // Pull a recent candidate pool (portable; Postgres can't ORDER BY alias // expressions), then rank by overlap in PHP. ->orderByDesc('created_at') ->limit(60) ->get() ->sortByDesc(fn ($m) => $m->category_matches + $m->subcategory_matches + $m->tag_matches) ->take($limit) ->values(); } public function rate(Request $request, $mediaId) { $request->validate([ 'stars' => 'required|integer|min:1|max:5' ]); $media = Media::findOrFail($mediaId); $rating = $media->ratings()->updateOrCreate( ['user_id' => auth()->id()], ['stars' => $request->stars] ); return response()->json([ 'message' => 'Rating saved', 'average_rating' => $media->fresh()->average_rating, 'your_rating' => $rating->stars ]); } public function storeComment(Request $request, $mediaId) { $request->validate([ 'content' => 'required|string|max:1000' ]); $media = Media::findOrFail($mediaId); $comment = $media->comments()->create([ 'user_id' => auth()->id(), 'content' => $request->content ]); return response()->json([ 'message' => 'Comment added', 'comment' => $comment->load('user') ]); } public function getComments($mediaId) { $media = Media::findOrFail($mediaId); return response()->json( $media->comments() ->with('user') ->latest() ->paginate(10) ); } // UPDATE media public function update(Request $request, $id) { $media = Media::where('id', $id) ->where('user_id', auth()->id()) ->firstOrFail(); $data = $request->validate([ 'title' => 'nullable|string', 'caption' => 'nullable|string', 'type' => 'nullable|in:audio,video', 'category_ids' => 'nullable|array', 'category_ids.*' => 'integer|exists:categories,id', 'subcategory_ids' => 'nullable|array', 'subcategory_ids.*' => 'integer|exists:sub_categories,id', 'is_premium' => 'nullable|boolean', 'image_id' => 'nullable|exists:images,id', 'image' => 'nullable|file|extensions:jpg,jpeg,png,gif,webp,bmp,svg|max:8192', 'detail_image_id' => 'nullable|exists:images,id', 'detail_image' => 'nullable|file|extensions:jpg,jpeg,png,gif,webp,bmp,svg|max:8192', 'duration' => 'nullable|integer', 'file' => 'nullable|file|extensions:mp3,wav,ogg,flac,aac,m4a,opus,mpga,wma,mp4,mov,m4v,webm,mkv,avi,3gp|max:512000', 'external_url' => 'nullable|string', 'visibility' => 'nullable|in:public,private', 'tags' => 'nullable|array', 'tags.*' => 'string', ]); // --- handle file replace --- if ($request->hasFile('file')) { if ($media->file_path) { Storage::disk('public')->delete($media->file_path); } $data['file_path'] = $this->storeUpload($request->file('file'), 'media'); // Keep external_url pointing at the newly uploaded file for the old front-end. $data['external_url'] = asset('storage/' . $data['file_path']); } // --- Prepare update data --- $updateData = [ 'title' => $data['title'] ?? $media->title, 'caption' => $data['caption'] ?? $media->caption, 'type' => $data['type'] ?? $media->type, 'is_premium' => $data['is_premium'] ?? $media->is_premium, 'duration' => $data['duration'] ?? $media->duration, 'external_url' => $data['external_url'] ?? $media->external_url, 'visibility' => $data['visibility'] ?? $media->visibility, 'file_path' => $data['file_path'] ?? $media->file_path, ]; // --- Handle image --- // An uploaded image file wins; otherwise an explicit image_id (even null to // clear) is honored; otherwise the existing value is kept. $uploadedImageId = $this->uploadedImageId($request); if ($uploadedImageId !== null) { $updateData['image_id'] = $uploadedImageId; } elseif (array_key_exists('image_id', $data)) { $updateData['image_id'] = $data['image_id']; } else { $updateData['image_id'] = $media->image_id; } // --- Handle detail image (shown on the show-by-id screen) --- $uploadedDetailImageId = $this->uploadedImageId($request, 'detail_image'); if ($uploadedDetailImageId !== null) { $updateData['detail_image_id'] = $uploadedDetailImageId; } elseif (array_key_exists('detail_image_id', $data)) { $updateData['detail_image_id'] = $data['detail_image_id']; } else { $updateData['detail_image_id'] = $media->detail_image_id; } // --- update media --- $media->update($updateData); if (array_key_exists('category_ids', $data)) { $media->categories()->sync($data['category_ids'] ?? []); } if (array_key_exists('subcategory_ids', $data)) { $media->subCategories()->sync($data['subcategory_ids'] ?? []); } if (isset($data['tags'])) { $tagIds = []; foreach ($data['tags'] as $tagName) { $tag = Tag::firstOrCreate(['name' => $tagName]); $tagIds[] = $tag->id; } $media->tags()->sync($tagIds); } return response()->json([ 'message' => 'Media updated successfully', 'media' => $media->load(['image', 'detailImage', 'categories', 'subCategories', 'tags']), ]); } public function destroy($id) { $media = Media::where('id', $id) ->where('user_id', auth()->id()) ->firstOrFail(); if ($media->file_path) { Storage::disk('public')->delete($media->file_path); } $media->delete(); return response()->json(['message' => 'Media deleted']); } // SAVE media public function toggleSaveMedia($id) { $user = auth()->user(); if ($user->savedMedia()->where('media_id', $id)->exists()) { // already saved → unsave $user->savedMedia()->detach($id); return response()->json(['message' => 'Unsaved!']); } else { // not saved → save $user->savedMedia()->attach($id); return response()->json(['message' => 'Saved!']); } } // GET saved public function saved() { return auth()->user()->savedMedia()->with(['image','detailImage','categories', 'subCategories', 'myNote' , 'tags'])->get(); } // RECORD a play for the current user (feeds popular + recently played). public function recordPlay($id) { $media = Media::where('id', $id) ->where(function ($q) { $q->where('visibility', 'public')->orWhere('user_id', auth()->id()); }) ->firstOrFail(); $play = MediaPlay::firstOrNew([ 'user_id' => auth()->id(), 'media_id' => $media->id, ]); $play->play_count = ($play->play_count ?? 0) + 1; $play->last_played_at = now(); $play->save(); return response()->json([ 'message' => 'Play recorded', 'play_count' => $play->play_count, 'last_played_at' => $play->last_played_at, ]); } // POPULAR media (global), ranked by total play count across all users. public function popular(Request $request) { $limit = (int) $request->input('limit', 20); $media = Media::query() ->where(function ($q) { $q->where('visibility', 'public')->orWhere('user_id', auth()->id()); }) ->withCount('plays as listeners_count') // distinct users who played ->withSum('plays as plays_count', 'play_count') // total plays ->with(['image', 'detailImage', 'categories', 'subCategories', 'tags']) ->orderByDesc('plays_count') ->orderByDesc('listeners_count') ->orderByDesc('created_at') ->limit($limit) ->get(); return response()->json($media); } // RECENTLY PLAYED media for the current user, most recent first. public function recentlyPlayed(Request $request) { $limit = (int) $request->input('limit', 20); $plays = MediaPlay::where('user_id', auth()->id()) ->whereNotNull('last_played_at') ->with(['media' => fn ($q) => $q->with(['image', 'detailImage', 'categories', 'subCategories', 'tags'])]) ->orderByDesc('last_played_at') ->limit($limit) ->get(); $media = $plays->map(function ($play) { $media = $play->media; if (!$media) { return null; } $media->last_played_at = $play->last_played_at; $media->play_count = $play->play_count; return $media; })->filter()->values(); return response()->json($media); } public function storeNote(Request $request, $mediaId) { $media = Media::findOrFail($mediaId); $userId = auth()->id(); $content = $request->input('content'); // Check if note already exists for this user $note = $media->notes()->where('user_id', $userId)->first(); if (empty($content)) { // If content is empty, delete the note if it exists if ($note) { $note->delete(); return response()->json(['message' => 'Note deleted']); } return response()->json(['message' => 'No note to delete']); } if ($note) { // Update existing note $note->update(['content' => $content]); } else { // Create new note $note = $media->notes()->create([ 'user_id' => $userId, 'content' => $content, ]); } return response()->json([ 'message' => 'Note saved successfully', 'note' => $note ]); } }