feat: deep link added

This commit is contained in:
2026-09-02 10:30:02 +03:30
parent 3f3b5b1d69
commit a3cda12560
9 changed files with 256 additions and 11 deletions
+54 -1
View File
@@ -23,7 +23,19 @@ public function index(Request $request)
return response()->json($this->playlistCategories($request)->values()); 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')) { if ($request->filled('type')) {
$query->where('type', $type); $query->where('type', $type);
@@ -91,6 +103,7 @@ public function store(Request $request)
'order' => 'nullable|integer', 'order' => 'nullable|integer',
'description' => 'nullable|string', 'description' => 'nullable|string',
'icon' => 'nullable|file|extensions:jpg,jpeg,png,gif,webp,bmp,svg|max:8192', 'icon' => 'nullable|file|extensions:jpg,jpeg,png,gif,webp,bmp,svg|max:8192',
'is_deeplink_only' => 'nullable|boolean',
]); ]);
$category = Category::create([ $category = Category::create([
@@ -101,6 +114,7 @@ public function store(Request $request)
'icon' => $request->hasFile('icon') 'icon' => $request->hasFile('icon')
? $this->storeUpload($request->file('icon'), 'categories/icons') ? $this->storeUpload($request->file('icon'), 'categories/icons')
: null, : null,
'is_deeplink_only' => $data['is_deeplink_only'] ?? false,
]); ]);
return response()->json([ return response()->json([
@@ -111,11 +125,45 @@ public function store(Request $request)
public function show($id) 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); $category = Category::with('subcategories')->withCount('subcategories')->findOrFail($id);
return response()->json($category); 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. // Category with each of its sub-categories and that sub-category's medias.
public function grouped($id) public function grouped($id)
{ {
@@ -163,6 +211,7 @@ public function update(Request $request, $id)
'order' => 'nullable|integer', 'order' => 'nullable|integer',
'description' => 'nullable|string', 'description' => 'nullable|string',
'icon' => 'nullable|file|extensions:jpg,jpeg,png,gif,webp,bmp,svg|max:8192', '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) { 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'); $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(); $category->save();
return response()->json([ return response()->json([
+80 -7
View File
@@ -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', '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', 'external_url' => 'nullable|string',
'visibility' => 'nullable|in:public,private', 'visibility' => 'nullable|in:public,private',
'is_deeplink_only' => 'nullable|boolean',
'tags' => 'nullable|array', 'tags' => 'nullable|array',
'tags.*' => 'string', 'tags.*' => 'string',
@@ -66,7 +67,8 @@ public function store(Request $request)
'detail_image_id' => $detailImageId, 'detail_image_id' => $detailImageId,
'duration' => $data['duration'] ?? null, 'duration' => $data['duration'] ?? null,
'visibility' => $data['visibility'] ?? 'public', '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'] ?? []); $media->categories()->sync($data['category_ids'] ?? []);
@@ -89,11 +91,23 @@ public function store(Request $request)
} }
public function index(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']) $query = Media::with(['image', 'detailImage', 'categories', 'subCategories', 'myNote', 'tags','comments'])
->where(function ($q) { ->where(function ($q) {
$q->where('visibility', 'public') $q->where('visibility', 'public')
->orWhere('user_id', auth()->id()); ->orWhere('user_id', auth()->id());
}); })
->where('is_deeplink_only', false);
$this->applyMediaFilters($query, $request); $this->applyMediaFilters($query, $request);
@@ -213,6 +227,7 @@ public function newest(Request $request)
$q->where('visibility', 'public') $q->where('visibility', 'public')
->orWhere('user_id', auth()->id()); ->orWhere('user_id', auth()->id());
}) })
->where('is_deeplink_only', false)
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->paginate($perPage); ->paginate($perPage);
@@ -298,7 +313,8 @@ public function filters(Request $request)
->where(function ($q) { ->where(function ($q) {
$q->where('visibility', 'public') $q->where('visibility', 'public')
->orWhere('user_id', auth()->id()); ->orWhere('user_id', auth()->id());
}); })
->where('is_deeplink_only', false);
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -310,7 +326,7 @@ public function filters(Request $request)
$q->where(function ($inner) { $q->where(function ($inner) {
$inner->where('media.visibility', 'public') $inner->where('media.visibility', 'public')
->orWhere('media.user_id', auth()->id()); ->orWhere('media.user_id', auth()->id());
}); })->where('media.is_deeplink_only', false);
}; };
$categories = Category::query() $categories = Category::query()
@@ -354,6 +370,7 @@ public function filters(Request $request)
$q->where('visibility', 'public') $q->where('visibility', 'public')
->orWhere('user_id', auth()->id()); ->orWhere('user_id', auth()->id());
}) })
->where('is_deeplink_only', false)
->groupBy('duration_range') ->groupBy('duration_range')
->pluck('total', 'duration_range'); ->pluck('total', 'duration_range');
@@ -381,7 +398,8 @@ public function search(Request $request)
->where(function ($q) { ->where(function ($q) {
$q->where('visibility', 'public') $q->where('visibility', 'public')
->orWhere('user_id', auth()->id()); ->orWhere('user_id', auth()->id());
}); })
->where('is_deeplink_only', false);
$this->applyMediaFilters($query, $request); $this->applyMediaFilters($query, $request);
@@ -412,7 +430,8 @@ public function show($id)
->where('id', $id) ->where('id', $id)
->where(function($query) { ->where(function($query) {
$query->where('visibility', 'public') $query->where('visibility', 'public')
->orWhere('user_id', auth()->id()); ->orWhere('user_id', auth()->id())
->orWhere('is_deeplink_only', true);
}) })
->firstOrFail(); ->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, // Media that shares categories / subcategories / tags with the given one,
// ranked by how much they overlap. Excludes the media itself. // ranked by how much they overlap. Excludes the media itself.
private function similarMedia(Media $media, int $limit = 5) private function similarMedia(Media $media, int $limit = 5)
@@ -488,6 +557,7 @@ private function similarMedia(Media $media, int $limit = 5)
return Media::query() return Media::query()
->where('media.id', '!=', $media->id) ->where('media.id', '!=', $media->id)
->where('media.type', $media->type) ->where('media.type', $media->type)
->where('media.is_deeplink_only', false)
->where(function ($q) use ($userId) { ->where(function ($q) use ($userId) {
$q->where('visibility', 'public')->orWhere('user_id', $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', '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', 'external_url' => 'nullable|string',
'visibility' => 'nullable|in:public,private', 'visibility' => 'nullable|in:public,private',
'is_deeplink_only' => 'nullable|boolean',
'tags' => 'nullable|array', 'tags' => 'nullable|array',
'tags.*' => 'string', 'tags.*' => 'string',
]); ]);
@@ -616,6 +687,7 @@ public function update(Request $request, $id)
'duration' => $data['duration'] ?? $media->duration, 'duration' => $data['duration'] ?? $media->duration,
'external_url' => $data['external_url'] ?? $media->external_url, 'external_url' => $data['external_url'] ?? $media->external_url,
'visibility' => $data['visibility'] ?? $media->visibility, '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, 'file_path' => $data['file_path'] ?? $media->file_path,
]; ];
@@ -741,6 +813,7 @@ public function popular(Request $request)
->where(function ($q) { ->where(function ($q) {
$q->where('visibility', 'public')->orWhere('user_id', auth()->id()); $q->where('visibility', 'public')->orWhere('user_id', auth()->id());
}) })
->where('is_deeplink_only', false)
->withCount('plays as listeners_count') // distinct users who played ->withCount('plays as listeners_count') // distinct users who played
->withSum('plays as plays_count', 'play_count') // total plays ->withSum('plays as plays_count', 'play_count') // total plays
->with(['image', 'detailImage', 'categories', 'subCategories', 'tags']) ->with(['image', 'detailImage', 'categories', 'subCategories', 'tags'])
@@ -760,7 +833,7 @@ public function recentlyPlayed(Request $request)
$plays = MediaPlay::where('user_id', auth()->id()) $plays = MediaPlay::where('user_id', auth()->id())
->whereNotNull('last_played_at') ->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') ->orderByDesc('last_played_at')
->limit($limit) ->limit($limit)
->get(); ->get();
+14 -2
View File
@@ -16,19 +16,31 @@ class Category extends Model
self::TYPE_BREATHING_TEMPLATE, self::TYPE_BREATHING_TEMPLATE,
]; ];
protected $fillable = ['name', 'type', 'order', 'description', 'icon']; protected $fillable = ['name', 'type', 'order', 'description', 'icon', 'is_deeplink_only'];
protected $casts = [ protected $casts = [
'order' => 'integer', 'order' => 'integer',
'is_deeplink_only' => 'boolean',
]; ];
protected $appends = ['icon_url']; protected $appends = ['icon_url', 'deep_link'];
public function getIconUrlAttribute(): ?string public function getIconUrlAttribute(): ?string
{ {
return $this->icon ? asset('storage/' . $this->icon) : null; 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() public function questions()
{ {
return $this->hasMany(Question::class); return $this->hasMany(Question::class);
+19 -1
View File
@@ -21,7 +21,12 @@ class Media extends Model
'external_url', 'external_url',
'duration', 'duration',
'visibility', 'visibility',
'is_premium' 'is_premium',
'is_deeplink_only'
];
protected $casts = [
'is_deeplink_only' => 'boolean',
]; ];
protected $appends = [ protected $appends = [
'average_rating', 'average_rating',
@@ -37,6 +42,7 @@ class Media extends Model
'is_liked', 'is_liked',
'likes_count', 'likes_count',
'url', 'url',
'deep_link',
]; ];
public function image() public function image()
{ {
@@ -89,6 +95,18 @@ public function getUrlAttribute()
return $this->external_url; 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), // is_saved / saved_count come from the HasSaves trait (saved_items table),
// so the /saves/* endpoints and the media list/show all agree. // so the /saves/* endpoints and the media list/show all agree.
} }
+16
View File
@@ -0,0 +1,16 @@
<?php
// Deep link / share URL configuration. The canonical domain the mobile app and
// web share deep links in (e.g. https://aramejan.com), with {id} placeholders
// (the ID of the media / category).
return [
// Canonical deep link domain (without trailing slash).
'base_url' => 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}'),
];
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('media', function (Blueprint $table) {
// When true the media is hidden from all lists/search but still
// reachable through its deep link (https://aramejan.com/media/{id}).
$table->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');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('categories', function (Blueprint $table) {
// When true the category is hidden from list/search but still
// reachable through its deep link (https://aramejan.com/category/{id}).
$table->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');
});
}
};
+6
View File
@@ -52,6 +52,12 @@
Route::get('package-names/{name}/products', [ProductController::class, 'index']); 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::prefix('auth')->controller(UserController::class)->group(function () {
Route::post('login-with-approo-v2', [UserController::class, 'loginV2']); Route::post('login-with-approo-v2', [UserController::class, 'loginV2']);
Route::middleware(['throttle:3,1'])->post('login-otp', 'loginOTP'); Route::middleware(['throttle:3,1'])->post('login-otp', 'loginOTP');
+7
View File
@@ -1,7 +1,14 @@
<?php <?php
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\MediaController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::get('/', function () {
return view('welcome'); return view('welcome');
}); });
// Canonical deep links (e.g. https://aramejan.com/media/3). Served as JSON so
// the mobile app / web can fetch the hidden item reached through the deep link.
Route::get('/media/{id}', [MediaController::class, 'publicShow']);
Route::get('/category/{id}', [CategoryController::class, 'publicShow']);