From a3cda1256078aa75afdd447673adafc6206543e5 Mon Sep 17 00:00:00 2001 From: AmirmahdiNourkazemi Date: Wed, 2 Sep 2026 10:30:02 +0330 Subject: [PATCH] feat: deep link added --- app/Http/Controllers/CategoryController.php | 55 +++++++++++- app/Http/Controllers/MediaController.php | 87 +++++++++++++++++-- app/Models/Category.php | 16 +++- app/Models/Media.php | 20 ++++- config/deeplinks.php | 16 ++++ ...00_add_deeplink_columns_to_media_table.php | 30 +++++++ ...d_deeplink_columns_to_categories_table.php | 30 +++++++ routes/api.php | 6 ++ routes/web.php | 7 ++ 9 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 config/deeplinks.php create mode 100644 database/migrations/2026_09_02_100000_add_deeplink_columns_to_media_table.php create mode 100644 database/migrations/2026_09_02_100100_add_deeplink_columns_to_categories_table.php diff --git a/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/CategoryController.php index a1ca6b7..f7dcb05 100644 --- a/app/Http/Controllers/CategoryController.php +++ b/app/Http/Controllers/CategoryController.php @@ -23,7 +23,19 @@ public function index(Request $request) return response()->json($this->playlistCategories($request)->values()); } - $query = Category::query()->withCount('subcategories'); + // Admin listing of deep-link (hidden) categories: ?deeplink_only=1 + // returns only the hidden records so the admin can manage their links. + if ($request->boolean('deeplink_only')) { + $hidden = Category::query()->withCount('subcategories') + ->where('is_deeplink_only', true) + ->orderBy('order') + ->orderBy('name') + ->get(); + + return response()->json($hidden); + } + + $query = Category::query()->withCount('subcategories')->where('is_deeplink_only', false); if ($request->filled('type')) { $query->where('type', $type); @@ -91,6 +103,7 @@ public function store(Request $request) 'order' => 'nullable|integer', 'description' => 'nullable|string', 'icon' => 'nullable|file|extensions:jpg,jpeg,png,gif,webp,bmp,svg|max:8192', + 'is_deeplink_only' => 'nullable|boolean', ]); $category = Category::create([ @@ -101,6 +114,7 @@ public function store(Request $request) 'icon' => $request->hasFile('icon') ? $this->storeUpload($request->file('icon'), 'categories/icons') : null, + 'is_deeplink_only' => $data['is_deeplink_only'] ?? false, ]); return response()->json([ @@ -111,11 +125,45 @@ public function store(Request $request) public function show($id) { + // Includes hidden (deeplink-only) categories too, so a category reached + // through its deep link can still be shown to the app. $category = Category::with('subcategories')->withCount('subcategories')->findOrFail($id); return response()->json($category); } + // Public (no auth) deep link lookup by ID. Hidden (deeplink-only) categories + // are the only ones served here, since they are discovered via the deep link. + public function publicShow($id) + { + $category = Category::with(['subcategories' => function ($q) { + $q->orderBy('name')->with(['media' => function ($m) { + $m->with(['image', 'detailImage', 'tags'])->latest(); + }]); + }])->where('id', $id)->where('is_deeplink_only', true)->firstOrFail(); + + return response()->json([ + 'category' => [ + 'id' => $category->id, + 'name' => $category->name, + 'description' => $category->description, + 'icon_url' => $category->icon_url, + 'icon' => $category->icon, + 'type' => $category->type, + 'is_deeplink_only' => $category->is_deeplink_only, + 'deep_link' => $category->deep_link, + ], + 'sub_categories' => $category->subcategories->map(fn ($sub) => [ + 'sub_category' => [ + 'id' => $sub->id, + 'name' => $sub->name, + 'description' => $sub->description, + ], + 'medias' => $sub->media, + ])->values(), + ]); + } + // Category with each of its sub-categories and that sub-category's medias. public function grouped($id) { @@ -163,6 +211,7 @@ public function update(Request $request, $id) 'order' => 'nullable|integer', 'description' => 'nullable|string', 'icon' => 'nullable|file|extensions:jpg,jpeg,png,gif,webp,bmp,svg|max:8192', + 'is_deeplink_only' => 'nullable|boolean', ]); if (array_key_exists('type', $data) && $data['type'] !== null) { @@ -184,6 +233,10 @@ public function update(Request $request, $id) $category->icon = $this->storeUpload($request->file('icon'), 'categories/icons'); } + if (array_key_exists('is_deeplink_only', $data)) { + $category->is_deeplink_only = $data['is_deeplink_only']; + } + $category->save(); return response()->json([ diff --git a/app/Http/Controllers/MediaController.php b/app/Http/Controllers/MediaController.php index 9a4a9fc..9322928 100644 --- a/app/Http/Controllers/MediaController.php +++ b/app/Http/Controllers/MediaController.php @@ -37,6 +37,7 @@ public function store(Request $request) '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', + 'is_deeplink_only' => 'nullable|boolean', 'tags' => 'nullable|array', 'tags.*' => 'string', @@ -66,7 +67,8 @@ public function store(Request $request) 'detail_image_id' => $detailImageId, 'duration' => $data['duration'] ?? null, 'visibility' => $data['visibility'] ?? 'public', - 'is_premium'=> $data['is_premium'] ?? false + 'is_premium'=> $data['is_premium'] ?? false, + 'is_deeplink_only' => $data['is_deeplink_only'] ?? false ]); $media->categories()->sync($data['category_ids'] ?? []); @@ -89,11 +91,23 @@ public function store(Request $request) } public function index(Request $request) { + // Admin listing of deep-link (hidden) media: ?deeplink_only=1 returns only + // the hidden records so the admin can manage their share links. + if ($request->boolean('deeplink_only')) { + $hidden = Media::with(['image', 'detailImage', 'categories', 'subCategories', 'tags']) + ->where('is_deeplink_only', true) + ->orderBy('created_at', 'desc') + ->get(); + + return response()->json($hidden); + } + $query = Media::with(['image', 'detailImage', 'categories', 'subCategories', 'myNote', 'tags','comments']) ->where(function ($q) { $q->where('visibility', 'public') ->orWhere('user_id', auth()->id()); - }); + }) + ->where('is_deeplink_only', false); $this->applyMediaFilters($query, $request); @@ -213,6 +227,7 @@ public function newest(Request $request) $q->where('visibility', 'public') ->orWhere('user_id', auth()->id()); }) + ->where('is_deeplink_only', false) ->orderBy('created_at', 'desc') ->paginate($perPage); @@ -298,7 +313,8 @@ public function filters(Request $request) ->where(function ($q) { $q->where('visibility', 'public') ->orWhere('user_id', auth()->id()); - }); + }) + ->where('is_deeplink_only', false); /* |-------------------------------------------------------------------------- @@ -310,7 +326,7 @@ public function filters(Request $request) $q->where(function ($inner) { $inner->where('media.visibility', 'public') ->orWhere('media.user_id', auth()->id()); - }); + })->where('media.is_deeplink_only', false); }; $categories = Category::query() @@ -354,6 +370,7 @@ public function filters(Request $request) $q->where('visibility', 'public') ->orWhere('user_id', auth()->id()); }) + ->where('is_deeplink_only', false) ->groupBy('duration_range') ->pluck('total', 'duration_range'); @@ -381,7 +398,8 @@ public function search(Request $request) ->where(function ($q) { $q->where('visibility', 'public') ->orWhere('user_id', auth()->id()); - }); + }) + ->where('is_deeplink_only', false); $this->applyMediaFilters($query, $request); @@ -412,7 +430,8 @@ public function show($id) ->where('id', $id) ->where(function($query) { $query->where('visibility', 'public') - ->orWhere('user_id', auth()->id()); + ->orWhere('user_id', auth()->id()) + ->orWhere('is_deeplink_only', true); }) ->firstOrFail(); @@ -471,6 +490,56 @@ public function show($id) ]); } +// Public (no auth) deep link lookup by ID. Returns the same shape as show() +// minus the user-specific fields. Hidden (deeplink-only) media is included, +// since the deep link is how such media is discovered. +public function publicShow($id) +{ + $media = Media::with([ + 'image', + 'detailImage', + 'categories', + 'subCategories', + 'tags', + 'comments' => function($query) { + $query->with('user')->latest()->limit(10); + }, + ]) + ->where('id', $id) + ->where('is_deeplink_only', true) + ->firstOrFail(); + + 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, + 'is_premium' => $media->is_premium, + 'is_deeplink_only' => $media->is_deeplink_only, + 'deep_link' => $media->deep_link, + 'created_at' => $media->created_at, + 'updated_at' => $media->updated_at, + 'image' => $media->image, + 'detail_image' => $media->detailImage, + 'categories' => $media->categories, + 'sub_categories' => $media->subCategories, + 'tags' => $media->tags, + 'comments' => $media->comments, + 'statistics' => [ + 'average_rating' => $media->average_rating, + 'total_ratings' => $media->ratings_count, + 'total_comments' => $media->comments_count, + 'total_likes' => $media->likes_count, + 'total_saves' => $media->saved_count, + 'rating_distribution' => $media->rating_distribution, + ], + ]); +} + // 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) @@ -488,6 +557,7 @@ private function similarMedia(Media $media, int $limit = 5) return Media::query() ->where('media.id', '!=', $media->id) ->where('media.type', $media->type) + ->where('media.is_deeplink_only', false) ->where(function ($q) use ($userId) { $q->where('visibility', 'public')->orWhere('user_id', $userId); }) @@ -593,6 +663,7 @@ public function update(Request $request, $id) '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', + 'is_deeplink_only' => 'nullable|boolean', 'tags' => 'nullable|array', 'tags.*' => 'string', ]); @@ -616,6 +687,7 @@ public function update(Request $request, $id) 'duration' => $data['duration'] ?? $media->duration, 'external_url' => $data['external_url'] ?? $media->external_url, 'visibility' => $data['visibility'] ?? $media->visibility, + 'is_deeplink_only' => array_key_exists('is_deeplink_only', $data) ? $data['is_deeplink_only'] : $media->is_deeplink_only, 'file_path' => $data['file_path'] ?? $media->file_path, ]; @@ -741,6 +813,7 @@ public function popular(Request $request) ->where(function ($q) { $q->where('visibility', 'public')->orWhere('user_id', auth()->id()); }) + ->where('is_deeplink_only', false) ->withCount('plays as listeners_count') // distinct users who played ->withSum('plays as plays_count', 'play_count') // total plays ->with(['image', 'detailImage', 'categories', 'subCategories', 'tags']) @@ -760,7 +833,7 @@ public function recentlyPlayed(Request $request) $plays = MediaPlay::where('user_id', auth()->id()) ->whereNotNull('last_played_at') - ->with(['media' => fn ($q) => $q->with(['image', 'detailImage', 'categories', 'subCategories', 'tags'])]) + ->with(['media' => fn ($q) => $q->where('is_deeplink_only', false)->with(['image', 'detailImage', 'categories', 'subCategories', 'tags'])]) ->orderByDesc('last_played_at') ->limit($limit) ->get(); diff --git a/app/Models/Category.php b/app/Models/Category.php index 525ea12..0b8a6f5 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -16,19 +16,31 @@ class Category extends Model self::TYPE_BREATHING_TEMPLATE, ]; - protected $fillable = ['name', 'type', 'order', 'description', 'icon']; + protected $fillable = ['name', 'type', 'order', 'description', 'icon', 'is_deeplink_only']; protected $casts = [ 'order' => 'integer', + 'is_deeplink_only' => 'boolean', ]; - protected $appends = ['icon_url']; + protected $appends = ['icon_url', 'deep_link']; public function getIconUrlAttribute(): ?string { return $this->icon ? asset('storage/' . $this->icon) : null; } + public function getDeepLinkAttribute(): ?string + { + $canonical = config('deeplinks.category_pattern'); + + if ($canonical) { + return rtrim(str_replace('{id}', $this->id, $canonical), '/'); + } + + return url('/category/' . $this->id); + } + public function questions() { return $this->hasMany(Question::class); diff --git a/app/Models/Media.php b/app/Models/Media.php index 3646547..1f6992c 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -21,7 +21,12 @@ class Media extends Model 'external_url', 'duration', 'visibility', - 'is_premium' + 'is_premium', + 'is_deeplink_only' + ]; + + protected $casts = [ + 'is_deeplink_only' => 'boolean', ]; protected $appends = [ 'average_rating', @@ -37,6 +42,7 @@ class Media extends Model 'is_liked', 'likes_count', 'url', + 'deep_link', ]; public function image() { @@ -89,6 +95,18 @@ public function getUrlAttribute() return $this->external_url; } + + // Public deep link for this media (canonical aramejan.com form). + public function getDeepLinkAttribute(): ?string + { + $canonical = config('deeplinks.media_pattern'); + + if ($canonical) { + return rtrim(str_replace('{id}', $this->id, $canonical), '/'); + } + + return url('/media/' . $this->id); + } // is_saved / saved_count come from the HasSaves trait (saved_items table), // so the /saves/* endpoints and the media list/show all agree. } diff --git a/config/deeplinks.php b/config/deeplinks.php new file mode 100644 index 0000000..192e7bf --- /dev/null +++ b/config/deeplinks.php @@ -0,0 +1,16 @@ + env('DEEPLINK_BASE_URL', 'https://aramejan.com'), + + // Pattern used for media deep links. + 'media_pattern' => env('DEEPLINK_MEDIA_PATTERN', 'https://aramejan.com/media/{id}'), + + // Pattern used for category deep links. + 'category_pattern' => env('DEEPLINK_CATEGORY_PATTERN', 'https://aramejan.com/category/{id}'), +]; \ No newline at end of file diff --git a/database/migrations/2026_09_02_100000_add_deeplink_columns_to_media_table.php b/database/migrations/2026_09_02_100000_add_deeplink_columns_to_media_table.php new file mode 100644 index 0000000..05654fd --- /dev/null +++ b/database/migrations/2026_09_02_100000_add_deeplink_columns_to_media_table.php @@ -0,0 +1,30 @@ +boolean('is_deeplink_only')->default(false)->after('is_premium'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('media', function (Blueprint $table) { + $table->dropColumn('is_deeplink_only'); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_09_02_100100_add_deeplink_columns_to_categories_table.php b/database/migrations/2026_09_02_100100_add_deeplink_columns_to_categories_table.php new file mode 100644 index 0000000..8c48b51 --- /dev/null +++ b/database/migrations/2026_09_02_100100_add_deeplink_columns_to_categories_table.php @@ -0,0 +1,30 @@ +boolean('is_deeplink_only')->default(false)->after('icon'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('categories', function (Blueprint $table) { + $table->dropColumn('is_deeplink_only'); + }); + } +}; \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 376af99..fbf65ec 100644 --- a/routes/api.php +++ b/routes/api.php @@ -52,6 +52,12 @@ Route::get('package-names/{name}/products', [ProductController::class, 'index']); + +// Public deep links — allow hidden (is_deeplink_only) media/categories to be +// fetched without authentication when reached via their share link. +Route::get('public/media/{id}', [MediaController::class, 'publicShow']); +Route::get('public/categories/{id}', [CategoryController::class, 'publicShow']); + Route::prefix('auth')->controller(UserController::class)->group(function () { Route::post('login-with-approo-v2', [UserController::class, 'loginV2']); Route::middleware(['throttle:3,1'])->post('login-otp', 'loginOTP'); diff --git a/routes/web.php b/routes/web.php index 86a06c5..7cf40ee 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,14 @@