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());
}
$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([
+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',
'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();
+14 -2
View File
@@ -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);
+19 -1
View File
@@ -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.
}
+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']);
// 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');
+7
View File
@@ -1,7 +1,14 @@
<?php
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\MediaController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
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']);