mirror of
https://github.com/chenasraf/nextcloud-jukebox.git
synced 2026-05-18 01:39:00 +00:00
feat: add radio stations
This commit is contained in:
@@ -4,5 +4,5 @@ module.exports = {
|
||||
'*.php': [
|
||||
'php vendor-bin/cs-fixer/vendor/php-cs-fixer/shim/php-cs-fixer.phar --config=.php-cs-fixer.dist.php fix',
|
||||
],
|
||||
'*Controller.php': [() => 'make openapi'],
|
||||
'*Controller.php': [() => 'make openapi', () => 'git add openapi.json'],
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
SPDX-License-Identifier: CC0-1.0
|
||||
-->
|
||||
<id>jukebox</id>
|
||||
<name lang="en">Jukebox</name>
|
||||
<summary lang="en">Stream and organize all your audio content in one place.</summary>
|
||||
<description lang="en"><![CDATA[
|
||||
<name>Jukebox</name>
|
||||
<summary>Stream and organize all your audio content in one place.</summary>
|
||||
<description><![CDATA[
|
||||
**Jukebox** is a Nextcloud app for streaming and organizing all your audio content in one place.
|
||||
It supports music files, podcasts (with gPodder sync), audiobooks, YouTube videos, and online radio.
|
||||
|
||||
@@ -46,6 +46,7 @@ It supports music files, podcasts (with gPodder sync), audiobooks, YouTube video
|
||||
|
||||
<commands>
|
||||
<command>OCA\Jukebox\Command\ScanMusic</command>
|
||||
<command>OCA\Jukebox\Command\ImportRadioStations</command>
|
||||
</commands>
|
||||
|
||||
<settings>
|
||||
|
||||
50
lib/Command/ImportRadioStations.php
Normal file
50
lib/Command/ImportRadioStations.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Chen Asraf <casraf@pm.me>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Jukebox\Command;
|
||||
|
||||
use OCA\Jukebox\Db\JukeboxRadioStationMapper;
|
||||
use OCA\Jukebox\Service\RadioSourcesService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class ImportRadioStations extends Command {
|
||||
public function __construct(
|
||||
private RadioSourcesService $service,
|
||||
private JukeboxRadioStationMapper $stationMapper,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('jukebox:import-radio')
|
||||
->setDescription('Import internet radio stations for a user')
|
||||
->addArgument('uid', InputArgument::REQUIRED, 'User ID to import stations for');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$uid = $input->getArgument('uid');
|
||||
|
||||
try {
|
||||
$existingCount = $this->stationMapper->countForUser($uid);
|
||||
$output->writeln("<info>Importing radio stations for user '$uid' (starting from offset $existingCount)...</info>");
|
||||
|
||||
$count = $this->service->importStations($uid, $existingCount);
|
||||
|
||||
$output->writeln("<info>Successfully imported or updated $count stations.</info>");
|
||||
return Command::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>Import failed: ' . $e->getMessage() . '</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Command;
|
||||
|
||||
use OCA\Jukebox\Service\MusicScanner;
|
||||
use OCA\Jukebox\Service\MusicScannerService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -17,7 +17,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class ScanMusic extends Command {
|
||||
public function __construct(
|
||||
private MusicScanner $service,
|
||||
private MusicScannerService $service,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Controller;
|
||||
|
||||
use OCA\Jukebox\Db\JukeboxMediaMapper;
|
||||
use OCA\Jukebox\Db\JukeboxMusicMapper;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
@@ -29,7 +29,7 @@ class MusicController extends OCSController {
|
||||
IRequest $request,
|
||||
private IAppConfig $config,
|
||||
private IL10N $l,
|
||||
private JukeboxMediaMapper $mediaMapper,
|
||||
private JukeboxMusicMapper $musicMapper,
|
||||
private IUserSession $userSession,
|
||||
private IRootFolder $rootFolder,
|
||||
private LoggerInterface $logger,
|
||||
@@ -51,7 +51,7 @@ class MusicController extends OCSController {
|
||||
return new JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$tracks = $this->mediaMapper->findByMediaType($user->getUID(), 'track');
|
||||
$tracks = $this->musicMapper->findByUserId($user->getUID());
|
||||
return new JSONResponse(['tracks' => array_map(fn ($t) => $t->jsonSerialize(), $tracks)]);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ class MusicController extends OCSController {
|
||||
$this->logger->info('Streaming track with ID: ' . $id, ['user' => $user->getUID()]);
|
||||
|
||||
try {
|
||||
$media = $this->mediaMapper->find((string)$id);
|
||||
$media = $this->musicMapper->find((string)$id);
|
||||
if ($media->getUserId() !== $user->getUID()) {
|
||||
return new JSONResponse(['message' => 'Forbidden'], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
@@ -122,7 +122,7 @@ class MusicController extends OCSController {
|
||||
return new JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$tracks = $this->mediaMapper->findByMediaType($user->getUID(), 'track');
|
||||
$tracks = $this->musicMapper->findByUserId($user->getUID());
|
||||
|
||||
$albums = [];
|
||||
|
||||
@@ -187,7 +187,7 @@ class MusicController extends OCSController {
|
||||
|
||||
$this->logger->debug('Looking up album', ['artist' => $decodedArtist, 'album' => $decodedAlbum]);
|
||||
|
||||
$tracks = $this->mediaMapper->findByAlbum($user->getUID(), $decodedArtist, $decodedAlbum);
|
||||
$tracks = $this->musicMapper->findByAlbum($user->getUID(), $decodedArtist, $decodedAlbum);
|
||||
|
||||
if (empty($tracks)) {
|
||||
return new JSONResponse(['message' => 'Album not found'], Http::STATUS_NOT_FOUND);
|
||||
@@ -228,7 +228,7 @@ class MusicController extends OCSController {
|
||||
return new JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$artists = $this->mediaMapper->listGroupedArtists($user->getUID());
|
||||
$artists = $this->musicMapper->listGroupedArtists($user->getUID());
|
||||
|
||||
return new JSONResponse(['artists' => $artists]);
|
||||
} catch (\Exception $e) {
|
||||
@@ -270,7 +270,7 @@ class MusicController extends OCSController {
|
||||
|
||||
$this->logger->info('Looking up artist', ['artist' => $decoded]);
|
||||
|
||||
$tracks = $this->mediaMapper->findByArtist($user->getUID(), $decoded);
|
||||
$tracks = $this->musicMapper->findByArtist($user->getUID(), $decoded);
|
||||
|
||||
if (empty($tracks)) {
|
||||
$this->logger->warning('Artist not found', ['artist' => $decoded]);
|
||||
|
||||
157
lib/Controller/RadioController.php
Normal file
157
lib/Controller/RadioController.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Jukebox\Controller;
|
||||
|
||||
use OCA\Jukebox\Db\JukeboxRadioStation;
|
||||
use OCA\Jukebox\Db\JukeboxRadioStationMapper;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
|
||||
class RadioController extends OCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private IAppConfig $config,
|
||||
private IL10N $l,
|
||||
private JukeboxRadioStationMapper $stationMapper,
|
||||
private IUserSession $userSession,
|
||||
private IClientService $httpClientService,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all saved radio stations for current user, paginated
|
||||
*
|
||||
* @param int $offset Offset for pagination
|
||||
* @param int $limit Number of items to return
|
||||
* @return JSONResponse<Http::STATUS_OK, array{stations: list<array<string, mixed>>}, array{}>
|
||||
*
|
||||
* 200: List of radio stations returned
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/radio/stations')]
|
||||
public function index(int $offset = 0, int $limit = 50): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$stations = $this->stationMapper->findPaginatedByUserId($user->getUID(), $offset, $limit);
|
||||
return new JSONResponse(['stations' => array_map(fn ($s) => $s->jsonSerialize(), $stations)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all favorited radio stations for current user, paginated
|
||||
*
|
||||
* @param int $offset Offset for pagination
|
||||
* @param int $limit Number of items to return
|
||||
* @return JSONResponse<Http::STATUS_OK, array{stations: list<array<string, mixed>>}, array{}>
|
||||
*
|
||||
* 200: List of radio stations returned
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/radio/favorites')]
|
||||
public function favorites(int $offset = 0, int $limit = 50): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$stations = $this->stationMapper->findFavoritesByUserId($user->getUID(), $offset, $limit);
|
||||
return new JSONResponse(['stations' => array_map(fn ($s) => $s->jsonSerialize(), $stations)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search radio stations from Radio Browser by name
|
||||
*
|
||||
* @param string $name Name or partial name of the radio station
|
||||
* @return JSONResponse<Http::STATUS_OK, array{stations: list<array<string, mixed>>}, array{}>
|
||||
*
|
||||
* 200: Matching radio stations returned
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/radio/search/{name}')]
|
||||
public function search(string $name): JSONResponse {
|
||||
try {
|
||||
$client = $this->httpClientService->newClient();
|
||||
$response = $client->get('http://de2.api.radio-browser.info/json/stations/byname/' . urlencode($name), [
|
||||
'headers' => [
|
||||
'User-Agent' => 'Nextcloud-Jukebox/1.0 (+https://github.com/chenasraf/nextcloud-jukebox)'
|
||||
],
|
||||
]);
|
||||
$data = json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$stations = [];
|
||||
foreach ($data as $item) {
|
||||
if (!isset($item['stationuuid'])) {
|
||||
continue;
|
||||
}
|
||||
$station = new JukeboxRadioStation();
|
||||
$station->setRemoteUuid($item['stationuuid']);
|
||||
$station->setName($item['name'] ?? '');
|
||||
$station->setStreamUrl($item['url_resolved'] ?? '');
|
||||
$station->setHomepage($item['homepage'] ?? null);
|
||||
$station->setCountry($item['country'] ?? null);
|
||||
$station->setState($item['state'] ?? null);
|
||||
$station->setLanguage($item['language'] ?? null);
|
||||
$station->setBitrate($item['bitrate'] ?? null);
|
||||
$station->setCodec($item['codec'] ?? null);
|
||||
$station->setTags($item['tags'] ?? null);
|
||||
$station->setLastUpdated(time());
|
||||
// favicon is not downloaded here, but we can store the URL temporarily in rawData
|
||||
$station->setRawData(json_encode($item, JSON_THROW_ON_ERROR));
|
||||
|
||||
$stations[] = $station->jsonSerialize();
|
||||
}
|
||||
|
||||
return new JSONResponse(['stations' => $stations]);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['message' => 'Search failed'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a radio station to the database by UUID
|
||||
*
|
||||
* @param string $uuid Unique identifier for the radio station from Radio Browser
|
||||
* @return JSONResponse<Http::STATUS_OK, array{message: string}, array{}>
|
||||
*
|
||||
* 200: Station was added successfully
|
||||
*/
|
||||
#[ApiRoute(verb: 'POST', url: '/api/radio/add/{uuid}')]
|
||||
public function addByUuid(string $uuid): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->httpClientService->newClient();
|
||||
$response = $client->get('http://de2.api.radio-browser.info/json/stations/byuuid/' . urlencode($uuid), [
|
||||
'headers' => [
|
||||
'User-Agent' => 'Nextcloud-Jukebox/1.0 (+https://github.com/chenasraf/nextcloud-jukebox)'
|
||||
],
|
||||
]);
|
||||
$data = json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (empty($data[0])) {
|
||||
return new JSONResponse(['message' => 'Station not found'], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// TODO: persist station in DB here (you may want to use RadioSourcesService)
|
||||
|
||||
return new JSONResponse(['message' => 'Station added']);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['message' => 'Failed to add station'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Cron;
|
||||
|
||||
use OCA\Jukebox\Service\MusicScannerService;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
@@ -12,7 +13,7 @@ use Psr\Log\LoggerInterface;
|
||||
class MusicScannerJob extends TimedJob {
|
||||
public function __construct(
|
||||
private ITimeFactory $time,
|
||||
private MusicScanner $service,
|
||||
private MusicScannerService $service,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
|
||||
@@ -12,8 +12,6 @@ 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()
|
||||
@@ -42,11 +40,12 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method void setUserId(string $userId)
|
||||
* @method int getMtime()
|
||||
* @method void setMtime(int $mtime)
|
||||
* @method string|null getRawId3()
|
||||
* @method void setRawId3(?string $rawId3)
|
||||
* @method string|null getRawData()
|
||||
* @method void setRawData(?string $rawData)
|
||||
* @method bool isFavorited()
|
||||
* @method void setFavorited(bool $favorited)
|
||||
*/
|
||||
class JukeboxMedia extends Entity implements JsonSerializable {
|
||||
protected string $mediaType = 'track';
|
||||
class JukeboxMusic extends Entity implements JsonSerializable {
|
||||
protected string $path = '';
|
||||
protected ?string $title = null;
|
||||
protected ?int $trackNumber = null;
|
||||
@@ -61,7 +60,8 @@ class JukeboxMedia extends Entity implements JsonSerializable {
|
||||
protected ?string $codec = null;
|
||||
protected string $userId = '';
|
||||
protected int $mtime = 0;
|
||||
protected ?string $rawId3 = null;
|
||||
protected ?string $rawData = null;
|
||||
protected bool $favorited = false;
|
||||
|
||||
/**
|
||||
* Returns the base64-encoded version of the album art blob
|
||||
@@ -87,7 +87,6 @@ class JukeboxMedia extends Entity implements JsonSerializable {
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'mediaType' => $this->mediaType,
|
||||
'path' => $this->path,
|
||||
'title' => $this->title,
|
||||
'trackNumber' => $this->trackNumber,
|
||||
@@ -102,7 +101,8 @@ class JukeboxMedia extends Entity implements JsonSerializable {
|
||||
'codec' => $this->codec,
|
||||
'userId' => $this->userId,
|
||||
'mtime' => $this->mtime,
|
||||
'rawId3' => $this->rawId3,
|
||||
'rawData' => $this->rawData,
|
||||
'favorited' => $this->favorited,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -14,21 +14,21 @@ use OCP\IDBConnection;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<JukeboxMedia>
|
||||
* @template-extends QBMapper<JukeboxMusic>
|
||||
*/
|
||||
class JukeboxMediaMapper extends QBMapper {
|
||||
class JukeboxMusicMapper extends QBMapper {
|
||||
public function __construct(
|
||||
IDBConnection $db,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($db, Application::tableName('media'), JukeboxMedia::class);
|
||||
parent::__construct($db, Application::tableName('music'), JukeboxMusic::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function find(string $id): JukeboxMedia {
|
||||
public function find(string $id): JukeboxMusic {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
@@ -39,7 +39,24 @@ class JukeboxMediaMapper extends QBMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<JukeboxMedia>
|
||||
* Find all music entries for a specific user
|
||||
*
|
||||
* @param string $userId
|
||||
* @return array<JukeboxMusic>
|
||||
*/
|
||||
public function findByUserId(string $userId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId))
|
||||
);
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<JukeboxMusic>
|
||||
*/
|
||||
public function findAll(): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
@@ -49,29 +66,10 @@ class JukeboxMediaMapper extends QBMapper {
|
||||
|
||||
/**
|
||||
* @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>
|
||||
* @return array<JukeboxMusic>
|
||||
*/
|
||||
public function searchMedia(string $userId, ?string $mediaType, string $query): array {
|
||||
public function searchMusic(string $userId, string $query): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$expr = $qb->expr();
|
||||
|
||||
@@ -81,27 +79,23 @@ class JukeboxMediaMapper extends QBMapper {
|
||||
$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));
|
||||
->where(
|
||||
$expr->andX(
|
||||
$expr->eq('user_id', $qb->createNamedParameter($userId)),
|
||||
$searchExpr
|
||||
)
|
||||
);
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $userId
|
||||
* @param string $albumArtist
|
||||
* @param string $artist
|
||||
* @param string $album
|
||||
* @return array<JukeboxMedia>
|
||||
* @return array<JukeboxMusic>
|
||||
*/
|
||||
public function findByAlbum(string $userId, string $artist, string $album): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
@@ -120,7 +114,7 @@ class JukeboxMediaMapper extends QBMapper {
|
||||
/**
|
||||
* @param string $userId
|
||||
* @param string $artist
|
||||
* @return array<JukeboxMedia>
|
||||
* @return array<JukeboxMusic>
|
||||
*/
|
||||
public function findByArtist(string $userId, string $artist): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
@@ -143,9 +137,9 @@ class JukeboxMediaMapper extends QBMapper {
|
||||
/**
|
||||
* @param string $userId
|
||||
* @param string $path
|
||||
* @return JukeboxMedia|null
|
||||
* @return JukeboxMusic|null
|
||||
*/
|
||||
public function findByUserIdAndPath(string $userId, string $path): ?JukeboxMedia {
|
||||
public function findByUserIdAndPath(string $userId, string $path): ?JukeboxMusic {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
@@ -185,7 +179,8 @@ class JukeboxMediaMapper extends QBMapper {
|
||||
$qb->expr()->andX(
|
||||
$expr->eq('user_id', $qb->createNamedParameter($userId)),
|
||||
$expr->isNotNull('artist'),
|
||||
$expr->neq('artist', $qb->createNamedParameter('')))
|
||||
$expr->neq('artist', $qb->createNamedParameter(''))
|
||||
)
|
||||
)
|
||||
->orderBy('name')
|
||||
->groupBy('name');
|
||||
@@ -213,16 +208,14 @@ class JukeboxMediaMapper extends QBMapper {
|
||||
*/
|
||||
private function getImageBlobBase64(?string $image): ?string {
|
||||
if ($image === '' || $image === null) {
|
||||
return null; // No image data
|
||||
return null;
|
||||
}
|
||||
// Attempt to detect MIME type, fallback to jpeg
|
||||
$mime = 'image/jpeg';
|
||||
if (str_starts_with($image, "\x89PNG")) {
|
||||
$mime = 'image/png';
|
||||
} elseif (str_starts_with($image, 'GIF')) {
|
||||
$mime = 'image/gif';
|
||||
}
|
||||
|
||||
return 'data:' . $mime . ';base64,' . base64_encode($image);
|
||||
}
|
||||
}
|
||||
81
lib/Db/JukeboxRadioStation.php
Normal file
81
lib/Db/JukeboxRadioStation.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?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 string getRemoteUuid()
|
||||
* @method void setRemoteUuid(string $remoteUuid)
|
||||
* @method string getName()
|
||||
* @method void setName(string $name)
|
||||
* @method string getStreamUrl()
|
||||
* @method void setStreamUrl(string $streamUrl)
|
||||
* @method string|null getHomepage()
|
||||
* @method void setHomepage(?string $homepage)
|
||||
* @method string|null getFavicon()
|
||||
* @method void setFavicon(?string $favicon)
|
||||
* @method string|null getCountry()
|
||||
* @method void setCountry(?string $country)
|
||||
* @method string|null getState()
|
||||
* @method void setState(?string $state)
|
||||
* @method string|null getLanguage()
|
||||
* @method void setLanguage(?string $language)
|
||||
* @method int|null getBitrate()
|
||||
* @method void setBitrate(?int $bitrate)
|
||||
* @method string|null getCodec()
|
||||
* @method void setCodec(?string $codec)
|
||||
* @method string|null getTags()
|
||||
* @method void setTags(?string $tags)
|
||||
* @method string|null getRawData()
|
||||
* @method void setRawData(?string $rawData)
|
||||
* @method string getUserId()
|
||||
* @method void setUserId(string $userId)
|
||||
* @method int getLastUpdated()
|
||||
* @method void setLastUpdated(int $lastUpdated)
|
||||
* @method bool isFavorited()
|
||||
* @method void setFavorited(bool $favorited)
|
||||
*/
|
||||
class JukeboxRadioStation extends Entity implements JsonSerializable {
|
||||
protected string $remoteUuid = '';
|
||||
protected string $name = '';
|
||||
protected string $streamUrl = '';
|
||||
protected ?string $homepage = null;
|
||||
protected ?string $favicon = null;
|
||||
protected ?string $country = null;
|
||||
protected ?string $state = null;
|
||||
protected ?string $language = null;
|
||||
protected ?int $bitrate = null;
|
||||
protected ?string $codec = null;
|
||||
protected ?string $tags = null;
|
||||
protected ?string $rawData = null;
|
||||
protected string $userId = '';
|
||||
protected int $lastUpdated = 0;
|
||||
protected bool $favorited = false;
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'remoteUuid' => $this->remoteUuid,
|
||||
'name' => $this->name,
|
||||
'streamUrl' => $this->streamUrl,
|
||||
'homepage' => $this->homepage,
|
||||
'favicon' => $this->favicon,
|
||||
'country' => $this->country,
|
||||
'state' => $this->state,
|
||||
'language' => $this->language,
|
||||
'bitrate' => $this->bitrate,
|
||||
'codec' => $this->codec,
|
||||
'tags' => $this->tags,
|
||||
'rawData' => $this->rawData,
|
||||
'userId' => $this->userId,
|
||||
'lastUpdated' => $this->lastUpdated,
|
||||
'favorited' => $this->favorited,
|
||||
];
|
||||
}
|
||||
}
|
||||
124
lib/Db/JukeboxRadioStationMapper.php
Normal file
124
lib/Db/JukeboxRadioStationMapper.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?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<JukeboxRadioStation>
|
||||
*/
|
||||
class JukeboxRadioStationMapper extends QBMapper {
|
||||
public function __construct(
|
||||
IDBConnection $db,
|
||||
) {
|
||||
parent::__construct($db, Application::tableName('radio_stations'), JukeboxRadioStation::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function find(int $id): JukeboxRadioStation {
|
||||
$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<JukeboxRadioStation>
|
||||
*/
|
||||
public function findAll(): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName());
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $remoteUuid
|
||||
* @return JukeboxRadioStation|null
|
||||
*/
|
||||
public function findByRemoteUuid(string $remoteUuid): ?JukeboxRadioStation {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('remote_uuid', $qb->createNamedParameter($remoteUuid)))
|
||||
->setMaxResults(1);
|
||||
|
||||
try {
|
||||
return $this->findEntity($qb);
|
||||
} catch (DoesNotExistException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $userId
|
||||
* @return array<JukeboxRadioStation>
|
||||
*/
|
||||
public function findByUserId(string $userId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $userId
|
||||
* @return array<JukeboxRadioStation>
|
||||
*/
|
||||
public function findFavoritesByUserId(string $userId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->andWhere($qb->expr()->eq('favorited', $qb->createNamedParameter(true)));
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many radio stations exist for a user
|
||||
*
|
||||
* @param string $userId
|
||||
* @return int
|
||||
*/
|
||||
public function countForUser(string $userId): int {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select($qb->createFunction('COUNT(*)'))
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
return (int)$qb->executeQuery()->fetchOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find radio stations for a user with pagination support
|
||||
*
|
||||
* @param string $userId
|
||||
* @param int $offset
|
||||
* @param int $limit
|
||||
* @return array<JukeboxRadioStation>
|
||||
*/
|
||||
public function findPaginatedByUserId(string $userId, int $offset, int $limit): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->setFirstResult($offset)
|
||||
->setMaxResults($limit)
|
||||
->orderBy('name', 'ASC');
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
}
|
||||
@@ -15,127 +15,171 @@ 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');
|
||||
// Drop existing tables if they exist (dev only)
|
||||
if ($schema->hasTable('jukebox_music')) {
|
||||
$schema->dropTable('jukebox_music');
|
||||
}
|
||||
if ($schema->hasTable('jukebox_radio_stations')) {
|
||||
$schema->dropTable('jukebox_radio_stations');
|
||||
}
|
||||
|
||||
$table = $schema->createTable('jukebox_media');
|
||||
// 🎵 Music Table
|
||||
$media = $schema->createTable('jukebox_music');
|
||||
|
||||
$table->addColumn('id', 'integer', [
|
||||
$media->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', [
|
||||
$media->addColumn('path', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 1024,
|
||||
]);
|
||||
|
||||
$table->addColumn('title', 'string', [
|
||||
$media->addColumn('title', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 255,
|
||||
]);
|
||||
|
||||
$table->addColumn('track_number', 'integer', [
|
||||
$media->addColumn('track_number', 'integer', [
|
||||
'notnull' => false,
|
||||
]);
|
||||
|
||||
$table->addColumn('artist', 'string', [
|
||||
$media->addColumn('artist', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 255,
|
||||
]);
|
||||
|
||||
$table->addColumn('album', 'string', [
|
||||
$media->addColumn('album', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 255,
|
||||
]);
|
||||
|
||||
$table->addColumn('album_artist', 'string', [
|
||||
$media->addColumn('album_artist', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 255,
|
||||
]);
|
||||
|
||||
$table->addColumn('duration', 'integer', [
|
||||
$media->addColumn('duration', 'integer', [
|
||||
'notnull' => false,
|
||||
'comment' => 'Duration in seconds',
|
||||
]);
|
||||
|
||||
$table->addColumn('album_art', 'blob', [
|
||||
$media->addColumn('album_art', 'blob', [
|
||||
'notnull' => false,
|
||||
'comment' => 'Raw binary image data for album art',
|
||||
]);
|
||||
|
||||
$table->addColumn('genre', 'string', [
|
||||
$media->addColumn('genre', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 255,
|
||||
]);
|
||||
|
||||
$table->addColumn('year', 'smallint', [
|
||||
$media->addColumn('year', 'smallint', [
|
||||
'notnull' => false,
|
||||
]);
|
||||
|
||||
$table->addColumn('bitrate', 'integer', [
|
||||
$media->addColumn('bitrate', 'integer', [
|
||||
'notnull' => false,
|
||||
'comment' => 'In kbps',
|
||||
]);
|
||||
|
||||
$table->addColumn('codec', 'string', [
|
||||
$media->addColumn('codec', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 100,
|
||||
]);
|
||||
|
||||
$table->addColumn('user_id', 'string', [
|
||||
$media->addColumn('user_id', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
]);
|
||||
|
||||
$table->addColumn('mtime', 'bigint', [
|
||||
$media->addColumn('mtime', 'bigint', [
|
||||
'notnull' => true,
|
||||
'comment' => 'File modified time',
|
||||
]);
|
||||
|
||||
$table->addColumn('raw_id3', 'text', [
|
||||
$media->addColumn('raw_data', 'text', [
|
||||
'notnull' => false,
|
||||
'comment' => 'Raw ID3 metadata as JSON',
|
||||
'comment' => 'Raw metadata (ID3) as JSON',
|
||||
]);
|
||||
$media->addColumn('favorited', 'boolean', [
|
||||
'notnull' => false,
|
||||
'default' => false,
|
||||
]);
|
||||
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addIndex(['user_id'], 'media_user_idx');
|
||||
$table->addIndex(['media_type'], 'media_type_idx');
|
||||
$table->addIndex(['path'], 'media_path_idx');
|
||||
$media->setPrimaryKey(['id']);
|
||||
$media->addIndex(['user_id'], 'media_user_idx');
|
||||
$media->addIndex(['path'], 'media_path_idx');
|
||||
|
||||
// 📻 Radio Table
|
||||
$radio = $schema->createTable('jukebox_radio_stations');
|
||||
|
||||
$radio->addColumn('id', 'integer', [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
$radio->addColumn('remote_uuid', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 255,
|
||||
'default' => '',
|
||||
]);
|
||||
$radio->addColumn('name', 'text', [
|
||||
'notnull' => true,
|
||||
'default' => '',
|
||||
]);
|
||||
$radio->addColumn('stream_url', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 1024,
|
||||
'default' => '',
|
||||
]);
|
||||
$radio->addColumn('homepage', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 1024,
|
||||
]);
|
||||
$radio->addColumn('favicon', 'blob', [
|
||||
'notnull' => false,
|
||||
'comment' => 'Raw binary image data for station icon',
|
||||
]);
|
||||
$radio->addColumn('country', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 255,
|
||||
]);
|
||||
$radio->addColumn('state', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 255,
|
||||
]);
|
||||
$radio->addColumn('language', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 255,
|
||||
]);
|
||||
$radio->addColumn('bitrate', 'integer', [
|
||||
'notnull' => false,
|
||||
]);
|
||||
$radio->addColumn('codec', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 100,
|
||||
]);
|
||||
$radio->addColumn('tags', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 1024,
|
||||
]);
|
||||
$radio->addColumn('raw_data', 'text', [
|
||||
'notnull' => false,
|
||||
'comment' => 'Full station metadata as JSON',
|
||||
]);
|
||||
$radio->addColumn('user_id', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
'default' => '',
|
||||
]);
|
||||
$radio->addColumn('last_updated', 'bigint', [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
]);
|
||||
$radio->addColumn('favorited', 'boolean', [
|
||||
'notnull' => false,
|
||||
'default' => false,
|
||||
]);
|
||||
|
||||
$radio->setPrimaryKey(['id']);
|
||||
$radio->addUniqueIndex(['remote_uuid'], 'radio_remote_uuid_idx');
|
||||
$radio->addIndex(['user_id'], 'radio_user_idx');
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ namespace OCA\Jukebox\Service;
|
||||
|
||||
use getID3;
|
||||
use OCA\Jukebox\AppInfo\Application;
|
||||
use OCA\Jukebox\Db\JukeboxMedia;
|
||||
use OCA\Jukebox\Db\JukeboxMediaMapper;
|
||||
use OCA\Jukebox\Db\JukeboxMusic;
|
||||
use OCA\Jukebox\Db\JukeboxMusicMapper;
|
||||
use OCP\Files\File;
|
||||
use OCP\Files\Folder;
|
||||
use OCP\Files\IRootFolder;
|
||||
@@ -18,11 +18,11 @@ use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Class MusicScanner
|
||||
* Class MusicScannerService
|
||||
*
|
||||
* Scans user folders for audio files and extracts metadata such as artist, album, and title.
|
||||
*/
|
||||
class MusicScanner {
|
||||
class MusicScannerService {
|
||||
private IRootFolder $rootFolder;
|
||||
private IUserSession $userSession;
|
||||
|
||||
@@ -31,7 +31,7 @@ class MusicScanner {
|
||||
IUserSession $userSession,
|
||||
private LoggerInterface $logger,
|
||||
private IAppConfig $appConfig,
|
||||
private JukeboxMediaMapper $mediaMapper,
|
||||
private JukeboxMusicMapper $musicMapper,
|
||||
private IDBConnection $db,
|
||||
) {
|
||||
$this->rootFolder = $rootFolder;
|
||||
@@ -169,13 +169,12 @@ class MusicScanner {
|
||||
$album = $info['tags']['id3v2']['album'][0] ?? '';
|
||||
|
||||
// Check for existing
|
||||
$existing = $this->mediaMapper->findByUserIdAndPath($userId, $path);
|
||||
$media = $existing ?? new JukeboxMedia();
|
||||
$existing = $this->musicMapper->findByUserIdAndPath($userId, $path);
|
||||
$media = $existing ?? new JukeboxMusic();
|
||||
|
||||
$media->setUserId($userId);
|
||||
$media->setPath($path);
|
||||
$media->setMtime($mtime);
|
||||
$media->setMediaType('track');
|
||||
$media->setTitle($title);
|
||||
$media->setArtist($trackArtist);
|
||||
$media->setAlbumArtist($albumArtist);
|
||||
@@ -192,9 +191,9 @@ class MusicScanner {
|
||||
}
|
||||
|
||||
$sanitizedInfo = $this->sanitizeForJson($info);
|
||||
$rawId3 = json_encode($sanitizedInfo);
|
||||
if ($rawId3 !== false) {
|
||||
$media->setRawId3($rawId3);
|
||||
$rawData = json_encode($sanitizedInfo);
|
||||
if ($rawData !== false) {
|
||||
$media->setRawData($rawData);
|
||||
} else {
|
||||
$this->logger->warning("Failed to encode ID3 data for file '{$file->getPath()}'");
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
@@ -203,9 +202,9 @@ class MusicScanner {
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$this->mediaMapper->update($media);
|
||||
$this->musicMapper->update($media);
|
||||
} else {
|
||||
$this->mediaMapper->insert($media);
|
||||
$this->musicMapper->insert($media);
|
||||
}
|
||||
|
||||
$this->logger->info("Saved metadata for '$path'");
|
||||
123
lib/Service/RadioSourcesService.php
Normal file
123
lib/Service/RadioSourcesService.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Jukebox\Service;
|
||||
|
||||
use OCA\Jukebox\Db\JukeboxRadioStation;
|
||||
use OCA\Jukebox\Db\JukeboxRadioStationMapper;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IDBConnection;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class RadioSourcesService {
|
||||
private const API_URL = 'http://de2.api.radio-browser.info/json/stations';
|
||||
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private JukeboxRadioStationMapper $stationMapper,
|
||||
private IClientService $httpClientService,
|
||||
private IDBConnection $db,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and persist internet radio stations for the given user.
|
||||
*
|
||||
* @param string $userId
|
||||
* @param int $startOffset Optional offset to start from (default: 0)
|
||||
* @return int Number of imported or updated stations
|
||||
*/
|
||||
public function importStations(string $userId, int $startOffset = 0): int {
|
||||
$client = $this->httpClientService->newClient();
|
||||
$count = 0;
|
||||
$limit = 200;
|
||||
$offset = $startOffset;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
$this->logger->info("Fetching radio stations with limit $limit and offset $offset");
|
||||
|
||||
$response = $client->post(
|
||||
'http://de2.api.radio-browser.info/json/stations/search',
|
||||
[
|
||||
'headers' => [
|
||||
'User-Agent' => 'Nextcloud-Jukebox/1.0 (+https://github.com/chenasraf/nextcloud-jukebox)',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => json_encode([
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
], JSON_THROW_ON_ERROR),
|
||||
]
|
||||
);
|
||||
|
||||
$stations = json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (empty($stations)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$this->db->beginTransaction();
|
||||
foreach ($stations as $data) {
|
||||
try {
|
||||
$uuid = $data['stationuuid'] ?? null;
|
||||
if (!$uuid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = $this->stationMapper->findByRemoteUuid($uuid);
|
||||
$station = $existing ?? new JukeboxRadioStation();
|
||||
|
||||
$station->setRemoteUuid($uuid);
|
||||
$station->setName($data['name'] ?? '');
|
||||
$station->setStreamUrl($data['url_resolved'] ?? '');
|
||||
$station->setHomepage($data['homepage'] ?? null);
|
||||
$station->setCountry($data['country'] ?? null);
|
||||
$station->setState($data['state'] ?? null);
|
||||
$station->setLanguage($data['language'] ?? null);
|
||||
$station->setBitrate($data['bitrate'] ?? null);
|
||||
$station->setCodec($data['codec'] ?? null);
|
||||
$station->setTags($data['tags'] ?? null);
|
||||
$station->setRawData(json_encode($data, JSON_THROW_ON_ERROR));
|
||||
$station->setUserId($userId);
|
||||
$station->setLastUpdated(time());
|
||||
|
||||
if ($existing === null && !empty($data['favicon']) && filter_var($data['favicon'], FILTER_VALIDATE_URL)) {
|
||||
$host = parse_url($data['favicon'], PHP_URL_HOST);
|
||||
if ($host && filter_var(gethostbyname($host), FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
try {
|
||||
$faviconResponse = $client->get($data['favicon']);
|
||||
$station->setFavicon($faviconResponse->getBody());
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->debug('Failed to fetch favicon for station: ' . $uuid, ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->stationMapper->insertOrUpdate($station);
|
||||
$count++;
|
||||
$this->logger->info("Processed radio station {$count}: {$station->getName()} ({$station->getRemoteUuid()})");
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to process radio station', [
|
||||
'station' => $data,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
$this->db->commit();
|
||||
$this->logger->info("Processed $count stations so far");
|
||||
|
||||
$offset += $limit;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->rollBack();
|
||||
$this->logger->error('Failed to import radio stations transactionally', ['exception' => $e]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
276
openapi.json
276
openapi.json
@@ -539,6 +539,282 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/jukebox/api/radio/stations": {
|
||||
"get": {
|
||||
"operationId": "radio-index",
|
||||
"summary": "List all saved radio stations for current user, paginated",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"radio"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"description": "Offset for pagination",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "Number of items to return",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 50
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of radio stations returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"stations"
|
||||
],
|
||||
"properties": {
|
||||
"stations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/jukebox/api/radio/favorites": {
|
||||
"get": {
|
||||
"operationId": "radio-favorites",
|
||||
"summary": "List all favorited radio stations for current user, paginated",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"radio"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"description": "Offset for pagination",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "Number of items to return",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 50
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of radio stations returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"stations"
|
||||
],
|
||||
"properties": {
|
||||
"stations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/jukebox/api/radio/search/{name}": {
|
||||
"get": {
|
||||
"operationId": "radio-search",
|
||||
"summary": "Search radio stations from Radio Browser by name",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"radio"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"description": "Name or partial name of the radio station",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Matching radio stations returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"stations"
|
||||
],
|
||||
"properties": {
|
||||
"stations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/jukebox/api/radio/add/{uuid}": {
|
||||
"post": {
|
||||
"operationId": "radio-add-by-uuid",
|
||||
"summary": "Add a radio station to the database by UUID",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"radio"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "Unique identifier for the radio station from Radio Browser",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Station was added successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/jukebox/api/settings": {
|
||||
"put": {
|
||||
"operationId": "settings-save-settings",
|
||||
|
||||
10
src/App.vue
10
src/App.vue
@@ -18,7 +18,10 @@
|
||||
<Album :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem name="Artists" :to="{ path: '/artists' }">
|
||||
<NcAppNavigationItem
|
||||
name="Artists"
|
||||
:to="{ path: '/artists' }"
|
||||
:active="isPrefixRoute('/artists')">
|
||||
<template #icon>
|
||||
<AccountMusic :size="20" />
|
||||
</template>
|
||||
@@ -43,7 +46,10 @@
|
||||
<Filmstrip :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem name="Radio" :to="{ path: '/radio' }">
|
||||
<NcAppNavigationItem
|
||||
name="Radio"
|
||||
:to="{ path: '/radio' }"
|
||||
:active="isPrefixRoute('/radio')">
|
||||
<template #icon>
|
||||
<RadioTower :size="20" />
|
||||
</template>
|
||||
|
||||
81
src/components/media/RadioStationCardItem.vue
Normal file
81
src/components/media/RadioStationCardItem.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="radio-card" :style="{ width }" @click="onClick">
|
||||
<img v-if="station.favicon" :src="station.favicon" alt="Favicon" width="128" height="128" class="cover" />
|
||||
<RadioTower v-else :size="128" />
|
||||
<div class="metadata">
|
||||
<div class="title">{{ station.name || 'Untitled Station' }}</div>
|
||||
<div class="meta" v-if="station.country || station.language">
|
||||
{{ [station.country, station.language].filter(Boolean).join(' · ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import RadioTower from '@icons/RadioTower.vue'
|
||||
import type { RadioStation } from '@/models/media'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'RadioStationCardItem',
|
||||
props: {
|
||||
station: {
|
||||
type: Object as PropType<RadioStation>,
|
||||
required: true,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '128px',
|
||||
},
|
||||
},
|
||||
emits: ['click'],
|
||||
components: {
|
||||
RadioTower,
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
|
||||
const onClick = () => emit('click')
|
||||
|
||||
return { onClick }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.radio-card {
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--border-radius-element);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
transition: background 0.15s;
|
||||
|
||||
&,
|
||||
& * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
|
||||
.cover {
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,5 @@
|
||||
export interface Media {
|
||||
id: number
|
||||
mediaType: string
|
||||
path: string
|
||||
title: string | null
|
||||
trackNumber: number | null
|
||||
@@ -15,7 +14,31 @@ export interface Media {
|
||||
codec: string | null
|
||||
userId: string
|
||||
mtime: number
|
||||
rawId3: string | null
|
||||
rawData: string | null
|
||||
remoteUuid: string | null
|
||||
homepage: string | null
|
||||
favicon: string | null
|
||||
country: string | null
|
||||
state: string | null
|
||||
language: string | null
|
||||
}
|
||||
|
||||
export interface RadioStation {
|
||||
id: number
|
||||
remoteUuid: string
|
||||
name: string
|
||||
streamUrl: string
|
||||
homepage: string | null
|
||||
country: string | null
|
||||
state: string | null
|
||||
language: string | null
|
||||
tags: string | null
|
||||
codec: string | null
|
||||
bitrate: number | null
|
||||
favicon: string | null
|
||||
rawData: string | null
|
||||
favorited: boolean
|
||||
lastUpdated: number
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
|
||||
@@ -11,7 +11,7 @@ const routes: RouteRecordRaw[] = [
|
||||
// { path: '/audiobooks', component: () => import('@/views/AudiobooksView.vue') },
|
||||
// { path: '/videos', component: () => import('@/views/VideosView.vue') },
|
||||
// { path: '/genres', component: () => import('@/views/GenresView.vue') },
|
||||
// { path: '/radio', component: () => import('@/views/RadioView.vue') },
|
||||
{ path: '/radio', component: () => import('@/views/RadioStationsView.vue') },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -12,6 +12,10 @@ export function getAlbumPath(artist: string, album: string): string {
|
||||
return `/albums/${hashedPath(artist)}/${hashedPath(album)}`;
|
||||
}
|
||||
|
||||
export function getRadioStationPath(uuid: string): string {
|
||||
return `/radio/${uuid}`;
|
||||
}
|
||||
|
||||
export function useGoToRoute() {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -30,3 +34,7 @@ export function useGoToArtist() {
|
||||
return (artist: string) => goToRoute(getArtistPath(artist))
|
||||
}
|
||||
|
||||
export function useGoToRadioStation() {
|
||||
const goToRoute = useGoToRoute()
|
||||
return (uuid: string) => goToRoute(getRadioStationPath(uuid))
|
||||
}
|
||||
|
||||
135
src/views/RadioStationsView.vue
Normal file
135
src/views/RadioStationsView.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<Page :loading="isLoading">
|
||||
<template #title>
|
||||
Radio Stations
|
||||
</template>
|
||||
|
||||
<div class="search-bar">
|
||||
<NcAppNavigationSearch v-model="searchTerm" placeholder="Search stations..." />
|
||||
</div>
|
||||
|
||||
<div v-if="searchTerm.trim()">
|
||||
<h4>Search Results</h4>
|
||||
<RadioStationCardItem v-for="station in searchResults" :key="station.remoteUuid" :station="station"
|
||||
@click="goToStation(station.remoteUuid)" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="favorites.length">
|
||||
<h4>Favorites</h4>
|
||||
<RadioStationCardItem v-for="station in favorites" :key="station.remoteUuid" :station="station"
|
||||
@click="goToStation(station.remoteUuid)" />
|
||||
</div>
|
||||
|
||||
<div v-if="stations.length">
|
||||
<h4>My Stations</h4>
|
||||
<RadioStationCardItem v-for="station in stations" :key="station.remoteUuid" :station="station"
|
||||
@click="goToStation(station.remoteUuid)" />
|
||||
</div>
|
||||
|
||||
<div v-if="!favorites.length && !stations.length" class="empty-state">
|
||||
<p>No radio stations found. Add some to get started!</p>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, computed, ref, watch } from 'vue'
|
||||
import { axios } from '@/axios'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Page from '@/components/Page.vue'
|
||||
import RadioStationCardItem from '@/components/media/RadioStationCardItem.vue'
|
||||
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
|
||||
import type { RadioStation } from '@/models/media'
|
||||
import { useGoToRadioStation } from '@/utils/routing'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'RadioStationsView',
|
||||
components: {
|
||||
Page,
|
||||
RadioStationCardItem,
|
||||
NcAppNavigationSearch,
|
||||
},
|
||||
setup() {
|
||||
const stations = ref<RadioStation[]>([])
|
||||
const favorites = ref<RadioStation[]>([])
|
||||
const searchResults = ref<RadioStation[]>([])
|
||||
const searchTerm = ref('')
|
||||
const isLoading = ref(true)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goToStation = useGoToRadioStation()
|
||||
|
||||
const fetchFavorites = async () => {
|
||||
try {
|
||||
const res = await axios.get('/radio/favorites')
|
||||
favorites.value = res.data.stations
|
||||
} catch (err) {
|
||||
console.error('Failed to load radio stations:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStations = async () => {
|
||||
try {
|
||||
const res = await axios.get('/radio/stations')
|
||||
stations.value = res.data.stations
|
||||
} catch (err) {
|
||||
console.error('Failed to load radio stations:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchTerm.value.trim()) {
|
||||
searchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.get(`/radio/search/${encodeURIComponent(searchTerm.value)}`)
|
||||
searchResults.value = res.data.stations
|
||||
} catch (err) {
|
||||
console.error('Failed to search radio stations:', err)
|
||||
}
|
||||
}
|
||||
|
||||
let debounceTimeout: number | undefined
|
||||
watch(searchTerm, () => {
|
||||
clearTimeout(debounceTimeout)
|
||||
debounceTimeout = window.setTimeout(handleSearch, 400)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchStations()
|
||||
fetchFavorites()
|
||||
})
|
||||
|
||||
return {
|
||||
stations,
|
||||
favorites,
|
||||
searchTerm,
|
||||
searchResults,
|
||||
isLoading,
|
||||
handleSearch,
|
||||
goToStation,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-bar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user