reorg again, absolute (module) imports

This commit is contained in:
austinried
2021-07-08 12:21:44 +09:00
parent a94a011a18
commit ea4421b7af
54 changed files with 186 additions and 251 deletions

198
app/subsonic/api.ts Normal file
View File

@@ -0,0 +1,198 @@
import { DOMParser } from 'xmldom'
import RNFS from 'react-native-fs'
import {
GetAlbumList2Params,
GetAlbumListParams,
GetAlbumParams,
GetArtistInfo2Params,
GetArtistInfoParams,
GetArtistParams,
GetCoverArtParams,
GetIndexesParams,
GetMusicDirectoryParams,
StreamParams,
} from '@app/subsonic/params'
import {
GetAlbumList2Response,
GetAlbumListResponse,
GetAlbumResponse,
GetArtistInfo2Response,
GetArtistInfoResponse,
GetArtistResponse,
GetArtistsResponse,
GetIndexesResponse,
GetMusicDirectoryResponse,
SubsonicResponse,
} from '@app/subsonic/responses'
import { Server } from '@app/models/settings'
import paths from '@app/util/paths'
import PromiseQueue from '@app/util/PromiseQueue'
export class SubsonicApiError extends Error {
method: string
code: string
constructor(method: string, xml: Document) {
const errorElement = xml.getElementsByTagName('error')[0]
super(errorElement.getAttribute('message') as string)
this.name = method
this.method = method
this.code = errorElement.getAttribute('code') as string
}
}
const downloadQueue = new PromiseQueue(1)
export class SubsonicApiClient {
address: string
username: string
private params: URLSearchParams
constructor(server: Server) {
this.address = server.address
this.username = server.username
this.params = new URLSearchParams()
this.params.append('u', server.username)
this.params.append('t', server.token)
this.params.append('s', server.salt)
this.params.append('v', '1.15.0')
this.params.append('c', 'subsonify-cool-unique-app-string')
}
private buildUrl(method: string, params?: { [key: string]: any }): string {
let query = this.params.toString()
if (params) {
const urlParams = this.obj2Params(params)
if (urlParams) {
query += '&' + urlParams.toString()
}
}
const url = `${this.address}/rest/${method}?${query}`
// console.log(url);
return url
}
private async apiDownload(method: string, path: string, params?: { [key: string]: any }): Promise<string> {
const download = RNFS.downloadFile({
fromUrl: this.buildUrl(method, params),
toFile: path,
}).promise
await downloadQueue.enqueue(() => download)
await downloadQueue.enqueue(() => new Promise(resolve => setTimeout(resolve, 100)))
return path
}
private async apiGetXml(method: string, params?: { [key: string]: any }): Promise<Document> {
const response = await fetch(this.buildUrl(method, params))
const text = await response.text()
// console.log(text);
const xml = new DOMParser().parseFromString(text)
if (xml.documentElement.getAttribute('status') !== 'ok') {
throw new SubsonicApiError(method, xml)
}
return xml
}
private obj2Params(obj: { [key: string]: any }): URLSearchParams | undefined {
const keys = Object.keys(obj)
if (keys.length === 0) {
return undefined
}
const params = new URLSearchParams()
for (const key of keys) {
params.append(key, String(obj[key]))
}
return params
}
//
// System
//
async ping(): Promise<SubsonicResponse<null>> {
const xml = await this.apiGetXml('ping')
return new SubsonicResponse<null>(xml, null)
}
//
// Browsing
//
async getArtists(): Promise<SubsonicResponse<GetArtistsResponse>> {
const xml = await this.apiGetXml('getArtists')
return new SubsonicResponse<GetArtistsResponse>(xml, new GetArtistsResponse(xml))
}
async getIndexes(params?: GetIndexesParams): Promise<SubsonicResponse<GetIndexesResponse>> {
const xml = await this.apiGetXml('getIndexes', params)
return new SubsonicResponse<GetIndexesResponse>(xml, new GetIndexesResponse(xml))
}
async getMusicDirectory(params: GetMusicDirectoryParams): Promise<SubsonicResponse<GetMusicDirectoryResponse>> {
const xml = await this.apiGetXml('getMusicDirectory', params)
return new SubsonicResponse<GetMusicDirectoryResponse>(xml, new GetMusicDirectoryResponse(xml))
}
async getAlbum(params: GetAlbumParams): Promise<SubsonicResponse<GetAlbumResponse>> {
const xml = await this.apiGetXml('getAlbum', params)
return new SubsonicResponse<GetAlbumResponse>(xml, new GetAlbumResponse(xml))
}
async getArtistInfo(params: GetArtistInfoParams): Promise<SubsonicResponse<GetArtistInfoResponse>> {
const xml = await this.apiGetXml('getArtistInfo', params)
return new SubsonicResponse<GetArtistInfoResponse>(xml, new GetArtistInfoResponse(xml))
}
async getArtistInfo2(params: GetArtistInfo2Params): Promise<SubsonicResponse<GetArtistInfo2Response>> {
const xml = await this.apiGetXml('getArtistInfo2', params)
return new SubsonicResponse<GetArtistInfo2Response>(xml, new GetArtistInfo2Response(xml))
}
async getArtist(params: GetArtistParams): Promise<SubsonicResponse<GetArtistResponse>> {
const xml = await this.apiGetXml('getArtist', params)
return new SubsonicResponse<GetArtistResponse>(xml, new GetArtistResponse(xml))
}
//
// Album/song lists
//
async getAlbumList(params: GetAlbumListParams): Promise<SubsonicResponse<GetAlbumListResponse>> {
const xml = await this.apiGetXml('getAlbumList', params)
return new SubsonicResponse<GetAlbumListResponse>(xml, new GetAlbumListResponse(xml))
}
async getAlbumList2(params: GetAlbumList2Params): Promise<SubsonicResponse<GetAlbumList2Response>> {
const xml = await this.apiGetXml('getAlbumList2', params)
return new SubsonicResponse<GetAlbumList2Response>(xml, new GetAlbumList2Response(xml))
}
//
// Media retrieval
//
async getCoverArt(params: GetCoverArtParams): Promise<string> {
const path = `${paths.songCache}/${params.id}`
return await this.apiDownload('getCoverArt', path, params)
}
getCoverArtUri(params: GetCoverArtParams): string {
return this.buildUrl('getCoverArt', params)
}
streamUri(params: StreamParams): string {
return this.buildUrl('stream', params)
}
}

237
app/subsonic/elements.ts Normal file
View File

@@ -0,0 +1,237 @@
function requiredString(e: Element, name: string): string {
return e.getAttribute(name) as string
}
function optionalString(e: Element, name: string): string | undefined {
return e.hasAttribute(name) ? requiredString(e, name) : undefined
}
function requiredBoolean(e: Element, name: string): boolean {
return (e.getAttribute(name) as string).toLowerCase() === 'true'
}
function optionalBoolean(e: Element, name: string): boolean | undefined {
return e.hasAttribute(name) ? requiredBoolean(e, name) : undefined
}
function requiredInt(e: Element, name: string): number {
return parseInt(e.getAttribute(name) as string)
}
function optionalInt(e: Element, name: string): number | undefined {
return e.hasAttribute(name) ? requiredInt(e, name) : undefined
}
function requiredFloat(e: Element, name: string): number {
return parseFloat(e.getAttribute(name) as string)
}
function optionalFloat(e: Element, name: string): number | undefined {
return e.hasAttribute(name) ? requiredFloat(e, name) : undefined
}
function requiredDate(e: Element, name: string): Date {
return new Date(e.getAttribute(name) as string)
}
function optionalDate(e: Element, name: string): Date | undefined {
return e.hasAttribute(name) ? requiredDate(e, name) : undefined
}
export class BaseArtistElement {
id: string
name: string
starred?: Date
constructor(e: Element) {
this.id = requiredString(e, 'id')
this.name = requiredString(e, 'name')
this.starred = optionalDate(e, 'starred')
}
}
export class ArtistID3Element extends BaseArtistElement {
coverArt?: string
albumCount?: number
constructor(e: Element) {
super(e)
this.coverArt = optionalString(e, 'coverArt')
this.albumCount = optionalInt(e, 'albumCount')
}
}
export class ArtistElement extends BaseArtistElement {
userRating?: number
averageRating?: number
constructor(e: Element) {
super(e)
this.userRating = optionalInt(e, 'userRating')
this.averageRating = optionalFloat(e, 'averageRating')
}
}
export class BaseArtistInfoElement<T> {
similarArtists: T[] = []
biography?: string
musicBrainzId?: string
lastFmUrl?: string
smallImageUrl?: string
mediumImageUrl?: string
largeImageUrl?: string
constructor(e: Element, artistType: new (e: Element) => T) {
if (e.getElementsByTagName('biography').length > 0) {
this.biography = e.getElementsByTagName('biography')[0].textContent as string
}
if (e.getElementsByTagName('musicBrainzId').length > 0) {
this.musicBrainzId = e.getElementsByTagName('musicBrainzId')[0].textContent as string
}
if (e.getElementsByTagName('lastFmUrl').length > 0) {
this.lastFmUrl = e.getElementsByTagName('lastFmUrl')[0].textContent as string
}
if (e.getElementsByTagName('smallImageUrl').length > 0) {
this.smallImageUrl = e.getElementsByTagName('smallImageUrl')[0].textContent as string
}
if (e.getElementsByTagName('mediumImageUrl').length > 0) {
this.mediumImageUrl = e.getElementsByTagName('mediumImageUrl')[0].textContent as string
}
if (e.getElementsByTagName('largeImageUrl').length > 0) {
this.largeImageUrl = e.getElementsByTagName('largeImageUrl')[0].textContent as string
}
const similarArtistElements = e.getElementsByTagName('similarArtist')
for (let i = 0; i < similarArtistElements.length; i++) {
this.similarArtists.push(new artistType(similarArtistElements[i]))
}
}
}
export class ArtistInfoElement extends BaseArtistInfoElement<ArtistElement> {
constructor(e: Element) {
super(e, ArtistElement)
}
}
export class ArtistInfo2Element extends BaseArtistInfoElement<ArtistID3Element> {
constructor(e: Element) {
super(e, ArtistID3Element)
}
}
export class DirectoryElement {
id: string
parent?: string
name: string
starred?: Date
userRating?: number
averageRating?: number
playCount?: number
constructor(e: Element) {
this.id = requiredString(e, 'id')
this.parent = optionalString(e, 'parent')
this.name = requiredString(e, 'name')
this.starred = optionalDate(e, 'starred')
this.userRating = optionalInt(e, 'userRating')
this.averageRating = optionalFloat(e, 'averageRating')
}
}
export class ChildElement {
id: string
parent?: string
isDir: boolean
title: string
album?: string
artist?: string
track?: number
year?: number
genre?: string
coverArt?: string
size?: number
contentType?: string
suffix?: string
transcodedContentType?: string
transcodedSuffix?: string
duration?: number
bitRate?: number
path?: string
isVideo?: boolean
userRating?: number
averageRating?: number
playCount?: number
discNumber?: number
created?: Date
starred?: Date
albumId?: string
artistId?: string
type?: string
bookmarkPosition?: number
originalWidth?: number
originalHeight?: number
constructor(e: Element) {
this.id = requiredString(e, 'id')
this.parent = optionalString(e, 'parent')
this.isDir = requiredBoolean(e, 'isDir')
this.title = requiredString(e, 'title')
this.album = optionalString(e, 'album')
this.artist = optionalString(e, 'artist')
this.track = optionalInt(e, 'track')
this.year = optionalInt(e, 'year')
this.genre = optionalString(e, 'genre')
this.coverArt = optionalString(e, 'coverArt')
this.size = optionalInt(e, 'size')
this.contentType = optionalString(e, 'contentType')
this.suffix = optionalString(e, 'suffix')
this.transcodedContentType = optionalString(e, 'transcodedContentType')
this.transcodedSuffix = optionalString(e, 'transcodedSuffix')
this.duration = optionalInt(e, 'duration')
this.bitRate = optionalInt(e, 'bitRate')
this.path = optionalString(e, 'path')
this.isVideo = optionalBoolean(e, 'isVideo')
this.userRating = optionalInt(e, 'userRating')
this.averageRating = optionalFloat(e, 'averageRating')
this.playCount = optionalInt(e, 'playCount')
this.discNumber = optionalInt(e, 'discNumber')
this.created = optionalDate(e, 'created')
this.starred = optionalDate(e, 'starred')
this.albumId = optionalString(e, 'albumId')
this.artistId = optionalString(e, 'artistId')
this.type = optionalString(e, 'type')
this.bookmarkPosition = optionalInt(e, 'bookmarkPosition')
this.originalWidth = optionalInt(e, 'originalWidth')
this.originalHeight = optionalInt(e, 'originalHeight')
}
}
export class AlbumID3Element {
id: string
name: string
artist?: string
artistId?: string
coverArt?: string
songCount: number
duration: number
playCount?: number
created: Date
starred?: Date
year?: number
genre?: string
constructor(e: Element) {
this.id = requiredString(e, 'id')
this.name = requiredString(e, 'name')
this.artist = optionalString(e, 'artist')
this.artistId = optionalString(e, 'artistId')
this.coverArt = optionalString(e, 'coverArt')
this.songCount = requiredInt(e, 'songCount')
this.duration = requiredInt(e, 'duration')
this.playCount = optionalInt(e, 'playCount')
this.created = requiredDate(e, 'created')
this.starred = optionalDate(e, 'starred')
this.year = optionalInt(e, 'year')
this.genre = optionalString(e, 'genre')
}
}

14
app/subsonic/hooks.ts Normal file
View File

@@ -0,0 +1,14 @@
import { useAtomValue } from 'jotai/utils'
import { activeServerAtom } from '@app/state/settings'
import { SubsonicApiClient } from '@app/subsonic/api'
export const useSubsonicApi = () => {
const activeServer = useAtomValue(activeServerAtom)
return () => {
if (!activeServer) {
return undefined
}
return new SubsonicApiClient(activeServer)
}
}

84
app/subsonic/params.ts Normal file
View File

@@ -0,0 +1,84 @@
//
// Browsing
//
export type GetIndexesParams = {
musicFolderId?: string
ifModifiedSince?: number
}
export type GetArtistInfoParams = {
id: string
count?: number
includeNotPresent?: boolean
}
export type GetArtistInfo2Params = GetArtistInfoParams
export type GetMusicDirectoryParams = {
id: string
}
export type GetAlbumParams = {
id: string
}
export type GetArtistParams = {
id: string
}
//
// Album/song lists
//
export type GetAlbumList2Type =
| 'random'
| 'newest'
| 'frequent'
| 'recent'
| 'starred'
| 'alphabeticalByName'
| 'alphabeticalByArtist'
export type GetAlbumListType = GetAlbumList2Type | ' highest'
export type GetAlbumList2TypeByYear = {
type: 'byYear'
fromYear: string
toYear: string
}
export type GetAlbumList2TypeByGenre = {
type: 'byGenre'
genre: string
}
export type GetAlbumList2Params =
| {
type: GetAlbumList2Type
size?: number
offset?: number
fromYear?: string
toYear?: string
genre?: string
musicFolderId?: string
}
| GetAlbumList2TypeByYear
| GetAlbumList2TypeByGenre
export type GetAlbumListParams = GetAlbumList2Params
//
// Media retrieval
//
export type GetCoverArtParams = {
id: string
size?: string
}
export type StreamParams = {
id: string
maxBitRate?: number
format?: string
estimateContentLength?: boolean
}

144
app/subsonic/responses.ts Normal file
View File

@@ -0,0 +1,144 @@
import {
AlbumID3Element,
ArtistElement,
ArtistID3Element,
ArtistInfo2Element,
ArtistInfoElement,
ChildElement,
DirectoryElement,
} from '@app/subsonic/elements'
export type ResponseStatus = 'ok' | 'failed'
export class SubsonicResponse<T> {
status: ResponseStatus
version: string
data: T
constructor(xml: Document, data: T) {
this.data = data
this.status = xml.documentElement.getAttribute('status') as ResponseStatus
this.version = xml.documentElement.getAttribute('version') as string
}
}
//
// Browsing
//
export class GetArtistsResponse {
ignoredArticles: string
artists: ArtistID3Element[] = []
constructor(xml: Document) {
this.ignoredArticles = xml.getElementsByTagName('artists')[0].getAttribute('ignoredArticles') as string
const artistElements = xml.getElementsByTagName('artist')
for (let i = 0; i < artistElements.length; i++) {
this.artists.push(new ArtistID3Element(artistElements[i]))
}
}
}
export class GetArtistResponse {
artist: ArtistID3Element
albums: AlbumID3Element[] = []
constructor(xml: Document) {
this.artist = new ArtistID3Element(xml.getElementsByTagName('artist')[0])
const albumElements = xml.getElementsByTagName('album')
for (let i = 0; i < albumElements.length; i++) {
this.albums.push(new AlbumID3Element(albumElements[i]))
}
}
}
export class GetIndexesResponse {
ignoredArticles: string
lastModified: number
artists: ArtistElement[] = []
constructor(xml: Document) {
const indexesElement = xml.getElementsByTagName('indexes')[0]
this.ignoredArticles = indexesElement.getAttribute('ignoredArticles') as string
this.lastModified = parseInt(indexesElement.getAttribute('lastModified') as string)
const artistElements = xml.getElementsByTagName('artist')
for (let i = 0; i < artistElements.length; i++) {
this.artists.push(new ArtistElement(artistElements[i]))
}
}
}
export class GetArtistInfoResponse {
artistInfo: ArtistInfoElement
constructor(xml: Document) {
this.artistInfo = new ArtistInfoElement(xml.getElementsByTagName('artistInfo')[0])
}
}
export class GetArtistInfo2Response {
artistInfo: ArtistInfo2Element
constructor(xml: Document) {
this.artistInfo = new ArtistInfo2Element(xml.getElementsByTagName('artistInfo2')[0])
}
}
export class GetMusicDirectoryResponse {
directory: DirectoryElement
children: ChildElement[] = []
constructor(xml: Document) {
this.directory = new DirectoryElement(xml.getElementsByTagName('directory')[0])
const childElements = xml.getElementsByTagName('child')
for (let i = 0; i < childElements.length; i++) {
this.children.push(new ChildElement(childElements[i]))
}
}
}
export class GetAlbumResponse {
album: AlbumID3Element
songs: ChildElement[] = []
constructor(xml: Document) {
this.album = new AlbumID3Element(xml.getElementsByTagName('album')[0])
const childElements = xml.getElementsByTagName('song')
for (let i = 0; i < childElements.length; i++) {
this.songs.push(new ChildElement(childElements[i]))
}
}
}
//
// Album/song lists
//
class BaseGetAlbumListResponse<T> {
albums: T[] = []
constructor(xml: Document, albumType: new (e: Element) => T) {
const albumElements = xml.getElementsByTagName('album')
for (let i = 0; i < albumElements.length; i++) {
this.albums.push(new albumType(albumElements[i]))
}
}
}
export class GetAlbumListResponse extends BaseGetAlbumListResponse<ChildElement> {
constructor(xml: Document) {
super(xml, ChildElement)
}
}
export class GetAlbumList2Response extends BaseGetAlbumListResponse<AlbumID3Element> {
constructor(xml: Document) {
super(xml, AlbumID3Element)
}
}