單機版 v.0.0.8 20250618

自動同步 SongLibraryCache 的表
This commit is contained in:
allen.yan 2025-06-18 14:38:55 +08:00
parent 9a89fc8273
commit 134b9de09d
9 changed files with 119 additions and 182 deletions

View File

@ -1,80 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Song;
use App\Models\SongLibraryCache;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class ExportSqliteSongLibraryCacheJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle()
{
// 安全清空資料(兼容 SQLite 和其他資料庫)
if (Schema::hasTable('song_library_cache')) {
if (DB::getDriverName() === 'sqlite') {
SongLibraryCache::query()->delete();
} else {
DB::table('song_library_cache')->truncate();
}
}
$totalInserted = 0;
Song::with(['artists', 'categories'])->chunk(500, function ($songs) use (&$totalInserted) {
$rows = [];
foreach ($songs as $song) {
$sortedArtists = $song->artists->sortBy('id')->values();
$artistA = $sortedArtists->get(0);
$artistB = $sortedArtists->get(1);
$rows[] = [
'song_id' => $song->id,
'song_name' => $song->name,
'song_simplified' => $song->simplified,
'phonetic_abbr' => $song->phonetic_abbr ?? '',
'pinyin_abbr' => $song->pinyin_abbr ?? '',
'strokes_abbr' => $song->strokes_abbr ?? 0,
'song_number' => $song->song_number ?? 0,
'artistA' => $artistA?->name,
'artistB' => $artistB?->name,
'artistA_simplified' => $artistA?->simplified,
'artistB_simplified' => $artistB?->simplified,
'artistA_category' => $artistA?->category?->value ?? '未定義',
'artistB_category' => $artistB?->category?->value ?? '未定義',
'artist_category' => in_array(\App\Enums\ArtistCategory::Group->value, [
$artistA?->category?->value,
$artistB?->category?->value,
]) ? '團' : '未定義',
'song_filename' => $song->filename,
'song_category' => $song->categories->pluck('code')->unique()->sort()->implode(', '),
'language_name' => $song->language_type ?? '未定義',
'add_date' => $song->adddate,
'situation' => $song->situation?->value ?? '未定義',
'vocal' => $song->vocal,
'db_change' => $song->db_change,
'song_counts' => $song->song_counts ?? 0,
'updated_at' => now(),
];
}
collect($rows)->chunk(1000)->each(function ($chunk) use (&$totalInserted) {
SongLibraryCache::insert($chunk->toArray());
$totalInserted += $chunk->count();
});
});
// 你也可以 log 或通知插入結果
// logger("Exported {$totalInserted} songs to cache.");
}
}

View File

@ -5,6 +5,7 @@ namespace App\Jobs;
use App\Models\Song;
use App\Models\Artist;
use App\Models\SongCategory;
use App\Models\SongLibraryCache;
use App\Enums\ArtistCategory;
use App\Enums\SongLanguageType;
use App\Enums\SongSituation;
@ -23,11 +24,11 @@ class ImportSongChunkJob implements ShouldQueue
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected Collection $rows;
protected String $id;
protected string $id;
protected array $categoryMap = [];
protected array $artistCache =[];
protected array $artistCache = [];
public function __construct(Collection $rows,String $id)
public function __construct(Collection $rows, string $id)
{
$this->rows = $rows;
$this->id = $id;
@ -36,13 +37,13 @@ class ImportSongChunkJob implements ShouldQueue
public function handle(): void
{
Log::warning('匯入啟動', [
'model' => "ImportSongChunkJob",
'rows_id' =>$this->id,
]);
Log::warning('匯入啟動', ['model' => "ImportSongChunkJob", 'rows_id' => $this->id]);
$ToInsert = [];
$songIdMap = [];
$artistMap = [];
$categoryMap = [];
$pMap = [
'\\\\SVR01\\DISK01\\' => 'DISK01\\',
'\\\\SVR01\\DISK02\\' => 'DISK02\\',
@ -54,53 +55,37 @@ class ImportSongChunkJob implements ShouldQueue
'\\\\SVR01\\DISK08\\' => 'DISK08\\',
'\\\\SVR01\\DISK09\\' => 'DISK09\\',
];
foreach ($this->rows as $index => $row) {
$songId = trim($row['編號'] ?? '');
if (!$songId || Song::where('id', $songId)->exists()) {
continue;
}
if (!$songId || Song::where('id', $songId)->exists()) continue;
try {
// 字元處理
$songName=$this->normalizeName($row['歌名'] ?? '');
$simplified=ChineseNameConverter::convertToSimplified($songName);// 繁體轉簡體
if (!$row->has('注音')) {
$phoneticAbbr = ChineseNameConverter::getKTVZhuyinAbbr($simplified);// 注音符號
} else {
$phoneticAbbr = trim($row['注音'] ?? '');
}
if (!$row->has('拼音')) {
$pinyinAbbr = ChineseNameConverter::getKTVPinyinAbbr($simplified);// 拼音首字母
} else {
$pinyinAbbr = trim($row['拼音'] ?? '');
}
if (!$row->has('kk3')) {//歌名第一個字筆畫
$chars = preg_split('//u', $songName, -1, PREG_SPLIT_NO_EMPTY);
$firstChar = $chars[0] ?? null;
$strokesAbbr=( $firstChar && preg_match('/\p{Han}/u', $firstChar) )? ChineseStrokesConverter::getStrokes($firstChar) : 0;
} else {
$strokesAbbr=trim($row['kk3'] ?? 0);
}
if (!$row->has('kk4')) {//歌名字數
$songNumber = mb_strlen($songName, 'UTF-8');
} else {
$songNumber=trim($row['kk4'] ?? 0);
}
$pathKey = trim($row['路徑01'] ?? '');
$disk = $pMap[$pathKey] ?? '';
$songName = $this->normalizeName($row['歌名'] ?? '');
$simplified = ChineseNameConverter::convertToSimplified($songName);
$phoneticAbbr = $row->has('注音') ? trim($row['注音'] ?? '') : ChineseNameConverter::getKTVZhuyinAbbr($simplified);
$pinyinAbbr = $row->has('拼音') ? trim($row['拼音'] ?? '') : ChineseNameConverter::getKTVPinyinAbbr($simplified);
$strokesAbbr = $row->has('kk3')
? trim($row['kk3'] ?? 0)
: (preg_match('/\p{Han}/u', $songName[0] ?? '') ? ChineseStrokesConverter::getStrokes($songName[0]) : 0);
$songNumber = $row->has('kk4')
? trim($row['kk4'] ?? 0)
: mb_strlen($songName, 'UTF-8');
$disk = $pMap[trim($row['路徑01'] ?? '')] ?? '';
$filename = trim($row['檔名'] ?? '');
// 準備 song 資料
$ToInsert[] = [
'id' => $songId,
'name' => $this->formatText($row['歌名']),
'adddate' => $this->parseExcelDate($row['日期'] ?? null),
'filename' => $disk . $filename,
//'language_type' => SongLanguageType::tryFrom(trim($row['語別'] ?? '')) ?? SongLanguageType::Unset,
'language_type' => SongLanguageType::fromLabelOrName($row['語別'] ?? ''),
'db_change' => trim($row['kk2'] ?? 0),//分貝增減
'vocal' => trim($row['kk6'] ?? 0),//人聲
'situation' => SongSituation::tryFrom(trim($row['kk7'] ?? '')) ?? SongSituation::Unset,//情境
'db_change' => trim($row['kk2'] ?? 0),
'vocal' => trim($row['kk6'] ?? 0),
'situation' => SongSituation::tryFrom(trim($row['kk7'] ?? '')) ?? SongSituation::Unset,
'copyright01' => trim($row['版權01'] ?? ''),
'copyright02' => trim($row['版權02'] ?? ''),
'note01' => trim($row['版權03'] ?? ''),
@ -116,67 +101,58 @@ class ImportSongChunkJob implements ShouldQueue
'song_counts' => trim($row['點播次數'] ?? 0),
];
// 處理關聯 - 歌手
$artistIds = [];
foreach (['歌星A', '歌星B'] as $key) {
$artistName = $this->normalizeName($row[$key] ?? '');
if ($artistName === '') continue;
// 若是歌星B且與歌星A相同則跳過
if ($key === '歌星B' && $artistName === $this->normalizeName($row['歌星A'] ?? '')) continue;
if ($artistName === '' || ($key === '歌星B' && $artistName === $this->normalizeName($row['歌星A'] ?? ''))) continue;
$artistMap[$songId][] = $this->getOrCreateArtistId($artistName);
}
// 分類處理(多個用 , 分隔)
if (!empty($row['分類'])) {
$categoryIds = [];
$codes = explode(',', $row['分類']);
foreach ($codes as $code) {
foreach (explode(',', $row['分類']) as $code) {
$code = trim($code);
if (isset($this->categoryMap[$code])) {
$categoryMap[$songId][] = $this->categoryMap[$code];
}
}
}
$songIdMap[] = $songId;
} catch (\Throwable $e) {
\Log::error("Row {$index} failed: {$e->getMessage()}", [
Log::error("Row {$index} failed: {$e->getMessage()}", [
'row' => $row,
'trace' => $e->getTraceAsString()
]);
}
}
// 寫入資料庫
Song::insert($ToInsert);
// 同步關聯(建議可用事件或批次處理)
foreach ($artistMap as $songId => $artistIds) {
$song = Song::find($songId);
if ($song) {
$song->artists()->sync($artistIds);
// ✅ 批次插入
collect($ToInsert)->chunk(500)->each(fn($chunk) => Song::insert($chunk->toArray()));
// ✅ 一次撈資料並同步
$songs = Song::whereIn('id', $songIdMap)->get()->keyBy('id');
foreach ($songs as $songId => $song) {
if (isset($artistMap[$songId])) {
$song->artists()->sync($artistMap[$songId]);
}
}
foreach ($categoryMap as $songId => $categoryIds) {
$song = Song::find($songId);
if ($song) {
$song->categories()->sync($categoryIds);
if (isset($categoryMap[$songId])) {
$song->categories()->sync($categoryMap[$songId]);
}
SongLibraryCache::syncFromSong($song);
}
}
private function getOrCreateArtistId(string $name): int
{
if (isset($this->artistCache[$name])) {
return $this->artistCache[$name];
}
$artist = Artist::firstOrCreate(
['name' => $name],
['category' => ArtistCategory::Unset]
);
$artist = Artist::firstOrCreate(['name' => $name], ['category' => ArtistCategory::Unset]);
return $this->artistCache[$name] = $artist->id;
}
public function normalizeName(?string $str): string
private function normalizeName(?string $str): string
{
return strtoupper(mb_convert_kana(trim($str ?? ''), 'as'));
}
@ -184,24 +160,21 @@ class ImportSongChunkJob implements ShouldQueue
protected function formatText($value)
{
if (is_numeric($value) && $value < 1 && $value > 0) {
// 嘗試判斷為時間類型的小數,轉為時間字串
$time = \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($value);
return $time->format('H:i');
return \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($value)->format('H:i');
}
return trim((string) $value);
return trim((string)$value);
}
private function parseExcelDate($value): ?string
{
if (is_numeric($value)) {
return \Carbon\Carbon::createFromFormat('Y-m-d', '1900-01-01')
->addDays((int)$value - 2)
->format('Y-m-d');
return \Carbon\Carbon::createFromFormat('Y-m-d', '1900-01-01')->addDays((int)$value - 2)->format('Y-m-d');
}
try {
return \Carbon\Carbon::parse($value)->format('Y-m-d');
} catch (\Exception $e) {
} catch (\Exception) {
return null;
}
}

View File

@ -12,13 +12,13 @@ use App\Enums\SongLanguageType;
use App\Enums\SongSituation;
use App\Models\Song;
use App\Models\SongCategory;
use App\Jobs\ExportSqliteSongLibraryCacheJob;
use App\Models\SongLibraryCache;
class SongForm extends Component
{
use WireUiActions;
protected $listeners = ['openModal','closeModal', 'deleteSong','synchronous'];
protected $listeners = ['openModal','closeModal', 'deleteSong'];
public bool $canCreate;
public bool $canEdit;
@ -70,6 +70,10 @@ class SongForm extends Component
'name' => $situation->labels(),
'value' => $situation->value,
])->toArray();
$this->canCreate = Auth::user()?->can('song-edit') ?? false;
$this->canEdit = Auth::user()?->can('song-edit') ?? false;
$this->canDownload=Auth::user()?->can('song-delete') ?? false;
$this->canDelect = Auth::user()?->can('song-delete') ?? false;
}
public function openModal($id = null)
@ -91,16 +95,6 @@ class SongForm extends Component
$this->resetFields();
$this->showModal = false;
}
public function synchronous(): void
{
ExportSqliteSongLibraryCacheJob::dispatch();
$this->notification()->send([
'icon' => 'success',
'title' => '歌庫_Cache 同步',
'description' => '已經加入排程',
]);
}
public function save()
{
@ -113,6 +107,7 @@ class SongForm extends Component
// ⭐ 同步多對多關聯
$song->artists()->sync($this->selectedArtists ?? []);
$song->categories()->sync($this->selectedCategories ?? []);
SongLibraryCache::syncFromSong($song);
$this->notification()->send([
'icon' => 'success',
'title' => '成功',
@ -126,6 +121,7 @@ class SongForm extends Component
// ⭐ 同步多對多關聯
$song->artists()->sync($this->selectedArtists ?? []);
$song->categories()->sync($this->selectedCategories ?? []);
SongLibraryCache::syncFromSong($song);
$this->notification()->send([
'icon' => 'success',
'title' => '成功',

View File

@ -3,6 +3,7 @@
namespace App\Livewire\Forms;
use App\Models\Song;
use App\Models\SongLibraryCache;
use App\Enums\SongLanguageType;
use App\Enums\SongSituation;
use Illuminate\Support\Carbon;
@ -136,6 +137,7 @@ final class SongTable extends PowerGridComponent
->add('note03')
->add('note04')
->add('enable')
->add('song_counts')
->add('created_at_formatted', fn (Song $model) => Carbon::parse($model->created_at)->format('Y-m-d H:i:s'));
}
@ -173,6 +175,8 @@ final class SongTable extends PowerGridComponent
$column[]=Column::make(__('songs.note04'), 'note04')->sortable()->searchable()->hidden(true, false)
->editOnClick(hasPermission: $this->canEdit, dataField: 'note04', fallback: 'N/A', saveOnMouseOut: true);
$column[]=Column::make(__('songs.enable'), 'enable')->toggleable(hasPermission: $this->canEdit, trueLabel: 'yes', falseLabel: 'no');
$column[]=Column::make(__('songs.song_counts'), 'song_counts')->sortable()->searchable()
->editOnClick(hasPermission: $this->canEdit, dataField: 'song_counts', fallback: 'N/A', saveOnMouseOut: true);
$column[]=Column::make('Created at', 'created_at_formatted', 'created_at')->sortable()->hidden(true, false);
$column[]=Column::action(__('songs.actions'));
return $column;
@ -197,6 +201,7 @@ final class SongTable extends PowerGridComponent
Filter::inputText('note02')->placeholder(__('songs.note02')),
Filter::inputText('note03')->placeholder(__('songs.note03')),
Filter::inputText('note04')->placeholder(__('songs.note04')),
Filter::number('song_counts'),
Filter::boolean('enable')->label('✅', '❌'),
Filter::datetimepicker('created_at'),
];
@ -229,7 +234,7 @@ final class SongTable extends PowerGridComponent
public function onUpdatedEditable($id, $field, $value): void
{
if (in_array($field,[
'name','filename','db_change',
'name','filename','db_change','song_counts',
'copyright01','copyright02','note01','note02','note03','note04'
]) && $this->canEdit) {
$this->noUpdated($id,$field,$value);
@ -247,6 +252,7 @@ final class SongTable extends PowerGridComponent
if ($song) {
$song->{$field} = $value;
$song->save(); // 明確觸發 saving
SongLibraryCache::syncFromSong($song);
}
$this->notification()->send([
'icon' => 'success',

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\SongLibraryCache;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Helpers\ChineseNameConverter;
@ -83,7 +84,9 @@ class Song extends Model
$song->strokes_abbr=($firstChar && preg_match('/\p{Han}/u', $firstChar)) ? ChineseStrokesConverter::getStrokes($firstChar) : 0;
$song->song_number = mb_strlen($song->name, 'UTF-8');
});
static::deleting(function (Song $song) {
SongLibraryCache::where('song_id', $song->id)->delete();
// Detach 關聯資料
$song->artists()->detach();
$song->categories()->detach();

View File

@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Models\Song;
class SongLibraryCache extends Model
{
@ -38,5 +39,44 @@ class SongLibraryCache extends Model
'song_counts',
'updated_at',
];
public static function syncFromSong(Song $song): void
{
$song->load(['artists', 'categories']);
$sortedArtists = $song->artists->sortBy('id')->values();
$artistA = $sortedArtists->get(0);
$artistB = $sortedArtists->get(1);
static::updateOrCreate(
['song_id' => $song->id],
[
'song_name' => $song->name,
'song_simplified' => $song->simplified,
'phonetic_abbr' => $song->phonetic_abbr ?? '',
'pinyin_abbr' => $song->pinyin_abbr ?? '',
'strokes_abbr' => $song->strokes_abbr ?? 0,
'song_number' => $song->song_number ?? 0,
'artistA' => $artistA?->name,
'artistB' => $artistB?->name,
'artistA_simplified' => $artistA?->simplified,
'artistB_simplified' => $artistB?->simplified,
'artistA_category' => $artistA?->category?->value ?? '未定義',
'artistB_category' => $artistB?->category?->value ?? '未定義',
'artist_category' => in_array(\App\Enums\ArtistCategory::Group->value, [
$artistA?->category?->value,
$artistB?->category?->value,
]) ? '團' : '未定義',
'song_filename' => $song->filename,
'song_category' => $song->categories->pluck('code')->unique()->sort()->implode(', '),
'language_name' => $song->language_type ?? '未定義',
'add_date' => $song->adddate,
'situation' => $song->situation?->value ?? '未定義',
'vocal' => $song->vocal,
'db_change' => $song->db_change,
'song_counts' => $song->song_counts ?? 0,
'updated_at' => now(),
]
);
}
}

View File

@ -33,6 +33,7 @@ return [
'vocal' => '人聲',
'situation' => '情境',
'simplified' => '歌名簡體',
'song_counts' => '點播次數',
'select_artists' =>'輸入搜尋歌手',

View File

@ -12,11 +12,4 @@
label="{{ __('songs.ImportData') }}"
class="bg-green-600 text-white"
/>
<x-wireui:button
wire:click="$dispatchTo('forms.song-form','synchronous')"
icon="document-plus"
label="同步歌庫"
class="bg-green-600 text-white"
/>
</x-admin.section-header>

View File

@ -40,4 +40,9 @@ IP
3F;pc301,pc302,pc303,pc305,pc306,pc307,pc308,pc309,pc310
3F;pc311,pc312,pc313,pc315,pc316,pc317,pc318,pc319,pc320
3F;pc321,pc322,pc323,
9F;pc901,pc902,pc903,pc910
9F;pc901,pc902,pc903,pc910
3F;svr01,svr02,svr03,svr04,svr05
3F;pc301,pc302,pc303,pc305,pc306,pc307,pc308,pc309,pc310
3F;pc311,pc312,pc313,pc315,pc316,pc317,pc318,pc319,pc320
3F;pc321,pc322,pc323