Tüm dosyalar, kodlar ve değişiklikler adım adım
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('active_playback_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->uuid('playback_id')->unique();
$table->string('session_id', 255)->nullable();
$table->unsignedBigInteger('current_song_id')->nullable();
$table->string('device_name', 100)->nullable();
$table->enum('device_type', ['desktop', 'mobile', 'tablet'])->default('desktop');
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->timestamp('last_ping')->useCurrent();
$table->timestamps();
// Indexes
$table->index(['user_id', 'last_ping']);
$table->index('last_ping');
});
}
public function down(): void
{
Schema::dropIfExists('active_playback_sessions');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('subscription_plans', function (Blueprint $table) {
$table->unsignedInteger('concurrent_streams_limit')
->default(1)
->after('device_limit')
->comment('Eşzamanlı aktif playback limiti');
});
}
public function down(): void
{
Schema::table('subscription_plans', function (Blueprint $table) {
$table->dropColumn('concurrent_streams_limit');
});
}
};
# Tenant migration çalıştır
php artisan tenants:migrate --path=database/migrations/tenant/2025_12_17_000001_create_active_playback_sessions_table.php
php artisan tenants:migrate --path=database/migrations/tenant/2025_12_17_000002_add_concurrent_streams_limit_to_subscription_plans.php
# Veya hepsini birden
php artisan tenants:migrate
<?php
namespace Modules\Muzibu\App\Services;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Jenssegers\Agent\Agent;
/**
* PlaybackSessionService - Concurrent Playback Limit Sistemi
*
* Spotify modeli: Sınırsız giriş, sınırlı playback
* Device limit yerine aktif stream limiti uygular
*/
class PlaybackSessionService
{
protected string $table = 'active_playback_sessions';
protected int $pingTimeout = 30; // seconds - 3 ping kaçırırsa expired
/**
* Servis çalışmalı mı?
*/
public function shouldRun(): bool
{
return tenant() !== null;
}
/**
* Yeni playback session başlat
* Play butonuna basıldığında çağrılır
*/
public function registerPlaybackSession(
User $user,
string $playbackId,
?int $songId = null
): array {
if (!$this->shouldRun()) {
return ['success' => true, 'playback_id' => $playbackId];
}
// 1. Expired session'ları temizle
$this->cleanupExpiredSessions();
// 2. Aktif playback sayısını kontrol et
$activeCount = $this->getActivePlaybackCount($user);
$limit = $this->getConcurrentStreamLimit($user);
// 3. Bu playback_id zaten kayıtlı mı? (refresh/reconnect durumu)
$existingSession = DB::table($this->table)
->where('playback_id', $playbackId)
->where('user_id', $user->id)
->first();
if ($existingSession) {
// Mevcut session'ı güncelle
DB::table($this->table)
->where('playback_id', $playbackId)
->update([
'current_song_id' => $songId,
'last_ping' => now(),
'updated_at' => now(),
]);
return [
'success' => true,
'playback_id' => $playbackId,
'resumed' => true,
];
}
// 4. Limit kontrolü (yeni session için)
if ($activeCount >= $limit) {
return [
'success' => false,
'error' => 'max_streams_exceeded',
'message' => "Bu abonelik zaten {$activeCount} yerde çalıyor. Limit: {$limit}",
'active_count' => $activeCount,
'limit' => $limit,
'active_sessions' => $this->getActiveSessions($user),
];
}
// 5. Yeni playback session kaydet
$agent = new Agent();
DB::table($this->table)->insert([
'user_id' => $user->id,
'playback_id' => $playbackId,
'session_id' => session()->getId(),
'current_song_id' => $songId,
'device_name' => $agent->platform() . ' - ' . $agent->browser(),
'device_type' => $this->getDeviceType($agent),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'last_ping' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
\Log::info('🎵 Playback session started', [
'user_id' => $user->id,
'playback_id' => $playbackId,
'song_id' => $songId,
]);
return [
'success' => true,
'playback_id' => $playbackId,
'active_count' => $activeCount + 1,
'limit' => $limit,
];
}
/**
* Playback heartbeat (10 saniyede bir)
*/
public function pingPlaybackSession(
User $user,
string $playbackId,
?int $songId = null
): array {
if (!$this->shouldRun()) {
return ['success' => true];
}
$updated = DB::table($this->table)
->where('playback_id', $playbackId)
->where('user_id', $user->id)
->update([
'last_ping' => now(),
'current_song_id' => $songId,
'updated_at' => now(),
]);
if ($updated === 0) {
// Session bulunamadı - muhtemelen timeout oldu veya silinmiş
return [
'success' => false,
'error' => 'session_not_found',
'message' => 'Playback session bulunamadı. Başka bir yerden durdurulmuş olabilir.',
];
}
return ['success' => true];
}
/**
* Playback session sonlandır (pause/stop)
*/
public function stopPlaybackSession(User $user, string $playbackId): bool
{
if (!$this->shouldRun()) {
return true;
}
$deleted = DB::table($this->table)
->where('playback_id', $playbackId)
->where('user_id', $user->id)
->delete();
if ($deleted > 0) {
\Log::info('🎵 Playback session stopped', [
'user_id' => $user->id,
'playback_id' => $playbackId,
]);
}
return $deleted > 0;
}
/**
* Belirli bir session'ı zorla durdur (admin veya kullanıcı isteği)
*/
public function forceStopSession(User $user, string $playbackId): bool
{
return $this->stopPlaybackSession($user, $playbackId);
}
/**
* Tüm playback session'ları durdur
*/
public function stopAllSessions(User $user): int
{
if (!$this->shouldRun()) {
return 0;
}
return DB::table($this->table)
->where('user_id', $user->id)
->delete();
}
/**
* Concurrent stream limit al
*/
public function getConcurrentStreamLimit(User $user): int
{
// 1. User override (admin tarafından ayarlanabilir)
if ($user->concurrent_streams_limit !== null && $user->concurrent_streams_limit > 0) {
return $user->concurrent_streams_limit;
}
// 2. Subscription Plan
$subscription = $user->subscriptions()
->whereIn('status', ['active', 'trial'])
->where(function($q) {
$q->whereNull('current_period_end')
->orWhere('current_period_end', '>', now());
})
->with('plan')
->first();
if ($subscription && $subscription->plan) {
// Önce concurrent_streams_limit, yoksa device_limit'e fallback
if ($subscription->plan->concurrent_streams_limit) {
return (int) $subscription->plan->concurrent_streams_limit;
}
if ($subscription->plan->device_limit) {
return (int) $subscription->plan->device_limit;
}
}
// 3. Tenant setting fallback
if (function_exists('setting') && setting('concurrent_streams_limit')) {
return (int) setting('concurrent_streams_limit');
}
// 4. Default
return 1;
}
/**
* Aktif playback sayısı
*/
public function getActivePlaybackCount(User $user): int
{
if (!$this->shouldRun()) {
return 0;
}
return DB::table($this->table)
->where('user_id', $user->id)
->where('last_ping', '>', now()->subSeconds($this->pingTimeout))
->count();
}
/**
* Kullanıcının aktif playback session'larını getir
*/
public function getActiveSessions(User $user): array
{
if (!$this->shouldRun()) {
return [];
}
return DB::table($this->table)
->where('user_id', $user->id)
->where('last_ping', '>', now()->subSeconds($this->pingTimeout))
->orderBy('last_ping', 'desc')
->get()
->map(function($session) {
return [
'playback_id' => $session->playback_id,
'device_name' => $session->device_name,
'device_type' => $session->device_type,
'current_song_id' => $session->current_song_id,
'last_ping' => $session->last_ping,
'started_at' => $session->created_at,
'ip_address' => $session->ip_address,
];
})
->toArray();
}
/**
* Expired session'ları temizle (30 saniye ping almayan)
*/
public function cleanupExpiredSessions(): int
{
return DB::table($this->table)
->where('last_ping', '<', now()->subSeconds($this->pingTimeout))
->delete();
}
/**
* Device type belirle
*/
protected function getDeviceType(Agent $agent): string
{
if ($agent->isMobile()) return 'mobile';
if ($agent->isTablet()) return 'tablet';
return 'desktop';
}
}
<?php
namespace Modules\Muzibu\App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Modules\Muzibu\App\Services\PlaybackSessionService;
class PlaybackController extends Controller
{
public function __construct(
private PlaybackSessionService $playbackService
) {}
/**
* POST /api/playback/start
* Play butonuna basıldığında çağrılır
*/
public function start(Request $request): JsonResponse
{
$request->validate([
'playback_id' => 'required|uuid',
'song_id' => 'nullable|integer',
]);
$user = auth('web')->user() ?? auth('sanctum')->user();
if (!$user) {
return response()->json([
'success' => false,
'error' => 'unauthenticated',
], 401);
}
$result = $this->playbackService->registerPlaybackSession(
$user,
$request->playback_id,
$request->song_id
);
if (!$result['success']) {
return response()->json($result, 403);
}
return response()->json($result);
}
/**
* POST /api/playback/ping
* Her 10 saniyede bir heartbeat
*/
public function ping(Request $request): JsonResponse
{
$request->validate([
'playback_id' => 'required|uuid',
'song_id' => 'nullable|integer',
]);
$user = auth('web')->user() ?? auth('sanctum')->user();
if (!$user) {
return response()->json(['success' => false], 401);
}
$result = $this->playbackService->pingPlaybackSession(
$user,
$request->playback_id,
$request->song_id
);
return response()->json($result);
}
/**
* POST /api/playback/stop
* Pause/Stop butonuna basıldığında
*/
public function stop(Request $request): JsonResponse
{
$request->validate([
'playback_id' => 'required|uuid',
]);
$user = auth('web')->user() ?? auth('sanctum')->user();
if (!$user) {
return response()->json(['success' => false], 401);
}
$this->playbackService->stopPlaybackSession($user, $request->playback_id);
return response()->json(['success' => true]);
}
/**
* GET /api/playback/active
* Aktif playback session'ları listele
*/
public function active(): JsonResponse
{
$user = auth('web')->user() ?? auth('sanctum')->user();
if (!$user) {
return response()->json(['success' => false], 401);
}
$sessions = $this->playbackService->getActiveSessions($user);
$limit = $this->playbackService->getConcurrentStreamLimit($user);
return response()->json([
'success' => true,
'active_count' => count($sessions),
'limit' => $limit,
'sessions' => $sessions,
]);
}
/**
* DELETE /api/playback/{playbackId}
* Belirli bir session'ı zorla durdur
*/
public function forceStop(Request $request, string $playbackId): JsonResponse
{
$user = auth('web')->user() ?? auth('sanctum')->user();
if (!$user) {
return response()->json(['success' => false], 401);
}
$this->playbackService->forceStopSession($user, $playbackId);
return response()->json(['success' => true]);
}
}
// Playback Session Routes (Concurrent Limit)
Route::prefix('playback')->middleware('auth:sanctum')->group(function () {
Route::post('/start', [PlaybackController::class, 'start']);
Route::post('/ping', [PlaybackController::class, 'ping']);
Route::post('/stop', [PlaybackController::class, 'stop']);
Route::get('/active', [PlaybackController::class, 'active']);
Route::delete('/{playbackId}', [PlaybackController::class, 'forceStop']);
});
/**
* Muzibu Playback Session Manager
* Concurrent playback limit için heartbeat sistemi
*
* Spotify Modeli: Sınırsız giriş, sınırlı playback
*/
const MuzibuPlaybackSession = {
// State
playbackId: null,
heartbeatInterval: null,
isRegistered: false,
// Config
HEARTBEAT_INTERVAL: 10000, // 10 saniye
API_BASE: '/api/playback',
/**
* Playback ID oluştur veya mevcut olanı al
* sessionStorage kullanılır (tab-specific)
*/
getOrCreatePlaybackId() {
if (this.playbackId) {
return this.playbackId;
}
let id = sessionStorage.getItem('mzb_playback_id');
if (!id) {
id = crypto.randomUUID();
sessionStorage.setItem('mzb_playback_id', id);
}
this.playbackId = id;
return id;
},
/**
* Playback session başlat
* Play butonundan ÖNCE çağrılmalı
*
* @returns {Promise<{success: boolean, error?: string}>}
*/
async startSession(songId = null) {
const playbackId = this.getOrCreatePlaybackId();
try {
const response = await fetch(`${this.API_BASE}/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'Referer': window.location.origin + '/',
},
credentials: 'same-origin',
body: JSON.stringify({
playback_id: playbackId,
song_id: songId,
}),
});
const data = await response.json();
if (!response.ok || !data.success) {
console.warn('🎵 Playback limit exceeded:', data);
return {
success: false,
error: data.error || 'unknown',
message: data.message,
activeSessions: data.active_sessions || [],
limit: data.limit,
};
}
// Başarılı - heartbeat başlat
this.isRegistered = true;
this.startHeartbeat(songId);
console.log('🎵 Playback session started:', playbackId);
return { success: true, playbackId };
} catch (error) {
console.error('🎵 Playback session start error:', error);
// Network hatası - yine de çalmaya izin ver (graceful degradation)
return { success: true, playbackId, offline: true };
}
},
/**
* Playback session durdur
* Pause/Stop butonunda çağrılmalı
*/
async stopSession() {
if (!this.playbackId || !this.isRegistered) {
return;
}
// Heartbeat'i hemen durdur
this.stopHeartbeat();
try {
await fetch(`${this.API_BASE}/stop`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({
playback_id: this.playbackId,
}),
});
console.log('🎵 Playback session stopped');
} catch (error) {
console.error('🎵 Playback session stop error:', error);
}
this.isRegistered = false;
},
/**
* Heartbeat başlat (10 saniyede bir ping)
*/
startHeartbeat(currentSongId = null) {
this.stopHeartbeat(); // Önceki varsa temizle
this.heartbeatInterval = setInterval(async () => {
if (!this.isRegistered) {
this.stopHeartbeat();
return;
}
try {
const response = await fetch(`${this.API_BASE}/ping`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({
playback_id: this.playbackId,
song_id: this.currentSongId || currentSongId,
}),
});
const data = await response.json();
if (!data.success) {
// Session artık geçerli değil
console.warn('🎵 Playback session expired:', data);
this.handleSessionExpired(data);
}
} catch (error) {
console.error('🎵 Heartbeat error:', error);
// Network hatası - devam et, belki geçicidir
}
}, this.HEARTBEAT_INTERVAL);
console.log('🎵 Heartbeat started (10s interval)');
},
/**
* Heartbeat durdur
*/
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
console.log('🎵 Heartbeat stopped');
}
},
/**
* Session expired olduğunda
*/
handleSessionExpired(data) {
this.isRegistered = false;
this.stopHeartbeat();
// Player'ı durdur
if (window.dispatchEvent) {
window.dispatchEvent(new CustomEvent('playback:session-expired', {
detail: data,
}));
}
},
/**
* Şu anki şarkı ID'sini güncelle
*/
updateCurrentSong(songId) {
this.currentSongId = songId;
},
/**
* Session durumunu kontrol et
*/
isActive() {
return this.isRegistered && this.heartbeatInterval !== null;
},
/**
* Aktif session'ları getir
*/
async getActiveSessions() {
try {
const response = await fetch(`${this.API_BASE}/active`, {
credentials: 'same-origin',
});
return await response.json();
} catch (error) {
console.error('🎵 Get active sessions error:', error);
return { sessions: [], limit: 1 };
}
},
};
// Global erişim için
window.MuzibuPlaybackSession = MuzibuPlaybackSession;
// Page unload'da session'ı durdur
window.addEventListener('beforeunload', () => {
if (MuzibuPlaybackSession.isActive()) {
// Sync XHR ile durdur (beforeunload'da async çalışmaz)
navigator.sendBeacon(
'/api/playback/stop',
JSON.stringify({ playback_id: MuzibuPlaybackSession.playbackId })
);
}
});
// ============================================
// DEĞİŞİKLİK 1: loadAndPlaySong fonksiyonuna hook ekle
// ============================================
async loadAndPlaySong(url, streamType = null, previewDuration = null, autoplay = true) {
// 🎵 PLAYBACK SESSION: Çalmadan önce session başlat
if (autoplay && this.isLoggedIn) {
const sessionResult = await MuzibuPlaybackSession.startSession(this.currentSong?.id);
if (!sessionResult.success) {
// Limit aşıldı - modal göster, çalma
this.showPlaybackLimitModal(sessionResult);
return;
}
}
// ... mevcut kod devam eder ...
}
// ============================================
// DEĞİŞİKLİK 2: togglePlayPause fonksiyonuna hook ekle
// ============================================
async togglePlayPause() {
if (this.isPlaying) {
// PAUSE - Session'ı durdur
await MuzibuPlaybackSession.stopSession();
// ... mevcut pause kodu ...
} else {
// PLAY - Session başlat
if (this.isLoggedIn) {
const sessionResult = await MuzibuPlaybackSession.startSession(this.currentSong?.id);
if (!sessionResult.success) {
this.showPlaybackLimitModal(sessionResult);
return;
}
}
// ... mevcut play kodu ...
}
}
// ============================================
// DEĞİŞİKLİK 3: stopCurrentPlayback fonksiyonuna hook ekle
// ============================================
stopCurrentPlayback() {
// 🎵 PLAYBACK SESSION: Durdurulduğunda session'ı kapat
MuzibuPlaybackSession.stopSession();
// ... mevcut kod devam eder ...
}
// ============================================
// DEĞİŞİKLİK 4: Yeni fonksiyon - Limit Modal
// ============================================
showPlaybackLimitModal(result) {
const sessions = result.activeSessions || [];
const limit = result.limit || 1;
// Modal HTML oluştur
const modalHtml = `
<div id="playback-limit-modal" class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div class="bg-slate-900 border border-slate-700 rounded-2xl p-8 max-w-md mx-4 shadow-2xl">
<div class="text-center">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-orange-500/20 flex items-center justify-center">
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<h3 class="text-xl font-bold text-white mb-2">Eşzamanlı Çalma Limiti</h3>
<p class="text-slate-300 mb-4">
Bu abonelik zaten <strong>${sessions.length}</strong> yerde çalıyor.
<br>Limit: <strong>${limit}</strong> eşzamanlı stream.
</p>
${sessions.length > 0 ? `
<div class="text-left mb-4">
<div class="text-sm text-slate-400 mb-2">Aktif Cihazlar:</div>
${sessions.map(s => `
<div class="flex items-center justify-between bg-slate-800 rounded-lg p-3 mb-2">
<div>
<div class="text-white text-sm">${s.device_name}</div>
<div class="text-slate-500 text-xs">${s.ip_address}</div>
</div>
<button onclick="window.forceStopPlayback('${s.playback_id}')"
class="px-3 py-1 bg-red-500/20 text-red-400 rounded hover:bg-red-500/30">
Durdur
</button>
</div>
`).join('')}
</div>
` : ''}
<button onclick="document.getElementById('playback-limit-modal').remove()"
class="w-full px-6 py-3 bg-slate-700 text-white font-semibold rounded-xl hover:bg-slate-600">
Tamam
</button>
</div>
</div>
</div>
`;
// Eski modal varsa kaldır
const existing = document.getElementById('playback-limit-modal');
if (existing) existing.remove();
// Modal'ı ekle
document.body.insertAdjacentHTML('beforeend', modalHtml);
},
// ============================================
// DEĞİŞİKLİK 5: Global force stop fonksiyonu
// ============================================
// player-core.js init() içine ekle:
window.forceStopPlayback = async (playbackId) => {
try {
await fetch(`/api/playback/${playbackId}`, {
method: 'DELETE',
credentials: 'same-origin',
});
// Modal'ı kapat ve tekrar dene
document.getElementById('playback-limit-modal')?.remove();
this.showToast('Diğer cihaz durduruldu. Tekrar deneyin.', 'success');
} catch (error) {
console.error('Force stop error:', error);
}
};
// ============================================
// DEĞİŞİKLİK 6: Session expired event listener
// ============================================
// player-core.js init() içine ekle:
window.addEventListener('playback:session-expired', (e) => {
this.stopCurrentPlayback();
this.isPlaying = false;
this.showToast('Oturum başka bir yerden başlatıldı. Bu cihaz durduruldu.', 'warning');
});
// login() fonksiyonunda bu satırı KALDIR veya YORUMA AL:
// ❌ KALDIR:
// $deviceService->registerSession($user);
// $deviceService->handlePostLoginDeviceLimit($user);
// ✅ YENİ: Hiçbir device limit işlemi yok
// Playback limit player tarafında kontrol edilecek
// Session polling'i DEVRE DIŞI bırak
const MuzibuSession = {
// ...
startSessionPolling() {
// ❌ ESKİ: Session polling başlatıyordu
// ✅ YENİ: Artık çalışmıyor - Playback session kullanılıyor
console.log('🔐 Session polling DEVRE DIŞI - Playback session kullanılıyor');
return;
// Eski kod yoruma alındı...
},
// Diğer fonksiyonlar da benzer şekilde return; ile bypass edilebilir
};
auth_device = false (devre dışı)concurrent_streams_enabled = true (yeni setting)concurrent_streams_limit = 1 (default limit)protected $fillable = [
// ... mevcut alanlar ...
'device_limit',
'concurrent_streams_limit', // ✅ YENİ EKLE
// ...
];
<!-- device_limit field'ının yanına ekle -->
<!-- Eşzamanlı Stream Limiti -->
<div class="col-md-6">
<label class="form-label">
Eşzamanlı Stream Limiti
<i class="fas fa-info-circle text-muted"
data-bs-toggle="tooltip"
title="Aynı anda kaç yerde müzik çalınabilir"></i>
</label>
<input type="number"
class="form-control"
wire:model="concurrent_streams_limit"
min="1"
max="10"
placeholder="1">
<small class="text-muted">
Spotify modeli: Sınırsız giriş, sınırlı playback
</small>
</div>
// Properties
public $concurrent_streams_limit = 1;
// Rules
protected $rules = [
// ... mevcut kurallar ...
'concurrent_streams_limit' => 'nullable|integer|min:1|max:10',
];
// mount() içinde
$this->concurrent_streams_limit = $plan->concurrent_streams_limit ?? 1;
// save() içinde
$plan->concurrent_streams_limit = $this->concurrent_streams_limit ?? 1;
"Playback session started" gör"Heartbeat" mesajları gör"Playback session stopped" gör# Aktif session'ları gör
php artisan tinker
>>> DB::table('active_playback_sessions')->get()
# Tüm session'ları temizle (test için)
>>> DB::table('active_playback_sessions')->truncate()
# Belirli kullanıcının session'ları
>>> DB::table('active_playback_sessions')->where('user_id', 1)->get()