feat: db migration + save tracks to db

This commit is contained in:
2025-06-07 00:47:16 +03:00
parent 01966525a2
commit 12f08ebf51
6 changed files with 474 additions and 42 deletions

View File

@@ -11,6 +11,7 @@ use OCP\AppFramework\Bootstrap\IRegistrationContext;
class Application extends App implements IBootstrap {
public const APP_ID = 'jukebox';
public const PREFIX = 'jukebox_';
public const DIST_DIR = '../dist';
public const JS_DIR = self::DIST_DIR . '/js';
public const CSS_DIR = self::DIST_DIR . '/css';
@@ -24,6 +25,10 @@ class Application extends App implements IBootstrap {
include_once __DIR__ . '/../../vendor/autoload.php';
}
public static function tableName(string $name): string {
return Application::PREFIX . $name;
}
public function boot(IBootContext $context): void {
}
}

87
lib/Db/JukeboxMedia.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Jukebox\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* @method int getId()
* @method void setId(int $id)
* @method string getMediaType()
* @method void setMediaType(string $mediaType)
* @method string getPath()
* @method void setPath(string $path)
* @method string|null getTitle()
* @method void setTitle(?string $title)
* @method int|null getTrackNumber()
* @method void setTrackNumber(?int $trackNumber)
* @method string|null getArtist()
* @method void setArtist(?string $artist)
* @method string|null getAlbum()
* @method void setAlbum(?string $album)
* @method string|null getAlbumArtist()
* @method void setAlbumArtist(?string $albumArtist)
* @method int|null getDuration()
* @method void setDuration(?int $duration)
* @method string|null getAlbumArt()
* @method void setAlbumArt(?string $albumArt)
* @method string|null getGenre()
* @method void setGenre(?string $genre)
* @method int|null getYear()
* @method void setYear(?int $year)
* @method int|null getBitrate()
* @method void setBitrate(?int $bitrate)
* @method string|null getCodec()
* @method void setCodec(?string $codec)
* @method string getUserId()
* @method void setUserId(string $userId)
* @method int getMtime()
* @method void setMtime(int $mtime)
* @method string|null getRawId3()
* @method void setRawId3(?string $rawId3)
*/
class JukeboxMedia extends Entity implements JsonSerializable {
protected string $mediaType = 'track';
protected string $path = '';
protected ?string $title = null;
protected ?int $trackNumber = null;
protected ?string $artist = null;
protected ?string $album = null;
protected ?string $albumArtist = null;
protected ?int $duration = null;
protected ?string $albumArt = null;
protected ?string $genre = null;
protected ?int $year = null;
protected ?int $bitrate = null;
protected ?string $codec = null;
protected string $userId = '';
protected int $mtime = 0;
protected ?string $rawId3 = null;
public function jsonSerialize(): array {
return [
'id' => $this->id,
'mediaType' => $this->mediaType,
'path' => $this->path,
'title' => $this->title,
'trackNumber' => $this->trackNumber,
'artist' => $this->artist,
'album' => $this->album,
'albumArtist' => $this->albumArtist,
'duration' => $this->duration,
'albumArt' => $this->albumArt,
'genre' => $this->genre,
'year' => $this->year,
'bitrate' => $this->bitrate,
'codec' => $this->codec,
'userId' => $this->userId,
'mtime' => $this->mtime,
'rawId3' => $this->rawId3,
];
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Jukebox\Db;
use OCA\Jukebox\AppInfo\Application;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* @template-extends QBMapper<JukeboxMedia>
*/
class JukeboxMediaMapper extends QBMapper {
public function __construct(
IDBConnection $db,
) {
parent::__construct($db, Application::tableName('media'), JukeboxMedia::class);
}
/**
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function find(string $id): JukeboxMedia {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))
);
return $this->findEntity($qb);
}
/**
* @return array<JukeboxMedia>
*/
public function findAll(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName());
return $this->findEntities($qb);
}
/**
* @param string $userId
* @param string $mediaType
* @return array<JukeboxMedia>
*/
public function findByMediaType(string $userId, string $mediaType): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->andX(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId)),
$qb->expr()->eq('media_type', $qb->createNamedParameter($mediaType))
)
);
return $this->findEntities($qb);
}
/**
* @param string $userId
* @param string|null $mediaType
* @param string $query
* @return array<JukeboxMedia>
*/
public function searchMedia(string $userId, ?string $mediaType, string $query): array {
$qb = $this->db->getQueryBuilder();
$expr = $qb->expr();
$searchExpr = $expr->orX(
$expr->iLike('title', $qb->createNamedParameter('%' . $query . '%')),
$expr->iLike('artist', $qb->createNamedParameter('%' . $query . '%')),
$expr->iLike('album', $qb->createNamedParameter('%' . $query . '%'))
);
$conditions = [
$expr->eq('user_id', $qb->createNamedParameter($userId)),
$searchExpr,
];
if ($mediaType !== null) {
$conditions[] = $expr->eq('media_type', $qb->createNamedParameter($mediaType));
}
$qb->select('*')
->from($this->getTableName())
->where($expr->andX(...$conditions));
return $this->findEntities($qb);
}
/**
* @param string $userId
* @param string $album
* @return array<JukeboxMedia>
*/
public function findByAlbum(string $userId, string $album): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->andX(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId)),
$qb->expr()->eq('album', $qb->createNamedParameter($album))
)
);
return $this->findEntities($qb);
}
/**
* @param string $userId
* @param string $artist
* @return array<JukeboxMedia>
*/
public function findByArtist(string $userId, string $artist): array {
$qb = $this->db->getQueryBuilder();
$expr = $qb->expr();
$qb->select('*')
->from($this->getTableName())
->where(
$expr->andX(
$expr->eq('user_id', $qb->createNamedParameter($userId)),
$expr->orX(
$expr->eq('artist', $qb->createNamedParameter($artist)),
$expr->eq('album_artist', $qb->createNamedParameter($artist))
)
)
);
return $this->findEntities($qb);
}
/**
* @param string $userId
* @param string $path
* @return JukeboxMedia|null
*/
public function findByUserIdAndPath(string $userId, string $path): ?JukeboxMedia {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->andX(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId)),
$qb->expr()->eq('path', $qb->createNamedParameter($path))
)
)
->setMaxResults(1);
try {
return $this->findEntity($qb);
} catch (DoesNotExistException) {
return null;
}
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Chen Asraf <casraf@pm.me>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Jukebox\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version1Date20250607001010 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
$schema = $schemaClosure();
if ($schema->hasTable('jukebox_media')) {
$schema->dropTable('jukebox_media');
}
$table = $schema->createTable('jukebox_media');
$table->addColumn('id', 'integer', [
'autoincrement' => true,
'notnull' => true,
]);
$table->addColumn('media_type', 'string', [
'length' => 20,
'notnull' => true,
'default' => 'track',
'comment' => 'track, podcast, audiobook, video',
]);
$table->addColumn('path', 'string', [
'notnull' => true,
'length' => 1024,
]);
$table->addColumn('title', 'string', [
'notnull' => false,
'length' => 255,
]);
$table->addColumn('track_number', 'integer', [
'notnull' => false,
]);
$table->addColumn('artist', 'string', [
'notnull' => false,
'length' => 255,
]);
$table->addColumn('album', 'string', [
'notnull' => false,
'length' => 255,
]);
$table->addColumn('album_artist', 'string', [
'notnull' => false,
'length' => 255,
]);
$table->addColumn('duration', 'integer', [
'notnull' => false,
'comment' => 'Duration in seconds',
]);
$table->addColumn('album_art', 'text', [
'notnull' => false,
'comment' => 'Path or encoded blob of album artwork',
]);
$table->addColumn('genre', 'string', [
'notnull' => false,
'length' => 255,
]);
$table->addColumn('year', 'smallint', [
'notnull' => false,
]);
$table->addColumn('bitrate', 'integer', [
'notnull' => false,
'comment' => 'In kbps',
]);
$table->addColumn('codec', 'string', [
'notnull' => false,
'length' => 100,
]);
$table->addColumn('user_id', 'string', [
'notnull' => true,
'length' => 64,
]);
$table->addColumn('mtime', 'bigint', [
'notnull' => true,
'comment' => 'File modified time',
]);
$table->addColumn('raw_id3', 'text', [
'notnull' => false,
'comment' => 'Raw ID3 metadata as JSON',
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['user_id'], 'media_user_idx');
$table->addIndex(['media_type'], 'media_type_idx');
$table->addIndex(['path'], 'media_path_idx');
return $schema;
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
}
}

View File

@@ -6,11 +6,14 @@ namespace OCA\Jukebox\Service;
use getID3;
use OCA\Jukebox\AppInfo\Application;
use OCA\Jukebox\Db\JukeboxMedia;
use OCA\Jukebox\Db\JukeboxMediaMapper;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IAppConfig;
use OCP\IDBConnection;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
@@ -28,6 +31,8 @@ class MusicScanner {
IUserSession $userSession,
private LoggerInterface $logger,
private IAppConfig $appConfig,
private JukeboxMediaMapper $mediaMapper,
private IDBConnection $db,
) {
$this->rootFolder = $rootFolder;
$this->userSession = $userSession;
@@ -57,6 +62,7 @@ class MusicScanner {
*/
public function scanUserByUID(string $uid): void {
try {
$this->db->beginTransaction();
$userFolder = $this->rootFolder->getUserFolder($uid);
$relativePath = $this->appConfig->getValueString(Application::APP_ID, 'music_folder_path_' . $uid, 'Music');
@@ -70,8 +76,12 @@ class MusicScanner {
$this->logger->info("Starting music scan for user '$uid' in folder '$relativePath'");
$this->traverseFolder($musicFolder, $uid);
$this->db->commit();
} catch (NotFoundException $e) {
$this->logger->error("Could not find music folder for user $uid: " . $e->getMessage());
} catch (\Throwable $e) {
$this->db->rollBack();
$this->logger->error('Scan failed: ' . $e->getMessage());
}
}
@@ -114,16 +124,11 @@ class MusicScanner {
return;
}
$handle = fopen($tempPath, 'w');
if ($handle === false) {
$this->logger->error('Failed to open temporary file handle.');
return;
}
$stream = $file->fopen('r');
if ($stream === false) {
fclose($handle);
$this->logger->error('Failed to open file stream for ' . $file->getPath());
$handle = fopen($tempPath, 'w');
if ($stream === false || $handle === false) {
$this->logger->error('Failed to open file stream or temp handle.');
return;
}
@@ -133,36 +138,70 @@ class MusicScanner {
$getID3 = new getID3();
$info = $getID3->analyze($tempPath);
$songArtist = $info['tags']['id3v2']['artist'][0] ?? '';
$albumArtist =
$info['tags']['id3v2']['band'][0] ??
$info['tags']['id3v2']['album_artist'][0] ??
$info['tags']['quicktime']['album_artist'][0] ??
$info['tags']['asf']['WM/AlbumArtist'][0] ??
'';
$album = $info['tags']['id3v2']['album'][0] ?? '';
$title = $info['tags']['id3v2']['title'][0] ?? $file->getName();
$this->logger->info("Scanned metadata for '{$file->getPath()}': Song Artist='{$songArtist}', Album Artist='{$albumArtist}', Album='{$album}', Title='{$title}'");
unlink($tempPath);
$this->saveMetadataToDatabase($uid, $file->getId(), $songArtist, $album, $title);
$this->saveMetadataToDatabase($uid, $file, $info);
}
/**
* Placeholder method to save music metadata.
*
* @param string $userId
* @param int $fileId
* @param string $artist
* @param string $album
* @param string $title
* @param File $fileId
* @param array $info
* @return void
*/
private function saveMetadataToDatabase(string $userId, int $fileId, string $artist, string $album, string $title): void {
// TODO: Implement database saving logic
private function saveMetadataToDatabase(string $userId, File $file, array $info): void {
try {
$path = $file->getPath();
$mtime = $file->getMTime();
$title = $info['tags']['id3v2']['title'][0]
?? $file->getName();
$trackArtist = $info['tags']['id3v2']['artist'][0] ?? '';
$albumArtist =
$info['tags']['id3v2']['band'][0]
?? $info['tags']['id3v2']['album_artist'][0]
?? $info['tags']['quicktime']['album_artist'][0]
?? $info['tags']['asf']['WM/AlbumArtist'][0]
?? '';
$album = $info['tags']['id3v2']['album'][0] ?? '';
// Check for existing
$existing = $this->mediaMapper->findByUserIdAndPath($userId, $path);
$media = $existing ?? new JukeboxMedia();
$media->setUserId($userId);
$media->setPath($path);
$media->setMtime($mtime);
$media->setMediaType('track');
$media->setTitle($title);
$media->setArtist($trackArtist);
$media->setAlbumArtist($albumArtist);
$media->setAlbum($album);
$media->setTrackNumber($info['tags']['id3v2']['track_number'][0] ?? null);
$media->setDuration((int)($info['playtime_seconds'] ?? 0));
$media->setGenre($info['tags']['id3v2']['genre'][0] ?? null);
$media->setYear((int)($info['tags']['id3v2']['year'][0] ?? 0));
$media->setBitrate((int)($info['audio']['bitrate'] ?? 0) / 1000);
$media->setCodec($info['audio']['dataformat'] ?? null);
$rawId3 = json_encode($info);
if ($rawId3 !== false) {
$media->setRawId3($rawId3);
}
if ($existing) {
$this->mediaMapper->update($media);
} else {
$this->mediaMapper->insert($media);
}
$this->logger->info("Saved metadata for '$path'");
} catch (\Throwable $e) {
$this->logger->error("Failed to save metadata for file '{$file->getPath()}': " . $e->getMessage());
}
}
}

View File

@@ -6,7 +6,7 @@ const { format } = require('date-fns')
const fs = require('node:fs')
function getLatestMigration() {
const migrationDir = 'lib/migration'
const migrationDir = 'lib/Migration'
const files = fs.readdirSync(migrationDir)
const migrationFiles = files.sort((a, b) => a.localeCompare(b))
const latestMigration = migrationFiles[migrationFiles.length - 1]
@@ -17,6 +17,7 @@ function getLatestMigration() {
// eslint-disable-next-line no-undef
module.exports = () => {
const latestMigrationVersion = getLatestMigration()
return {
component: {
templates: ['gen/component'],
@@ -63,17 +64,14 @@ module.exports = () => {
output: 'lib/Controller',
subDir: false,
},
migration: () => {
const latestMigrationVersion = getLatestMigration()
return {
templates: ['gen/migration'],
output: 'lib/Migration',
name: '-',
data: {
version: latestMigrationVersion,
dt: format(new Date(), 'yyyyMMddHHmmss'),
},
}
migration: {
templates: ['gen/migration'],
output: 'lib/Migration',
name: '-',
data: {
version: latestMigrationVersion,
dt: format(new Date(), 'yyyyMMddHHmmss'),
},
},
}
}