Compare commits

...

67 Commits

Author SHA1 Message Date
Óscar García Amor 90205fe0fb
Update README.md 2022-07-08 18:49:07 +02:00
Óscar García Amor 2165ce75b3
Merge branch 'develop' of github.com:ultrasonic/ultrasonic into develop 2022-07-08 12:42:04 +02:00
Óscar García Amor 8b3ee0a8d6
Migrate to GitLab 2022-07-08 12:41:53 +02:00
birdbird 695b2df63f
Merge pull request #750 from ultrasonic/offline/trackDao
Finish offline ID3 support
2022-07-08 09:20:40 +02:00
tzugen 798d795e81
Add Album list support in Offline 2022-07-07 19:20:40 +02:00
tzugen ecfce59e0f
Add clearer warning to ID3 offline setting 2022-07-07 18:53:36 +02:00
tzugen de0cb7713b
Improve offline support for Compilations 2022-07-06 15:35:34 +02:00
tzugen 78bfab3753
Conditionally hide offline Id3 Setting 2022-07-06 12:26:48 +02:00
tzugen b955d77152
Make Id3 offline dependent on Id3 2022-07-06 11:16:53 +02:00
tzugen b11694d6a2
Fix logic whether to showArtistPicture 2022-07-06 11:16:52 +02:00
tzugen 31a1fdace1
Formatting 2022-07-06 11:16:52 +02:00
tzugen 5b03b632fd
Fix three tests. 2022-07-06 11:16:52 +02:00
tzugen 152b1d261a
Fix two tests. 2022-07-06 11:16:52 +02:00
tzugen 53a1a5545a
Formatting.
Disable line length check in detekt. It's being guarded by KtLint already.
2022-07-06 11:16:52 +02:00
tzugen ad54db5bcb
Make Ids composite of Item Id + Server Id 2022-07-06 11:16:52 +02:00
tzugen 177329abcf
Add Migration 2022-07-06 11:16:52 +02:00
tzugen 241e51015f
Clean & formatting
Update room 2.4.0 -> 2.4.2
2022-07-06 11:16:52 +02:00
tzugen 60dbe70ca5
Add code to Downloader 2022-07-06 11:16:52 +02:00
tzugen 8490f7115d
Add Offline support for tracks 2022-07-06 11:16:52 +02:00
tzugen ee67f4c744
Add track Dao 2022-07-06 11:16:52 +02:00
tzugen 3a3bd10fdb
Add AlbumDao, rename getArtist to getAlbumsOfArtist 2022-07-06 11:16:52 +02:00
birdbird 3445576dc9
Merge pull request #775 from ultrasonic/seekBar
Add more values to preload count
2022-07-06 10:46:56 +02:00
tzugen 8c40f662a1
Add more values to preload count 2022-07-06 08:49:29 +02:00
birdbird 6c6227ce41
Merge pull request #770 from Maxmystere/notification/add-rating
Add song rating to notification
2022-07-05 19:31:18 +02:00
tzugen 240a2fa8f6
Fix a bug that the musicService was incorrectly cached... 2022-07-05 19:21:07 +02:00
tzugen 7de775dc26
Formatting 2022-07-05 18:38:26 +02:00
birdbird d034fc9c71
Update AutoMediaBrowserCallback.kt 2022-07-05 18:34:24 +02:00
birdbird 05ada9297d
Update MediaNotificationProvider.kt 2022-07-05 18:18:02 +02:00
Maxence G aa6c037b20
Remove useless request 2022-07-04 18:55:07 +02:00
Maxence G b8c924be27
MainThreadExecutor to Kotlin 2022-07-04 18:42:14 +02:00
Maxence G 0929a6a1bd
Lint 2022-07-03 18:40:58 +02:00
Maxence G fefee74a66
Use MainThreadExecutor 2022-07-03 18:38:29 +02:00
Maxence G 37e3ce09c1
Update notification on Rating change 2022-07-03 18:23:22 +02:00
Maxence G 16b3fcad32
Final lint 2022-07-02 21:58:45 +02:00
Maxence G d6aebd9989
Lint 2022-07-02 21:51:28 +02:00
Maxence G 3f408600cb
Add const for custom SessionCommands
Improve rating call
2022-07-02 21:47:32 +02:00
Maxence G 9014b47b74
Add song rating to notification 2022-07-02 01:27:12 +02:00
tzugen ac489ae8b9
Merge pull request #764 from ultrasonic/hmpr
Merge update build tools #755 by Holger Müller
2022-06-22 13:40:15 +02:00
tzugen e7f8fa21cb
Merge update build tools #755 by Holger Müller
Squashed commit of the following:

commit 4491c65b1b
Merge: 51ff716f 77865a14
Author: tzugen <67737443+tzugen@users.noreply.github.com>
Date:   Tue Jun 21 20:50:05 2022 +0200

    Merge branch 'develop' into gradle-update

commit 51ff716ff5
Author: Holger Müller <github@euhm.de>
Date:   Tue Jun 21 20:38:52 2022 +0200

    fixed lint warning

commit 18c31a5704
Author: Holger Müller <github@euhm.de>
Date:   Tue Jun 21 20:38:35 2022 +0200

    fixed lint warning

commit 603654c262
Author: Holger Müller <github@euhm.de>
Date:   Tue Jun 21 20:37:51 2022 +0200

    API is > lollipop ... target removed

commit b38a7211de
Author: Holger Müller <github@euhm.de>
Date:   Tue Jun 21 20:37:07 2022 +0200

    new created after fixes

commit 4929a526f7
Author: tzugen <tzugen@riseup.net>
Date:   Tue Jun 21 10:43:16 2022 +0200

    Disable ObsoleteLintCustomCheck

commit d0c30f0b6b
Author: tzugen <tzugen@riseup.net>
Date:   Tue Jun 21 10:14:06 2022 +0200

    Update more libs

commit e2fa447bbf
Merge: d4ead495 ff9c7b24
Author: tzugen <67737443+tzugen@users.noreply.github.com>
Date:   Tue Jun 21 09:47:03 2022 +0200

    Merge branch 'develop' into gradle-update

commit d4ead49548
Merge: 2dac6a7e 9a73d72f
Author: Holger Müller <github@euhm.de>
Date:   Tue Jun 21 08:50:42 2022 +0200

    merged with develop branch

commit 2dac6a7e01
Author: Holger Müller <github@euhm.de>
Date:   Mon Jun 20 21:45:22 2022 +0200

    update to android image tag 2022.06.1

commit f3dc259c39
Author: Holger Müller <github@euhm.de>
Date:   Mon Jun 20 20:56:37 2022 +0200

    rebuild lint-baseline.xml

commit c71bc1212a
Author: Holger Müller <github@euhm.de>
Date:   Mon Jun 20 20:55:00 2022 +0200

    removed unneeded cast

commit eca136dabe
Author: Holger Müller <github@euhm.de>
Date:   Fri Jun 17 23:58:37 2022 +0200

    commit signed

commit 540f476334
Author: Holger Müller <github@euhm.de>
Date:   Fri Jun 17 23:40:59 2022 +0200

    commit signed

    Signed-off-by: Holger Müller <github@euhm.de>

commit 986bd013a4
Author: Holger Müller <github@euhm.de>
Date:   Fri Jun 17 23:27:20 2022 +0200

    push to latest gradle version, set targetSdk to 33

Signed-off-by: tzugen <tzugen@riseup.net>
2022-06-22 13:35:19 +02:00
tzugen b1c3cabfef
Update README.md (#766) 2022-06-21 22:23:10 +02:00
tzugen 77865a143d
Enable schema export (needed for AutoMigration generator) (#757)
* Enable schema export (needed for AutoMigration generator)
* Add Schema folder to GIT
2022-06-21 10:07:03 +02:00
Óscar García Amor ff9c7b2435
Merge pull request #761 from ultrasonic/fix_#759
Fix #759 , a crash when the artist name was empty.
2022-06-21 08:52:54 +02:00
Óscar García Amor 737563bf6b
Merge branch 'develop' into fix_#759 2022-06-21 08:24:52 +02:00
tzugen 9a73d72fa4
Merge pull request #762 from ultrasonic/tzugen-patch-1
Update CONTRIBUTING.md
2022-06-21 06:38:35 +02:00
tzugen 98ce519014
Merge branch 'develop' into tzugen-patch-1 2022-06-20 23:05:21 +02:00
tzugen 83fc54d332
Merge pull request #758 from Maxmystere/media3-beta01
Upgrade to media3-beta01
2022-06-20 23:04:02 +02:00
Maxence G a2b9c6b9a3
Final final version good v3.2 2022-06-20 19:54:26 +02:00
Maxence G 5ae56d26c5
Merge branch 'media3-beta01' of github.com:Maxmystere/ultrasonic into media3-beta01 2022-06-20 19:50:15 +02:00
Maxence G 4efb6dcb58
Up lint 2022-06-20 19:49:17 +02:00
tzugen 8a90e98989
Update CONTRIBUTING.md 2022-06-20 19:35:46 +02:00
tzugen 46a8f4640d
Fix #759 , a crash when the artist name was empty. 2022-06-20 10:12:45 +02:00
tzugen ab41966943
Readd comment 2022-06-20 09:49:49 +02:00
tzugen 00d7ce326c
Rm comment 2022-06-20 09:48:28 +02:00
Maxence G bc4b0aa832
final fix checkstyle v2 2022-06-19 23:51:18 +02:00
Maxence G 23fd336ffd
Fix lint 2022-06-19 23:46:01 +02:00
Maxence G b57a973510
Fix static analysis 2022-06-19 23:29:07 +02:00
Maxence G 8796006ced
Fix checkstyle 2022-06-19 23:24:23 +02:00
Maxence G 545b65921e
Put Previous/Play/Next in compact notification 2022-06-19 19:43:55 +02:00
Maxence G cf367ead92
Add back notification icon 2022-06-19 18:40:04 +02:00
Maxence G 9961213f09
Upgrade to media3-beta01 2022-06-19 18:21:33 +02:00
tzugen 5deb7d4d58
Merge pull request #749 from ultrasonic/ready/removeJacoco
Remove Jacoco, closes #751
2022-06-18 22:37:12 +02:00
tzugen 5f31eaaffe
Merge branch 'develop' into ready/removeJacoco 2022-06-18 15:36:46 +02:00
tzugen cad6477cd9
Merge pull request #754 from hmueller01/target-sdk-31
Target sdk 31 into develop
2022-06-17 18:14:22 +02:00
tzugen b440821ea8
Tweak backup rules 2022-06-17 13:36:41 +02:00
Holger Müller 8663b9d50e
Target SDK 31 2022-06-17 13:27:30 +02:00
Óscar García Amor 2bae243be0
Merge branch 'develop' into ready/removeJacoco 2022-06-17 10:29:46 +02:00
tzugen 66443ba018
Remove Jacoco 2022-06-16 16:40:59 +02:00
88 changed files with 2581 additions and 793 deletions

View File

@ -9,7 +9,7 @@ parameters:
jobs: jobs:
build: build:
docker: docker:
- image: cimg/android:2022.03.1 - image: cimg/android:2022.06.1
working_directory: ~/ultrasonic working_directory: ~/ultrasonic
environment: environment:
JVM_OPTS: << pipeline.parameters.memory-config >> JVM_OPTS: << pipeline.parameters.memory-config >>
@ -41,7 +41,6 @@ jobs:
name: unit-tests name: unit-tests
command: | command: |
./gradlew ciTest testDebugUnitTest ./gradlew ciTest testDebugUnitTest
./gradlew jacocoFullReport
- run: - run:
name: lint name: lint
command: ./gradlew :ultrasonic:lintRelease command: ./gradlew :ultrasonic:lintRelease
@ -61,8 +60,6 @@ jobs:
- store_artifacts: - store_artifacts:
path: subsonic-api/build/reports path: subsonic-api/build/reports
destination: reports destination: reports
- store_artifacts:
path: build/reports/jacoco/jacocoFullReport/
push_translations: push_translations:
docker: docker:
- image: cimg/python:3.6 - image: cimg/python:3.6
@ -85,7 +82,7 @@ jobs:
tx push -s tx push -s
generate_signed_apk: generate_signed_apk:
docker: docker:
- image: cimg/android:2022.03.1 - image: cimg/android:2022.06.1
working_directory: ~/ultrasonic working_directory: ~/ultrasonic
environment: environment:
JVM_OPTS: << pipeline.parameters.memory-config >> JVM_OPTS: << pipeline.parameters.memory-config >>

View File

@ -18,18 +18,46 @@ By default Pull Request should be opened against **develop** branch, PR against
### Here are a few guidelines you should follow before submitting: ### Here are a few guidelines you should follow before submitting:
1. **License Acceptance:** All contributions must be licensed as [GNU GPLv3](LICENSE) to be accepted. 1. **License Acceptance:** All contributions must be licensed as [GNU GPLv3](LICENSE) to be accepted.
Use `git commit --signoff` to acknowledge this. Use `git commit --signoff` to acknowledge this.
2. **App is migrating to [Kotlin](https://kotlinlang.org/) programming language:** new Pull Requests 2. **No Breakage:** New features or changes to existing ones must not degrade the user experience.
should be written in this programming language. 3. **Coding standards:** best-practices should be followed, comment generously, and avoid "clever" algorithms.
3. **No Breakage:** New features or changes to existing ones must not degrade the user experience.
4. **Coding standards:** best-practices should be followed, comment generously, and avoid "clever" algorithms.
Refactoring existing messes is great, but watch out for breakage. Refactoring existing messes is great, but watch out for breakage.
5. **No large PR:** Try to limit the scope of PR only to the related issue, so it will be easier to review 4. **No large PR:** Try to limit the scope of PR only to the related issue, so it will be easier to review
and test. and test.
### Pull Request Process ### Pull Request Process
On each Pull Request Github runs a number of checks to make sure there are no problems.
#### Signed commits
Commits must be signed. [See here how to set it up](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits)
#### KtLint
This programm checks if the source code is formatted correctly.
You can run it yourself locally with
`./gradlew -Pqc ktlintFormat`
Running this command will fix common problems and will notify you of problems it couldn't fix automatically.
#### Detekt
Detekt is a static analyser. It helps to find potential bugs in our code.
You can run it yourself locally with
`./gradlew -Pqc detekt`
There is a "baseline" file, in which errors which have been in the code base before are noted.
Sometimes it is necessary to regenerate this file by running:
`./gradlew -Pqc detektBaseline`
#### Lint
Lint looks for general problems in the code or unused resources etc.
You can run it with
`./gradlew -Pqc lintRelease`
If there is a need to regenerate the baseline, remove `ultrasonic/lint-baseline.xml` and rerun the command.
1. Ensure [all commits are signed-off](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/about-commit-signature-verification).
2. Check tests for the new code are added.
3. Check code style is passing.
4. Check code static analysis is passing.

View File

@ -1,14 +1,25 @@
# Ultrasonic # WE HAVE MOVED
[![Build Status](https://circleci.com/gh/ultrasonic/ultrasonic/tree/develop.svg?style=shield&circle-token=:circle-token)](https://circleci.com/gh/ultrasonic)
[![Codecov branch](https://img.shields.io/codecov/c/github/ultrasonic/ultrasonic/develop.svg)]()
[![ktlint](https://img.shields.io/badge/code%20style-%E2%9D%A4-FF4081.svg)](https://ktlint.github.io/)
Ultrasonic is free and open-source music streaming Android client for [Subsonic](http://www.subsonic.org/) [API](http://www.subsonic.org/pages/api.jsp) (version 1.7.0 or higher) compatible servers. Ultrasonic code is now hosted in [GitLab][ultrasonic].
- New Web: https://ultrasonic.gitlab.io
- New Git: https://gitlab.com/ultrasonic/ultrasonic
- New bugtracker: https://gitlab.com/ultrasonic/ultrasonic/-/issues
- New releases: https://gitlab.com/ultrasonic/ultrasonic/-/packages
[ultrasonic]: https://gitlab.com/ultrasonic/ultrasonic
# Ultrasonic
Ultrasonic is free and open-source music streaming Android client for
[Subsonic][subsonic] [API][subapi] (version 1.7.0 or higher) compatible
servers.
## Help wanted ## Help wanted
We currently don't have that much time to spend developing Subsonic, so any We currently don't have that much time to spend developing Subsonic, so any
contributions or active developers are always welcomed. contributions or active developers are always welcomed.
Have a look at [CONTRIBUTING](CONTRIBUTING.md) to get started.
## Download ## Download
@ -16,24 +27,26 @@ App is available to download at following stores:
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" alt="Get it on Google Play" height="70">](https://play.google.com/store/apps/details?id=org.moire.ultrasonic) [<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" alt="Get it on Google Play" height="70">](https://play.google.com/store/apps/details?id=org.moire.ultrasonic)
[<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="70">](https://f-droid.org/packages/org.moire.ultrasonic/) [<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="70">](https://f-droid.org/packages/org.moire.ultrasonic/)
[<img src="https://ultrasonic.github.io/assets/img/get-it-on-github.png" alt="Get it on GitHub" height="70">](https://github.com/ultrasonic/ultrasonic/releases) [<img src="https://ultrasonic.gitlab.io/assets/img/get-it-on-gitlab.png" alt="Get it on GitLab" height="70">](https://gitlab.com/ultrasonic/ultrasonic/-/releases)
**Warning**: All three versions (Google Play, F-Droid and the APKs) are not **Warning**: All three versions (Google Play, F-Droid and the APKs) are not
compatible (not signed by the same key)! You must uninstall one to install compatible (not signed by the same key)! You must uninstall one to install
the other, which will delete all your data. the other, which will delete all your data.
If you want to use the version downloaded from F-Droid or from Github with **Android Auto**, you must enable Unknown Sources as it is described in [this wiki page](https://github.com/ultrasonic/ultrasonic/wiki/Using-Ultrasonic-with-Android-Auto). If you want to use the version downloaded from F-Droid or from GitLab with
**Android Auto**, you must enable Unknown Sources as it is described in
[this wiki page][wikiaa].
## Bugs and issues ## Bugs and issues
First, see if your issue havent been yet reported [here](https://github.com/ultrasonic/ultrasonic/issues), First, see if your issue havent been yet reported [here][issues], otherwise
otherwise open [a new issue](https://github.com/ultrasonic/ultrasonic/issues/new). open [a new issue][newissue].
### Known (not our) bugs ### Known (not our) bugs
If you are using *Madsonic 5.1.X* several sections of Ultrasonic will not If you are using *Madsonic 5.1.X* several sections of Ultrasonic will not
work. This is caused by bad implementation of Subsonic API by Madsonic. For work. This is caused by bad implementation of Subsonic API by Madsonic. For
more info about this you can read [this bug](https://github.com/ultrasonic/ultrasonic/issues/129). more info about this you can read [this bug][madbug].
## Contributing ## Contributing
@ -41,16 +54,29 @@ See [CONTRIBUTING](CONTRIBUTING.md).
## Supported (tested) Subsonic API implementations ## Supported (tested) Subsonic API implementations
- [Subsonic](http://www.subsonic.org/pages/index.jsp) - [Subsonic][subsonic]
- [Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced) - [Airsonic-Advanced][airsonic]
- [Supysonic](https://github.com/spl0k/supysonic) - [Supysonic][supysonic]
- [Ampache](https://ampache.org/) - [Ampache][ampache]
Other *Subsonic API* implementations should work as well as long as they follow API Other *Subsonic API* implementations should work as well as long as they
[documentation](http://www.subsonic.org/pages/api.jsp). follow API [documentation][subapi].
## License ## License
This software is licensed under the terms of the GNU General Public License version 3 (GPLv3). This software is licensed under the terms of the GNU General Public License
version 3 (GPLv3).
Full text of the license is available in the [LICENSE](LICENSE) file and [online](https://opensource.org/licenses/gpl-3.0.html). Full text of the license is available in the [LICENSE](LICENSE) file and
[online][gpl3].
[wikiaa]: https://gitlab.com/ultrasonic/ultrasonic/-/wikis/Using-Ultrasonic-with-Android-Auto
[issues]: https://gitlab.com/ultrasonic/ultrasonic/-/issues
[newissue]: https://gitlab.com/ultrasonic/ultrasonic/-/issues/new
[madbug]: https://gitlab.com/ultrasonic/ultrasonic/-/issues/129
[subsonic]: http://www.subsonic.org/
[subapi]: http://www.subsonic.org/pages/api.jsp
[airsonic]: https://github.com/airsonic-advanced/airsonic-advanced
[supysonic]: https://github.com/spl0k/supysonic
[ampache]: https://ampache.org/
[gpl3]: https://opensource.org/licenses/gpl-3.0.html

View File

@ -17,7 +17,6 @@ buildscript {
classpath libs.kotlin classpath libs.kotlin
classpath libs.ktlintGradle classpath libs.ktlintGradle
classpath libs.detekt classpath libs.detekt
classpath libs.jacoco
} }
} }
@ -44,8 +43,6 @@ allprojects {
} }
} }
apply from: 'gradle_scripts/jacoco.gradle'
wrapper { wrapper {
gradleVersion(libs.versions.gradle.get()) gradleVersion(libs.versions.gradle.get())
distributionType("all") distributionType("all")

View File

@ -1,12 +1,6 @@
apply from: bootstrap.androidModule apply from: bootstrap.androidModule
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
ext {
jacocoExclude = [
'**/domain/**'
]
}
dependencies { dependencies {
implementation libs.roomRuntime implementation libs.roomRuntime
implementation libs.roomKtx implementation libs.roomKtx

View File

@ -1,10 +1,21 @@
/*
* Album.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import androidx.room.PrimaryKey import androidx.room.ColumnInfo
import androidx.room.Entity
import java.util.Date import java.util.Date
@Entity(tableName = "albums", primaryKeys = ["id", "serverId"])
data class Album( data class Album(
@PrimaryKey override var id: String, override var id: String,
@ColumnInfo(defaultValue = "-1")
override var serverId: Int = -1,
override var parent: String? = null, override var parent: String? = null,
override var album: String? = null, override var album: String? = null,
override var title: String? = null, override var title: String? = null,

View File

@ -1,14 +1,23 @@
/*
* Artist.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "artists") @Entity(tableName = "artists", primaryKeys = ["id", "serverId"])
data class Artist( data class Artist(
@PrimaryKey override var id: String, override var id: String,
@ColumnInfo(defaultValue = "-1")
override var serverId: Int = -1,
override var name: String? = null, override var name: String? = null,
override var index: String? = null, override var index: String? = null,
override var coverArt: String? = null, override var coverArt: String? = null,
override var albumCount: Long? = null, override var albumCount: Long? = null,
override var closeness: Int = 0 override var closeness: Int = 0
) : ArtistOrIndex(id) ) : ArtistOrIndex(id, serverId)

View File

@ -1,11 +1,21 @@
/*
* ArtistOrIndex.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import androidx.room.Ignore import androidx.room.Ignore
@Suppress("LongParameterList")
abstract class ArtistOrIndex( abstract class ArtistOrIndex(
@Ignore @Ignore
override var id: String, override var id: String,
@Ignore @Ignore
open var serverId: Int,
@Ignore
override var name: String? = null, override var name: String? = null,
@Ignore @Ignore
open var index: String? = null, open var index: String? = null,
@ -18,15 +28,15 @@ abstract class ArtistOrIndex(
) : GenericEntry() { ) : GenericEntry() {
fun compareTo(other: ArtistOrIndex): Int { fun compareTo(other: ArtistOrIndex): Int {
when { return when {
this.closeness == other.closeness -> { this.closeness == other.closeness -> {
return 0 0
} }
this.closeness > other.closeness -> { this.closeness > other.closeness -> {
return -1 -1
} }
else -> { else -> {
return 1 1
} }
} }
} }

View File

@ -1,15 +1,24 @@
/*
* Index.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "indexes") @Entity(tableName = "indexes", primaryKeys = ["id", "serverId"])
data class Index( data class Index(
@PrimaryKey override var id: String, override var id: String,
@ColumnInfo(defaultValue = "-1")
override var serverId: Int = -1,
override var name: String? = null, override var name: String? = null,
override var index: String? = null, override var index: String? = null,
override var coverArt: String? = null, override var coverArt: String? = null,
override var albumCount: Long? = null, override var albumCount: Long? = null,
override var closeness: Int = 0, override var closeness: Int = 0,
var musicFolderId: String? = null var musicFolderId: String? = null
) : ArtistOrIndex(id) ) : ArtistOrIndex(id, serverId)

View File

@ -1,3 +1,10 @@
/*
* MusicDirectory.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import java.util.Date import java.util.Date
@ -31,6 +38,7 @@ class MusicDirectory : ArrayList<MusicDirectory.Child>() {
abstract class Child : GenericEntry() { abstract class Child : GenericEntry() {
abstract override var id: String abstract override var id: String
abstract var serverId: Int
abstract var parent: String? abstract var parent: String?
abstract var isDirectory: Boolean abstract var isDirectory: Boolean
abstract var album: String? abstract var album: String?

View File

@ -1,13 +1,22 @@
/*
* MusicFolder.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey
/** /**
* Represents a top level directory in which music or other media is stored. * Represents a top level directory in which music or other media is stored.
*/ */
@Entity(tableName = "music_folders") @Entity(tableName = "music_folders", primaryKeys = ["id", "serverId"])
data class MusicFolder( data class MusicFolder(
@PrimaryKey override val id: String, override val id: String,
override val name: String override val name: String,
@ColumnInfo(defaultValue = "-1")
var serverId: Int
) : GenericEntry() ) : GenericEntry()

View File

@ -1,13 +1,22 @@
/*
* Track.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey
import java.io.Serializable import java.io.Serializable
import java.util.Date import java.util.Date
@Entity @Entity(tableName = "tracks", primaryKeys = ["id", "serverId"])
data class Track( data class Track(
@PrimaryKey override var id: String, override var id: String,
@ColumnInfo(defaultValue = "-1")
override var serverId: Int = -1,
override var parent: String? = null, override var parent: String? = null,
override var isDirectory: Boolean = false, override var isDirectory: Boolean = false,
override var title: String? = null, override var title: String? = null,

View File

@ -20,11 +20,3 @@ dependencies {
testImplementation libs.mockWebServer testImplementation libs.mockWebServer
testImplementation libs.apacheCodecs testImplementation libs.apacheCodecs
} }
ext {
// Excluding data classes
jacocoExclude = [
'**/models/**',
'**/di/**'
]
}

View File

@ -44,7 +44,7 @@ fun <T : SubsonicResponse> Response<T>.throwOnFailure(): Response<T> {
val response = this val response = this
if (response.isSuccessful && response.body()!!.status === SubsonicResponse.Status.OK) { if (response.isSuccessful && response.body()!!.status === SubsonicResponse.Status.OK) {
return this as Response<T> return this
} }
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw IOException("Server error, code: " + response.code()) throw IOException("Server error, code: " + response.code())

View File

@ -64,10 +64,7 @@ style:
WildcardImport: WildcardImport:
active: true active: true
MaxLineLength: MaxLineLength:
active: true active: false
maxLineLength: 120
excludePackageStatements: false
excludeImportStatements: false
MagicNumber: MagicNumber:
# 100 common in percentage, 1000 in milliseconds # 100 common in percentage, 1000 in milliseconds
ignoreNumbers: ['-1', '0', '1', '2', '5', '10', '100', '256', '512', '1000', '1024', '4096'] ignoreNumbers: ['-1', '0', '1', '2', '5', '10', '100', '256', '512', '1000', '1024', '4096']

View File

@ -1,28 +1,27 @@
[versions] [versions]
# You need to run ./gradlew wrapper after updating the version # You need to run ./gradlew wrapper after updating the version
gradle = "7.3.2" gradle = "7.3.3"
navigation = "2.3.5" navigation = "2.3.5"
gradlePlugin = "7.1.1" gradlePlugin = "7.2.1"
androidxcore = "1.6.0" androidxcore = "1.6.0"
ktlint = "0.43.2" ktlint = "0.43.2"
ktlintGradle = "10.2.0" ktlintGradle = "10.2.0"
detekt = "1.19.0" detekt = "1.19.0"
jacoco = "0.8.7"
preferences = "1.1.1" preferences = "1.1.1"
media = "1.3.1" media = "1.3.1"
media3 = "1.0.0-alpha03" media3 = "1.0.0-beta01"
androidSupport = "28.0.0" androidSupport = "1.4.0"
androidLegacySupport = "1.0.0" androidLegacySupport = "1.0.0"
androidSupportDesign = "1.4.0" androidSupportDesign = "1.6.1"
constraintLayout = "2.1.1" constraintLayout = "2.1.1"
multidex = "2.0.1" multidex = "2.0.1"
room = "2.4.0" room = "2.4.2"
kotlin = "1.6.10" kotlin = "1.6.10"
kotlinxCoroutines = "1.6.0-native-mt" kotlinxCoroutines = "1.6.0-native-mt"
kotlinxGuava = "1.6.0" kotlinxGuava = "1.6.0"
viewModelKtx = "2.3.0" viewModelKtx = "2.4.1"
retrofit = "2.9.0" retrofit = "2.9.0"
jackson = "2.10.1" jackson = "2.10.1"
@ -49,12 +48,11 @@ gradle = { module = "com.android.tools.build:gradle", version.r
kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
ktlintGradle = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlintGradle" } ktlintGradle = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlintGradle" }
detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
jacoco = { module = "org.jacoco:org.jacoco.core", version.ref = "jacoco" }
core = { module = "androidx.core:core-ktx", version.ref = "androidxcore" } core = { module = "androidx.core:core-ktx", version.ref = "androidxcore" }
support = { module = "androidx.legacy:legacy-support-v4", version.ref = "androidLegacySupport" } support = { module = "androidx.legacy:legacy-support-v4", version.ref = "androidLegacySupport" }
design = { module = "com.google.android.material:material", version.ref = "androidSupportDesign" } design = { module = "com.google.android.material:material", version.ref = "androidSupportDesign" }
annotations = { module = "com.android.support:support-annotations", version.ref = "androidSupport" } annotations = { module = "androidx.annotation:annotation", version.ref = "androidSupport" }
multidex = { module = "androidx.multidex:multidex", version.ref = "multidex" } multidex = { module = "androidx.multidex:multidex", version.ref = "multidex" }
constraintLayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintLayout" } constraintLayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintLayout" }
room = { module = "androidx.room:room-compiler", version.ref = "room" } room = { module = "androidx.room:room-compiler", version.ref = "room" }
@ -103,4 +101,3 @@ kluentAndroid = { module = "org.amshove.kluent:kluent-android", versio
mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
apacheCodecs = { module = "commons-codec:commons-codec", version.ref = "apacheCodecs" } apacheCodecs = { module = "commons-codec:commons-codec", version.ref = "apacheCodecs" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }

View File

@ -1,5 +1,5 @@
ext.versions = [ ext.versions = [
minSdk : 21, minSdk : 21,
targetSdk : 30, targetSdk : 33,
compileSdk : 31, compileSdk : 31,
] ]

View File

@ -1,5 +1,6 @@
#Fri Jun 17 23:13:49 CEST 2022
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.2-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

View File

@ -3,7 +3,6 @@
*/ */
apply plugin: 'com.android.library' apply plugin: 'com.android.library'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'jacoco'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle" apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
android { android {
@ -48,10 +47,6 @@ android {
tasks.withType(Test) { tasks.withType(Test) {
useJUnitPlatform() useJUnitPlatform()
jacoco {
includeNoLocationClasses = true
excludes += jacocoExclude
}
} }
dependencies { dependencies {
@ -61,11 +56,4 @@ dependencies {
testRuntimeOnly libs.junitVintage testRuntimeOnly libs.junitVintage
} }
jacoco {
toolVersion(libs.versions.jacoco.get())
}
ext {
jacocoExclude = ['jdk.internal.*']
}

View File

@ -1,92 +0,0 @@
apply plugin: 'jacoco'
jacoco {
toolVersion(libs.versions.jacoco.get())
}
def mergedJacocoExec = file("${project.buildDir}/jacoco/jacocoMerged.exec")
def merge = tasks.register('jacocoMergeReports', JacocoMerge) {
group = "Reporting"
description = "Merge all jacoco reports from projects into one."
ListProperty<File> jacocoFiles = project.objects.listProperty(File.class)
project.subprojects { subproject ->
subproject.plugins.withId("jacoco") {
project.logger.info("${subproject.name} has Jacoco plugin applied")
subproject.tasks.withType(Test) { task ->
File destFile = task.extensions.getByType(JacocoTaskExtension.class).destinationFile
if (destFile.exists() && !task.name.contains("Release")) {
jacocoFiles.add(destFile)
}
}
}
}
executionData(jacocoFiles)
destinationFile(mergedJacocoExec)
}
tasks.register('jacocoFullReport', JacocoReport) {
dependsOn merge
group = "Reporting"
description = "Generate full Jacoco coverage report including all modules."
getClassDirectories().setFrom(files())
getSourceDirectories().setFrom(files())
getExecutionData().setFrom(files())
reports {
xml.enabled = true
html.enabled = true
csv.enabled = false
}
// Always run merging, as all input calculation is done in doFirst {}
outputs.upToDateWhen { false }
// Task will run anyway even if initial inputs are empty
onlyIf = { true }
project.subprojects { subproject ->
subproject.plugins.withId("jacoco") {
project.logger.info("${subproject.name} has Jacoco plugin applied")
subproject.plugins.withId("kotlin-android") {
project.logger.info("${subproject.name} is android project")
def mainSources = subproject.extensions.findByName("android").sourceSets['main']
project.logger.info("Android sources: ${mainSources.java.srcDirs}")
mainSources.java.srcDirs.forEach {
additionalSourceDirs(it)
}
project.logger.info("Subproject exclude: ${subproject.jacocoExclude}")
additionalClassDirs(fileTree(
dir: "${subproject.buildDir}/tmp/kotlin-classes/debug",
excludes: subproject.jacocoExclude
))
}
subproject.plugins.withId("kotlin") { plugin ->
project.logger.info("${subproject.name} is common kotlin project")
SourceDirectorySet mainSources = subproject.extensions.getByName("kotlin")
.sourceSets[SourceSet.MAIN_SOURCE_SET_NAME]
.kotlin
mainSources.srcDirs.forEach {
project.logger.debug("Adding sources: $it")
additionalSourceDirs(it)
}
project.logger.info("Subproject exclude: ${subproject.jacocoExclude}")
additionalClassDirs(fileTree(
dir: "${subproject.buildDir}/classes/kotlin/main",
excludes: subproject.jacocoExclude
))
}
subproject.tasks.withType(Test) { task ->
File destFile = task.extensions.getByType(JacocoTaskExtension.class).destinationFile
if (destFile.exists() && !task.name.contains("Release")) {
project.logger.info("Adding execution data: $destFile")
executionData(destFile)
}
}
}
}
}

View File

@ -3,7 +3,6 @@
*/ */
apply plugin: 'kotlin' apply plugin: 'kotlin'
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
apply plugin: 'jacoco'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle" apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
sourceSets { sourceSets {
@ -21,36 +20,8 @@ dependencies {
testRuntimeOnly libs.junitVintage testRuntimeOnly libs.junitVintage
} }
jacoco {
toolVersion(libs.versions.jacoco.get())
}
ext {
// override it in the module
jacocoExclude = ['jdk.internal.*']
}
jacocoTestReport {
reports {
html.required = true
xml.required = false
csv.required = false
}
afterEvaluate {
getClassDirectories().setFrom(files(classDirectories.files.collect {
fileTree(dir: it, excludes: jacocoExclude)
}))
}
}
tasks.named("test").configure { tasks.named("test").configure {
useJUnitPlatform() useJUnitPlatform()
jacoco {
excludes += jacocoExclude
includeNoLocationClasses = true
}
finalizedBy jacocoTestReport
} }
tasks.register("ciTest") { tasks.register("ciTest") {

View File

@ -1,7 +1,6 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
apply plugin: 'jacoco'
apply from: "../gradle_scripts/code_quality.gradle" apply from: "../gradle_scripts/code_quality.gradle"
android { android {
@ -64,7 +63,7 @@ android {
kapt { kapt {
arguments { arguments {
arg("room.schemaLocation", "$buildDir/schemas".toString()) arg("room.schemaLocation", "$rootDir/ultrasonic/schemas".toString())
} }
} }
lint { lint {
@ -74,6 +73,7 @@ android {
disable 'IconMissingDensityFolder', 'VectorPath' disable 'IconMissingDensityFolder', 'VectorPath'
ignore 'MissingTranslation', 'UnusedQuantity', 'MissingQuantity' ignore 'MissingTranslation', 'UnusedQuantity', 'MissingQuantity'
warning 'ImpliedQuantity' warning 'ImpliedQuantity'
disable 'ObsoleteLintCustomCheck'
} }
} }
@ -135,36 +135,3 @@ dependencies {
implementation libs.timber implementation libs.timber
} }
jacoco {
toolVersion(libs.versions.jacoco.get())
}
// Excluding all java classes and stuff that should not be covered
ext {
jacocoExclude = [
'**/activity/**',
'**/audiofx/**',
'**/fragment/**',
'**/provider/**',
'**/receiver/**',
'**/service/**',
'**/Test/**',
'**/util/**',
'**/view/**',
'**/R$*.class',
'**/R.class',
'**/BuildConfig.class',
'**/di/**',
'jdk.internal.*'
]
}
jacoco {
toolVersion(libs.versions.jacoco.get())
}
tasks.withType(Test) {
jacoco.includeNoLocationClasses = true
jacoco.excludes += jacocoExclude
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 7.1.1" type="baseline" client="gradle" dependencies="false" name="AGP (7.1.1)" variant="all" version="7.1.1"> <issues format="6" by="lint 7.2.1" type="baseline" client="gradle" dependencies="false" name="AGP (7.2.1)" variant="all" version="7.2.1">
<issue <issue
id="InflateParams" id="InflateParams"
@ -55,7 +55,7 @@
errorLine2=" ~~~~~~~~"> errorLine2=" ~~~~~~~~">
<location <location
file="src/main/AndroidManifest.xml" file="src/main/AndroidManifest.xml"
line="151" line="155"
column="10"/> column="10"/>
</issue> </issue>
@ -66,7 +66,7 @@
errorLine2=" ~~~~~~~~"> errorLine2=" ~~~~~~~~">
<location <location
file="src/main/AndroidManifest.xml" file="src/main/AndroidManifest.xml"
line="75" line="79"
column="10"/> column="10"/>
</issue> </issue>
@ -77,18 +77,7 @@
errorLine2=" ~~~~~~~"> errorLine2=" ~~~~~~~">
<location <location
file="src/main/AndroidManifest.xml" file="src/main/AndroidManifest.xml"
line="65" line="68"
column="10"/>
</issue>
<issue
id="IntentFilterExportedReceiver"
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \&#xA;available to other apps, and `false` otherwise. For launcher activities, this should be set to `true`."
errorLine1=" &lt;activity android:name=&quot;.activity.NavigationActivity&quot;"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="41"
column="10"/> column="10"/>
</issue> </issue>
@ -180,6 +169,61 @@
column="1"/> column="1"/>
</issue> </issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_pause` appears to be unused"
errorLine1="&lt;vector android:height=&quot;48dp&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/media3_notification_pause.xml"
line="1"
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_play` appears to be unused"
errorLine1="&lt;vector android:height=&quot;48dp&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/media3_notification_play.xml"
line="1"
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_seek_to_next` appears to be unused"
errorLine1="&lt;vector android:height=&quot;32dp&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/media3_notification_seek_to_next.xml"
line="1"
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_seek_to_previous` appears to be unused"
errorLine1="&lt;vector android:height=&quot;32dp&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/media3_notification_seek_to_previous.xml"
line="1"
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_small_icon` appears to be unused"
errorLine1="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/media3_notification_small_icon.xml"
line="1"
column="1"/>
</issue>
<issue <issue
id="IconDuplicates" id="IconDuplicates"
message="The following unrelated icon files have identical contents: list_pressed_holo_dark.9.png, list_pressed_holo_light.9.png"> message="The following unrelated icon files have identical contents: list_pressed_holo_dark.9.png, list_pressed_holo_light.9.png">

View File

@ -0,0 +1,124 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "4cea788a99b9bc28500948b1cd92e537",
"entities": [
{
"tableName": "ServerSetting",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `index` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `color` INTEGER, `userName` TEXT NOT NULL, `password` TEXT NOT NULL, `jukeboxByDefault` INTEGER NOT NULL, `allowSelfSignedCertificate` INTEGER NOT NULL, `ldapSupport` INTEGER NOT NULL, `musicFolderId` TEXT, `minimumApiVersion` TEXT, `chatSupport` INTEGER, `bookmarkSupport` INTEGER, `shareSupport` INTEGER, `podcastSupport` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "userName",
"columnName": "userName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "jukeboxByDefault",
"columnName": "jukeboxByDefault",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "allowSelfSignedCertificate",
"columnName": "allowSelfSignedCertificate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ldapSupport",
"columnName": "ldapSupport",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "musicFolderId",
"columnName": "musicFolderId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "minimumApiVersion",
"columnName": "minimumApiVersion",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "chatSupport",
"columnName": "chatSupport",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bookmarkSupport",
"columnName": "bookmarkSupport",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "shareSupport",
"columnName": "shareSupport",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "podcastSupport",
"columnName": "podcastSupport",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4cea788a99b9bc28500948b1cd92e537')"
]
}
}

View File

@ -0,0 +1,146 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "0580217b1e87b02d2edaf9b008891cbc",
"entities": [
{
"tableName": "artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "indexes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, `musicFolderId` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "musicFolderId",
"columnName": "musicFolderId",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "music_folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0580217b1e87b02d2edaf9b008891cbc')"
]
}
}

View File

@ -0,0 +1,474 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "b6ac795e7857eac4fed2dbbd01f80fb8",
"entities": [
{
"tableName": "artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "albums",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `parent` TEXT, `album` TEXT, `title` TEXT, `name` TEXT, `discNumber` INTEGER, `coverArt` TEXT, `songCount` INTEGER, `created` INTEGER, `artist` TEXT, `artistId` TEXT, `duration` INTEGER, `year` INTEGER, `genre` TEXT, `starred` INTEGER NOT NULL, `path` TEXT, `closeness` INTEGER NOT NULL, `isDirectory` INTEGER NOT NULL, `isVideo` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parent",
"columnName": "parent",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "album",
"columnName": "album",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "discNumber",
"columnName": "discNumber",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "songCount",
"columnName": "songCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "year",
"columnName": "year",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isVideo",
"columnName": "isVideo",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `parent` TEXT, `isDirectory` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `albumId` TEXT, `artist` TEXT, `artistId` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `contentType` TEXT, `suffix` TEXT, `transcodedContentType` TEXT, `transcodedSuffix` TEXT, `coverArt` TEXT, `size` INTEGER, `songCount` INTEGER, `duration` INTEGER, `bitRate` INTEGER, `path` TEXT, `isVideo` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `discNumber` INTEGER, `type` TEXT, `created` INTEGER, `closeness` INTEGER NOT NULL, `bookmarkPosition` INTEGER NOT NULL, `userRating` INTEGER, `averageRating` REAL, `name` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parent",
"columnName": "parent",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "album",
"columnName": "album",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "track",
"columnName": "track",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "year",
"columnName": "year",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentType",
"columnName": "contentType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "suffix",
"columnName": "suffix",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "transcodedContentType",
"columnName": "transcodedContentType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "transcodedSuffix",
"columnName": "transcodedSuffix",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "songCount",
"columnName": "songCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bitRate",
"columnName": "bitRate",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isVideo",
"columnName": "isVideo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "discNumber",
"columnName": "discNumber",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarkPosition",
"columnName": "bookmarkPosition",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userRating",
"columnName": "userRating",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "averageRating",
"columnName": "averageRating",
"affinity": "REAL",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "indexes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, `musicFolderId` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "musicFolderId",
"columnName": "musicFolderId",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "music_folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b6ac795e7857eac4fed2dbbd01f80fb8')"
]
}
}

View File

@ -0,0 +1,514 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "95e83d6663a862c03ac46f9567453ded",
"entities": [
{
"tableName": "artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, PRIMARY KEY(`id`, `serverId`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id",
"serverId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "albums",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `parent` TEXT, `album` TEXT, `title` TEXT, `name` TEXT, `discNumber` INTEGER, `coverArt` TEXT, `songCount` INTEGER, `created` INTEGER, `artist` TEXT, `artistId` TEXT, `duration` INTEGER, `year` INTEGER, `genre` TEXT, `starred` INTEGER NOT NULL, `path` TEXT, `closeness` INTEGER NOT NULL, `isDirectory` INTEGER NOT NULL, `isVideo` INTEGER NOT NULL, PRIMARY KEY(`id`, `serverId`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
},
{
"fieldPath": "parent",
"columnName": "parent",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "album",
"columnName": "album",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "discNumber",
"columnName": "discNumber",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "songCount",
"columnName": "songCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "year",
"columnName": "year",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isVideo",
"columnName": "isVideo",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id",
"serverId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `parent` TEXT, `isDirectory` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `albumId` TEXT, `artist` TEXT, `artistId` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `contentType` TEXT, `suffix` TEXT, `transcodedContentType` TEXT, `transcodedSuffix` TEXT, `coverArt` TEXT, `size` INTEGER, `songCount` INTEGER, `duration` INTEGER, `bitRate` INTEGER, `path` TEXT, `isVideo` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `discNumber` INTEGER, `type` TEXT, `created` INTEGER, `closeness` INTEGER NOT NULL, `bookmarkPosition` INTEGER NOT NULL, `userRating` INTEGER, `averageRating` REAL, `name` TEXT, PRIMARY KEY(`id`, `serverId`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
},
{
"fieldPath": "parent",
"columnName": "parent",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "album",
"columnName": "album",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "track",
"columnName": "track",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "year",
"columnName": "year",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentType",
"columnName": "contentType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "suffix",
"columnName": "suffix",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "transcodedContentType",
"columnName": "transcodedContentType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "transcodedSuffix",
"columnName": "transcodedSuffix",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "songCount",
"columnName": "songCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bitRate",
"columnName": "bitRate",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isVideo",
"columnName": "isVideo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "discNumber",
"columnName": "discNumber",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarkPosition",
"columnName": "bookmarkPosition",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userRating",
"columnName": "userRating",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "averageRating",
"columnName": "averageRating",
"affinity": "REAL",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"serverId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "indexes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, `musicFolderId` TEXT, PRIMARY KEY(`id`, `serverId`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "musicFolderId",
"columnName": "musicFolderId",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"serverId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "music_folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT NOT NULL, PRIMARY KEY(`id`, `serverId`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id",
"serverId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '95e83d6663a862c03ac46f9567453ded')"
]
}
}

View File

@ -21,6 +21,8 @@
<application <application
android:allowBackup="false" android:allowBackup="false"
android:fullBackupContent="@xml/backup_descriptor"
android:dataExtractionRules="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/NoActionBar" android:theme="@style/NoActionBar"
@ -40,7 +42,8 @@
<activity android:name=".activity.NavigationActivity" <activity android:name=".activity.NavigationActivity"
android:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
android:launchMode="singleTask"> android:launchMode="singleTask"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.SEARCH"/> <action android:name="android.intent.action.SEARCH"/>
@ -61,9 +64,10 @@
android:exported="false"> android:exported="false">
</service> </service>
<!-- TODO: Check if it works with exported=false as well --> <!-- Needs to be exported: https://android.googlesource.com/platform/developers/build/+/4de32d4/prebuilts/gradle/MediaBrowserService/README.md -->
<service android:name=".playback.PlaybackService" <service android:name=".playback.PlaybackService"
android:label="@string/common.appname" android:label="@string/common.appname"
android:foregroundServiceType="mediaPlayback"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
@ -150,7 +154,8 @@
</receiver> </receiver>
<provider <provider
android:name=".provider.SearchSuggestionProvider" android:name=".provider.SearchSuggestionProvider"
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"/> android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"
android:exported="true" />
</application> </application>

View File

@ -392,6 +392,8 @@ class NavigationActivity : AppCompatActivity() {
if (!infoDialogDisplayed) { if (!infoDialogDisplayed) {
infoDialogDisplayed = true infoDialogDisplayed = true
Settings.firstInstalledVersion = Util.getVersionCode(UApp.applicationContext())
InfoDialog.Builder(this) InfoDialog.Builder(this)
.setTitle(R.string.main_welcome_title) .setTitle(R.string.main_welcome_title)
.setMessage(R.string.main_welcome_text_demo) .setMessage(R.string.main_welcome_text_demo)

View File

@ -1,6 +1,6 @@
/* /*
* ArtistRowAdapter.kt * ArtistRowBinder.kt
* Copyright (C) 2009-2021 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@ -19,6 +19,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewBinder import com.drakeet.multitype.ItemViewBinder
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.ArtistOrIndex import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.imageloader.ImageLoader import org.moire.ultrasonic.imageloader.ImageLoader
@ -57,7 +58,7 @@ class ArtistRowBinder(
holder.coverArtId = item.coverArt holder.coverArtId = item.coverArt
if (Settings.shouldShowArtistPicture) { if (showArtistPicture()) {
holder.coverArt.visibility = View.VISIBLE holder.coverArt.visibility = View.VISIBLE
val key = FileUtil.getArtistArtKey(item.name, false) val key = FileUtil.getArtistArtKey(item.name, false)
imageLoader.loadImage( imageLoader.loadImage(
@ -102,11 +103,16 @@ class ArtistRowBinder(
} }
private fun getSectionFromName(name: String): String { private fun getSectionFromName(name: String): String {
var section = name.first().uppercaseChar() if (name.isEmpty()) return SECTION_KEY_DEFAULT
if (!section.isLetter()) section = '#' val section = name.first().uppercaseChar()
if (!section.isLetter()) return SECTION_KEY_DEFAULT
return section.toString() return section.toString()
} }
private fun showArtistPicture(): Boolean {
return ActiveServerProvider.isID3Enabled() && Settings.shouldShowArtistPicture
}
/** /**
* Creates an instance of our ViewHolder class * Creates an instance of our ViewHolder class
*/ */
@ -123,4 +129,8 @@ class ArtistRowBinder(
override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): ViewHolder { override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): ViewHolder {
return ViewHolder(inflater.inflate(layout, parent, false)) return ViewHolder(inflater.inflate(layout, parent, false))
} }
companion object {
const val SECTION_KEY_DEFAULT = "#"
}
} }

View File

@ -59,7 +59,7 @@ class BaseAdapter<T : Identifiable> : MultiTypeAdapter(), FastScrollRecyclerView
throw IllegalAccessException("You must use submitList() to add data to the Adapter") throw IllegalAccessException("You must use submitList() to add data to the Adapter")
} }
var mDiffer: AsyncListDiffer<T> = AsyncListDiffer( private var mDiffer: AsyncListDiffer<T> = AsyncListDiffer(
AdapterListUpdateCallback(this), AdapterListUpdateCallback(this),
AsyncDifferConfig.Builder(diffCallback).build() AsyncDifferConfig.Builder(diffCallback).build()
) )
@ -182,12 +182,11 @@ class BaseAdapter<T : Identifiable> : MultiTypeAdapter(), FastScrollRecyclerView
// Select them all // Select them all
getCurrentList().mapNotNullTo( getCurrentList().mapNotNullTo(
selectedSet, selectedSet
{ entry -> ) { entry ->
// Exclude any -1 ids, eg. headers and other UI elements // Exclude any -1 ids, eg. headers and other UI elements
entry.longId.takeIf { it != -1L } entry.longId.takeIf { it != -1L }
} }
)
return selectedSet.count() return selectedSet.count()
} }

View File

@ -153,6 +153,8 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
star.setImageDrawable(imageHelper.starHollowDrawable) star.setImageDrawable(imageHelper.starHollowDrawable)
song.starred = false song.starred = false
} }
// Should this be done here ?
Thread { Thread {
val musicService = MusicServiceFactory.getMusicService() val musicService = MusicServiceFactory.getMusicService()
try { try {

View File

@ -1,3 +1,10 @@
/*
* ActiveServerProvider.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.data package org.moire.ultrasonic.data
import androidx.room.Room import androidx.room.Room
@ -110,20 +117,23 @@ class ActiveServerProvider(
Timber.i("Switching to new database, id:$activeServer") Timber.i("Switching to new database, id:$activeServer")
cachedServerId = activeServer cachedServerId = activeServer
return buildDatabase(cachedServerId) cachedDatabase = initDatabase(activeServer)
return cachedDatabase!!
} }
val offlineMetaDatabase: MetaDatabase by lazy { val offlineMetaDatabase: MetaDatabase by lazy {
buildDatabase(OFFLINE_DB_ID) initDatabase(0)
} }
private fun buildDatabase(id: Int?): MetaDatabase { private fun initDatabase(serverId: Int): MetaDatabase {
return Room.databaseBuilder( return Room.databaseBuilder(
UApp.applicationContext(), UApp.applicationContext(),
MetaDatabase::class.java, MetaDatabase::class.java,
METADATA_DB + id METADATA_DB + serverId
) )
.fallbackToDestructiveMigration() .addMigrations(META_MIGRATION_2_3)
.fallbackToDestructiveMigrationOnDowngrade()
.build() .build()
} }
@ -239,6 +249,13 @@ class ActiveServerProvider(
return preferences.getBoolean(Constants.PREFERENCES_KEY_SCROBBLE, false) return preferences.getBoolean(Constants.PREFERENCES_KEY_SCROBBLE, false)
} }
/**
* Queries if ID3 tags should be used
*/
fun isID3Enabled(): Boolean {
return Settings.shouldUseId3Tags && (!isOffline() || Settings.useId3TagsOffline)
}
/** /**
* Queries if Server Scaling is enabled * Queries if Server Scaling is enabled
*/ */

View File

@ -0,0 +1,80 @@
package org.moire.ultrasonic.data
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import org.moire.ultrasonic.domain.Album
@Dao
interface AlbumDao : GenericDao<Album> {
/**
* Clear the whole database
*/
@Query("DELETE FROM albums")
fun clear()
/**
* Get all albums
*/
@Query("SELECT * FROM albums")
fun get(): List<Album>
/**
* Get all albums in a specific range
*/
@Query("SELECT * FROM albums LIMIT :offset,:size")
fun get(size: Int, offset: Int = 0): List<Album>
/**
* Get album by id
*/
@Query("SELECT * FROM albums where id LIKE :albumId LIMIT 1")
fun get(albumId: String): Album
/**
* Get albums by artist
*/
@Query("SELECT * FROM albums WHERE artistId LIKE :id")
fun byArtist(id: String): List<Album>
/**
* Clear albums by artist
*/
@Query("DELETE FROM albums WHERE artistId LIKE :id")
fun clearByArtist(id: String)
/**
* TODO: Make generic
* Upserts (insert or update) an object to the database
*
* @param obj the object to upsert
*/
@Transaction
@JvmSuppressWildcards
fun upsert(obj: Album) {
val id = insertIgnoring(obj)
if (id == -1L) {
update(obj)
}
}
/**
* Upserts (insert or update) a list of objects
*
* @param objList the object to be upserted
*/
@Transaction
@JvmSuppressWildcards
fun upsert(objList: List<Album>) {
val insertResult = insertIgnoring(objList)
val updateList: MutableList<Album> = ArrayList()
for (i in insertResult.indices) {
if (insertResult[i] == -1L) {
updateList.add(objList[i])
}
}
if (updateList.isNotEmpty()) {
update(updateList)
}
}
}

View File

@ -7,7 +7,7 @@ import androidx.room.Query
import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Artist
@Dao @Dao
interface ArtistsDao { interface ArtistDao {
/** /**
* Insert a list in the database. If the item already exists, replace it. * Insert a list in the database. If the item already exists, replace it.
* *
@ -43,5 +43,5 @@ interface ArtistsDao {
* Get artist by id * Get artist by id
*/ */
@Query("SELECT * FROM artists WHERE id LIKE :id") @Query("SELECT * FROM artists WHERE id LIKE :id")
fun get(id: String): Artist fun get(id: String): Artist?
} }

View File

@ -53,6 +53,7 @@ interface IndexDao : GenericDao<Index> {
fun get(musicFolderId: String): List<Index> fun get(musicFolderId: String): List<Index>
/** /**
* TODO: Make generic
* Upserts (insert or update) an object to the database * Upserts (insert or update) an object to the database
* *
* @param obj the object to upsert * @param obj the object to upsert

View File

@ -1,23 +1,85 @@
/*
* MetaDatabase.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.data package org.moire.ultrasonic.data
import androidx.room.AutoMigration
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import java.util.Date
import org.moire.ultrasonic.domain.Album
import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Index import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.MusicFolder import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.domain.Track
/** /**
* This database is used to store and cache the ID3 metadata * This database is used to store and cache the ID3 metadata
*/ */
@Database( @Database(
entities = [Artist::class, Index::class, MusicFolder::class], entities = [
version = 1 Artist::class,
Album::class,
Track::class,
Index::class,
MusicFolder::class
],
autoMigrations = [
AutoMigration(
from = 1,
to = 2
),
],
exportSchema = true,
version = 3
) )
@TypeConverters(Converters::class)
abstract class MetaDatabase : RoomDatabase() { abstract class MetaDatabase : RoomDatabase() {
abstract fun artistsDao(): ArtistsDao abstract fun artistDao(): ArtistDao
abstract fun albumDao(): AlbumDao
abstract fun trackDao(): TrackDao
abstract fun musicFoldersDao(): MusicFoldersDao abstract fun musicFoldersDao(): MusicFoldersDao
abstract fun indexDao(): IndexDao abstract fun indexDao(): IndexDao
} }
class Converters {
@TypeConverter
fun fromTimestamp(value: Long?): Date? {
return value?.let { Date(it) }
}
@TypeConverter
fun dateToTimestamp(date: Date?): Long? {
return date?.time
}
}
/* ktlint-disable max-line-length */
val META_MIGRATION_2_3: Migration = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("DROP TABLE `albums`")
database.execSQL("DROP TABLE `indexes`")
database.execSQL("DROP TABLE `artists`")
database.execSQL("DROP TABLE `tracks`")
database.execSQL("DROP TABLE `music_folders`")
database.execSQL("CREATE TABLE IF NOT EXISTS `albums` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `parent` TEXT, `album` TEXT, `title` TEXT, `name` TEXT, `discNumber` INTEGER, `coverArt` TEXT, `songCount` INTEGER, `created` INTEGER, `artist` TEXT, `artistId` TEXT, `duration` INTEGER, `year` INTEGER, `genre` TEXT, `starred` INTEGER NOT NULL, `path` TEXT, `closeness` INTEGER NOT NULL, `isDirectory` INTEGER NOT NULL, `isVideo` INTEGER NOT NULL, PRIMARY KEY(`id`, `serverId`))")
database.execSQL("CREATE TABLE IF NOT EXISTS `indexes` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, `musicFolderId` TEXT, PRIMARY KEY(`id`, `serverId`))")
database.execSQL("CREATE TABLE IF NOT EXISTS `artists` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, PRIMARY KEY(`id`, `serverId`))")
database.execSQL("CREATE TABLE IF NOT EXISTS `music_folders` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`id`, `serverId`))")
database.execSQL("CREATE TABLE IF NOT EXISTS `tracks` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `parent` TEXT, `isDirectory` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `albumId` TEXT, `artist` TEXT, `artistId` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `contentType` TEXT, `suffix` TEXT, `transcodedContentType` TEXT, `transcodedSuffix` TEXT, `coverArt` TEXT, `size` INTEGER, `songCount` INTEGER, `duration` INTEGER, `bitRate` INTEGER, `path` TEXT, `isVideo` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `discNumber` INTEGER, `type` TEXT, `created` INTEGER, `closeness` INTEGER NOT NULL, `bookmarkPosition` INTEGER NOT NULL, `userRating` INTEGER, `averageRating` REAL, `name` TEXT, PRIMARY KEY(`id`, `serverId`))")
}
}
/* ktlint-enable max-line-length */

View File

@ -0,0 +1,34 @@
package org.moire.ultrasonic.data
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Query
import org.moire.ultrasonic.domain.Track
@Dao
@Entity(tableName = "tracks")
interface TrackDao : GenericDao<Track> {
/**
* Clear the whole database
*/
@Query("DELETE FROM tracks")
fun clear()
/**
* Get all albums
*/
@Query("SELECT * FROM tracks")
fun get(): List<Track>
/**
* Get albums by artist
*/
@Query("SELECT * FROM tracks WHERE albumId LIKE :id")
fun byAlbum(id: String): List<Track>
/**
* Get albums by artist
*/
@Query("SELECT * FROM tracks WHERE artistId LIKE :id")
fun byArtist(id: String): List<Track>
}

View File

@ -1,3 +1,10 @@
/*
* APIAlbumConverter.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
// Converts Album entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient] // Converts Album entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
// to app domain entities. // to app domain entities.
@file:JvmName("APIAlbumConverter") @file:JvmName("APIAlbumConverter")
@ -6,8 +13,9 @@ package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.Album import org.moire.ultrasonic.api.subsonic.models.Album
typealias DomainAlbum = org.moire.ultrasonic.domain.Album typealias DomainAlbum = org.moire.ultrasonic.domain.Album
fun Album.toDomainEntity(): DomainAlbum = Album( fun Album.toDomainEntity(serverId: Int): DomainAlbum = Album(
id = this@toDomainEntity.id, id = this@toDomainEntity.id,
serverId = serverId,
title = this@toDomainEntity.name ?: this@toDomainEntity.title, title = this@toDomainEntity.name ?: this@toDomainEntity.title,
album = this@toDomainEntity.album, album = this@toDomainEntity.album,
coverArt = this@toDomainEntity.coverArt, coverArt = this@toDomainEntity.coverArt,
@ -21,8 +29,10 @@ fun Album.toDomainEntity(): DomainAlbum = Album(
starred = this@toDomainEntity.starredDate.isNotEmpty() starred = this@toDomainEntity.starredDate.isNotEmpty()
) )
fun Album.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory().apply { fun Album.toMusicDirectoryDomainEntity(serverId: Int): MusicDirectory = MusicDirectory().apply {
addAll(this@toMusicDirectoryDomainEntity.songList.map { it.toTrackEntity() }) addAll(this@toMusicDirectoryDomainEntity.songList.map { it.toTrackEntity(serverId) })
} }
fun List<Album>.toDomainEntityList(): List<DomainAlbum> = this.map { it.toDomainEntity() } fun List<Album>.toDomainEntityList(serverId: Int): List<DomainAlbum> = this.map {
it.toDomainEntity(serverId)
}

View File

@ -1,3 +1,10 @@
/*
* APIArtistConverter.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
// Converts Artist entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient] // Converts Artist entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
// to app domain entities. // to app domain entities.
@file:JvmName("APIArtistConverter") @file:JvmName("APIArtistConverter")
@ -6,24 +13,26 @@ package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.Artist as APIArtist import org.moire.ultrasonic.api.subsonic.models.Artist as APIArtist
// When we like to convert to an Artist // When we like to convert to an Artist
fun APIArtist.toDomainEntity(): Artist = Artist( fun APIArtist.toDomainEntity(serverId: Int): Artist = Artist(
id = this@toDomainEntity.id, id = this@toDomainEntity.id,
serverId = serverId,
coverArt = this@toDomainEntity.coverArt, coverArt = this@toDomainEntity.coverArt,
name = this@toDomainEntity.name name = this@toDomainEntity.name
) )
// When we like to convert to an index (eg. a single directory). // When we like to convert to an index (eg. a single directory).
fun APIArtist.toIndexEntity(): Index = Index( fun APIArtist.toIndexEntity(serverId: Int): Index = Index(
id = this@toIndexEntity.id, id = this@toIndexEntity.id,
serverId = serverId,
coverArt = this@toIndexEntity.coverArt, coverArt = this@toIndexEntity.coverArt,
name = this@toIndexEntity.name name = this@toIndexEntity.name
) )
fun APIArtist.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory().apply { fun APIArtist.toMusicDirectoryDomainEntity(serverId: Int): MusicDirectory = MusicDirectory().apply {
name = this@toMusicDirectoryDomainEntity.name name = this@toMusicDirectoryDomainEntity.name
addAll(this@toMusicDirectoryDomainEntity.albumsList.map { it.toDomainEntity() }) addAll(this@toMusicDirectoryDomainEntity.albumsList.map { it.toDomainEntity(serverId) })
} }
fun APIArtist.toDomainEntityList(): List<Album> { fun APIArtist.toDomainEntityList(serverId: Int): List<Album> {
return this.albumsList.map { it.toDomainEntity() } return this.albumsList.map { it.toDomainEntity(serverId) }
} }

View File

@ -1,16 +1,25 @@
/*
* APIBookmarkConverter.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
// Contains helper functions to convert api Bookmark entity to domain entity // Contains helper functions to convert api Bookmark entity to domain entity
@file:JvmName("APIBookmarkConverter") @file:JvmName("APIBookmarkConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.Bookmark as ApiBookmark import org.moire.ultrasonic.api.subsonic.models.Bookmark as ApiBookmark
fun ApiBookmark.toDomainEntity(): Bookmark = Bookmark( fun ApiBookmark.toDomainEntity(serverId: Int): Bookmark = Bookmark(
position = this@toDomainEntity.position.toInt(), position = this@toDomainEntity.position.toInt(),
username = this@toDomainEntity.username, username = this@toDomainEntity.username,
comment = this@toDomainEntity.comment, comment = this@toDomainEntity.comment,
created = this@toDomainEntity.created?.time, created = this@toDomainEntity.created?.time,
changed = this@toDomainEntity.changed?.time, changed = this@toDomainEntity.changed?.time,
track = this@toDomainEntity.entry.toTrackEntity() track = this@toDomainEntity.entry.toTrackEntity(serverId)
) )
fun List<ApiBookmark>.toDomainEntitiesList(): List<Bookmark> = map { it.toDomainEntity() } fun List<ApiBookmark>.toDomainEntitiesList(serverId: Int): List<Bookmark> =
map { it.toDomainEntity(serverId) }

View File

@ -1,14 +1,22 @@
/*
* APIIndexesConverter.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
// Converts Indexes entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient] // Converts Indexes entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
// to app domain entities. // to app domain entities.
@file:JvmName("APIIndexesConverter") @file:JvmName("APIIndexesConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.Index as APIIndex import org.moire.ultrasonic.api.subsonic.models.Index as APIIndex
import org.moire.ultrasonic.api.subsonic.models.Indexes as APIIndexes import org.moire.ultrasonic.api.subsonic.models.Indexes as APIIndexes
fun APIIndexes.toArtistList(): List<Artist> { fun APIIndexes.toArtistList(serverId: Int): List<Artist> {
val shortcuts = this.shortcutList.map { it.toDomainEntity() }.toMutableList() val shortcuts = this.shortcutList.map { it.toDomainEntity(serverId) }.toMutableList()
val indexes = this.indexList.foldIndexToArtistList() val indexes = this.indexList.foldIndexToArtistList(serverId)
indexes.forEach { indexes.forEach {
if (!shortcuts.contains(it)) { if (!shortcuts.contains(it)) {
@ -19,9 +27,9 @@ fun APIIndexes.toArtistList(): List<Artist> {
return shortcuts return shortcuts
} }
fun APIIndexes.toIndexList(musicFolderId: String?): List<Index> { fun APIIndexes.toIndexList(serverId: Int, musicFolderId: String?): List<Index> {
val shortcuts = this.shortcutList.map { it.toIndexEntity() }.toMutableList() val shortcuts = this.shortcutList.map { it.toIndexEntity(serverId) }.toMutableList()
val indexes = this.indexList.foldIndexToIndexList(musicFolderId) val indexes = this.indexList.foldIndexToIndexList(musicFolderId, serverId)
indexes.forEach { indexes.forEach {
if (!shortcuts.contains(it)) { if (!shortcuts.contains(it)) {
@ -32,22 +40,23 @@ fun APIIndexes.toIndexList(musicFolderId: String?): List<Index> {
return shortcuts return shortcuts
} }
private fun List<APIIndex>.foldIndexToArtistList(): List<Artist> = this.fold( private fun List<APIIndex>.foldIndexToArtistList(serverId: Int): List<Artist> = this.fold(
listOf(), listOf()
{ acc, index -> ) { acc, index ->
acc + index.artists.map { acc + index.artists.map {
it.toDomainEntity() it.toDomainEntity(serverId)
}
} }
) }
private fun List<APIIndex>.foldIndexToIndexList(musicFolderId: String?): List<Index> = this.fold( private fun List<APIIndex>.foldIndexToIndexList(
listOf(), musicFolderId: String?,
{ acc, index -> serverId: Int
acc + index.artists.map { ): List<Index> = this.fold(
val ret = it.toIndexEntity() listOf()
ret.musicFolderId = musicFolderId ) { acc, index ->
ret acc + index.artists.map {
} val ret = it.toIndexEntity(serverId)
ret.musicFolderId = musicFolderId
ret
} }
) }

View File

@ -1,6 +1,6 @@
/* /*
* APIMusicDirectoryConverter.kt * APIMusicDirectoryConverter.kt
* Copyright (C) 2009-2021 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@ -26,12 +26,12 @@ internal val dateFormat: DateFormat by lazy {
SimpleDateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT, Locale.getDefault()) SimpleDateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT, Locale.getDefault())
} }
fun MusicDirectoryChild.toTrackEntity(): Track = Track(id).apply { fun MusicDirectoryChild.toTrackEntity(serverId: Int): Track = Track(id, serverId).apply {
populateCommonProps(this, this@toTrackEntity) populateCommonProps(this, this@toTrackEntity)
populateTrackProps(this, this@toTrackEntity) populateTrackProps(this, this@toTrackEntity)
} }
fun MusicDirectoryChild.toAlbumEntity(): Album = Album(id).apply { fun MusicDirectoryChild.toAlbumEntity(serverId: Int): Album = Album(id, serverId).apply {
populateCommonProps(this, this@toAlbumEntity) populateCommonProps(this, this@toAlbumEntity)
} }
@ -80,24 +80,24 @@ private fun populateTrackProps(
track.averageRating = source.averageRating track.averageRating = source.averageRating
} }
fun List<MusicDirectoryChild>.toDomainEntityList(): List<MusicDirectory.Child> { fun List<MusicDirectoryChild>.toDomainEntityList(serverId: Int): List<MusicDirectory.Child> {
val newList: MutableList<MusicDirectory.Child> = mutableListOf() val newList: MutableList<MusicDirectory.Child> = mutableListOf()
forEach { forEach {
if (it.isDir) if (it.isDir)
newList.add(it.toAlbumEntity()) newList.add(it.toAlbumEntity(serverId))
else else
newList.add(it.toTrackEntity()) newList.add(it.toTrackEntity(serverId))
} }
return newList return newList
} }
fun List<MusicDirectoryChild>.toTrackList(): List<Track> = this.map { fun List<MusicDirectoryChild>.toTrackList(serverId: Int): List<Track> = this.map {
it.toTrackEntity() it.toTrackEntity(serverId)
} }
fun APIMusicDirectory.toDomainEntity(): MusicDirectory = MusicDirectory().apply { fun APIMusicDirectory.toDomainEntity(serverId: Int): MusicDirectory = MusicDirectory().apply {
name = this@toDomainEntity.name name = this@toDomainEntity.name
addAll(this@toDomainEntity.childList.toDomainEntityList()) addAll(this@toDomainEntity.childList.toDomainEntityList(serverId))
} }

View File

@ -1,3 +1,10 @@
/*
* APIMusicFolderConverter.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
// Converts MusicFolder entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient] // Converts MusicFolder entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
// to app domain entities. // to app domain entities.
@file:JvmName("APIMusicFolderConverter") @file:JvmName("APIMusicFolderConverter")
@ -5,7 +12,15 @@ package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.MusicFolder as APIMusicFolder import org.moire.ultrasonic.api.subsonic.models.MusicFolder as APIMusicFolder
fun APIMusicFolder.toDomainEntity(): MusicFolder = MusicFolder(this.id, this.name) fun APIMusicFolder.toDomainEntity(serverId: Int): MusicFolder = MusicFolder(
id = this.id,
serverId = serverId,
name = this.name
)
fun List<APIMusicFolder>.toDomainEntityList(): List<MusicFolder> = fun List<APIMusicFolder>.toDomainEntityList(serverId: Int): List<MusicFolder> =
this.map { it.toDomainEntity() } this.map {
val item = it.toDomainEntity(serverId)
item.serverId = serverId
item
}

View File

@ -1,6 +1,14 @@
/*
* APIPlaylistConverter.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
// Converts Playlist entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient] // Converts Playlist entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
// to app domain entities. // to app domain entities.
@file:JvmName("APIPlaylistConverter") @file:JvmName("APIPlaylistConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -10,10 +18,17 @@ import org.moire.ultrasonic.util.Util.ifNotNull
internal val playlistDateFormat by lazy(NONE) { SimpleDateFormat.getInstance() } internal val playlistDateFormat by lazy(NONE) { SimpleDateFormat.getInstance() }
fun APIPlaylist.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory().apply { fun APIPlaylist.toMusicDirectoryDomainEntity(serverId: Int): MusicDirectory =
name = this@toMusicDirectoryDomainEntity.name MusicDirectory().apply {
addAll(this@toMusicDirectoryDomainEntity.entriesList.map { it.toTrackEntity() }) name = this@toMusicDirectoryDomainEntity.name
} addAll(
this@toMusicDirectoryDomainEntity.entriesList.map {
val item = it.toTrackEntity(serverId)
item.serverId = serverId
item
}
)
}
fun APIPlaylist.toDomainEntity(): Playlist = Playlist( fun APIPlaylist.toDomainEntity(): Playlist = Playlist(
this.id, this.name, this.owner, this.id, this.name, this.owner,

View File

@ -1,3 +1,10 @@
/*
* APISearchConverter.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
// Converts SearchResult entities from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient] // Converts SearchResult entities from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
// to app domain entities. // to app domain entities.
@file:JvmName("APISearchConverter") @file:JvmName("APISearchConverter")
@ -7,19 +14,19 @@ import org.moire.ultrasonic.api.subsonic.models.SearchResult as APISearchResult
import org.moire.ultrasonic.api.subsonic.models.SearchThreeResult import org.moire.ultrasonic.api.subsonic.models.SearchThreeResult
import org.moire.ultrasonic.api.subsonic.models.SearchTwoResult import org.moire.ultrasonic.api.subsonic.models.SearchTwoResult
fun APISearchResult.toDomainEntity(): SearchResult = SearchResult( fun APISearchResult.toDomainEntity(serverId: Int): SearchResult = SearchResult(
emptyList(), emptyList(), emptyList(), emptyList(),
this.matchList.map { it.toTrackEntity() } this.matchList.map { it.toTrackEntity(serverId) }
) )
fun SearchTwoResult.toDomainEntity(): SearchResult = SearchResult( fun SearchTwoResult.toDomainEntity(serverId: Int): SearchResult = SearchResult(
this.artistList.map { it.toIndexEntity() }, this.artistList.map { it.toIndexEntity(serverId) },
this.albumList.map { it.toDomainEntity() }, this.albumList.map { it.toDomainEntity(serverId) },
this.songList.map { it.toTrackEntity() } this.songList.map { it.toTrackEntity(serverId) }
) )
fun SearchThreeResult.toDomainEntity(): SearchResult = SearchResult( fun SearchThreeResult.toDomainEntity(serverId: Int): SearchResult = SearchResult(
this.artistList.map { it.toDomainEntity() }, this.artistList.map { it.toDomainEntity(serverId) },
this.albumList.map { it.toDomainEntity() }, this.albumList.map { it.toDomainEntity(serverId) },
this.songList.map { it.toTrackEntity() } this.songList.map { it.toTrackEntity(serverId) }
) )

View File

@ -1,3 +1,10 @@
/*
* APIShareConverter.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
// Contains helper method to convert subsonic api share to domain model // Contains helper method to convert subsonic api share to domain model
@file:JvmName("APIShareConverter") @file:JvmName("APIShareConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
@ -9,11 +16,11 @@ import org.moire.ultrasonic.util.Util.ifNotNull
internal val shareTimeFormat by lazy(NONE) { SimpleDateFormat.getInstance() } internal val shareTimeFormat by lazy(NONE) { SimpleDateFormat.getInstance() }
fun List<APIShare>.toDomainEntitiesList(): List<Share> = this.map { fun List<APIShare>.toDomainEntitiesList(serverId: Int): List<Share> = this.map {
it.toDomainEntity() it.toDomainEntity(serverId)
} }
fun APIShare.toDomainEntity(): Share = Share( fun APIShare.toDomainEntity(serverId: Int): Share = Share(
created = this@toDomainEntity.created.ifNotNull { shareTimeFormat.format(it.time) }, created = this@toDomainEntity.created.ifNotNull { shareTimeFormat.format(it.time) },
description = this@toDomainEntity.description, description = this@toDomainEntity.description,
expires = this@toDomainEntity.expires.ifNotNull { shareTimeFormat.format(it.time) }, expires = this@toDomainEntity.expires.ifNotNull { shareTimeFormat.format(it.time) },
@ -22,5 +29,5 @@ fun APIShare.toDomainEntity(): Share = Share(
url = this@toDomainEntity.url, url = this@toDomainEntity.url,
username = this@toDomainEntity.username, username = this@toDomainEntity.username,
visitCount = this@toDomainEntity.visitCount.toLong(), visitCount = this@toDomainEntity.visitCount.toLong(),
tracks = this@toDomainEntity.items.toTrackList().toMutableList() tracks = this@toDomainEntity.items.toTrackList(serverId).toMutableList()
) )

View File

@ -66,17 +66,17 @@ class MainFragment : Fragment(), KoinComponent {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
var shouldRestart = false var shouldRelayout = false
val currentId3Setting = Settings.shouldUseId3Tags val currentId3Setting = Settings.shouldUseId3Tags
// If setting has changed... // If setting has changed...
if (currentId3Setting != cachedId3Setting) { if (currentId3Setting != useId3) {
cachedId3Setting = currentId3Setting useId3 = currentId3Setting
shouldRestart = true shouldRelayout = true
} }
// then setup the list anew. // then setup the list anew.
if (shouldRestart) { if (shouldRelayout) {
setupItemVisibility() setupItemVisibility()
} }
} }
@ -109,17 +109,19 @@ class MainFragment : Fragment(), KoinComponent {
private fun setupItemVisibility() { private fun setupItemVisibility() {
// Cache some values // Cache some values
cachedId3Setting = Settings.shouldUseId3Tags useId3 = Settings.shouldUseId3Tags
useId3Offline = Settings.useId3TagsOffline
val isOnline = !isOffline() val isOnline = !isOffline()
// Music // Music
musicTitle.isVisible = true musicTitle.isVisible = true
artistsButton.isVisible = true artistsButton.isVisible = true
albumsButton.isVisible = isOnline albumsButton.isVisible = isOnline || useId3Offline
genresButton.isVisible = true genresButton.isVisible = true
// Songs // Songs
songsTitle.isVisible = isOnline songsTitle.isVisible = true
randomSongsButton.isVisible = true randomSongsButton.isVisible = true
songsStarredButton.isVisible = isOnline songsStarredButton.isVisible = isOnline
@ -128,7 +130,7 @@ class MainFragment : Fragment(), KoinComponent {
albumsNewestButton.isVisible = isOnline albumsNewestButton.isVisible = isOnline
albumsRecentButton.isVisible = isOnline albumsRecentButton.isVisible = isOnline
albumsFrequentButton.isVisible = isOnline albumsFrequentButton.isVisible = isOnline
albumsHighestButton.isVisible = isOnline && !cachedId3Setting albumsHighestButton.isVisible = isOnline && !useId3
albumsRandomButton.isVisible = isOnline albumsRandomButton.isVisible = isOnline
albumsStarredButton.isVisible = isOnline albumsStarredButton.isVisible = isOnline
albumsAlphaByNameButton.isVisible = isOnline albumsAlphaByNameButton.isVisible = isOnline
@ -240,6 +242,7 @@ class MainFragment : Fragment(), KoinComponent {
} }
companion object { companion object {
private var cachedId3Setting = false private var useId3 = false
private var useId3Offline = false
} }
} }

View File

@ -33,17 +33,22 @@ import android.widget.LinearLayout
import android.widget.SeekBar import android.widget.SeekBar
import android.widget.SeekBar.OnSeekBarChangeListener import android.widget.SeekBar.OnSeekBarChangeListener
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import android.widget.ViewFlipper import android.widget.ViewFlipper
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.media3.common.HeartRating
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Timeline import androidx.media3.common.Timeline
import androidx.media3.session.SessionResult
import androidx.navigation.Navigation import androidx.navigation.Navigation
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.common.util.concurrent.FutureCallback
import com.google.common.util.concurrent.Futures
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -747,26 +752,32 @@ class PlayerFragment :
if (currentSong == null) return true if (currentSong == null) return true
val isStarred = currentSong!!.starred val isStarred = currentSong!!.starred
val id = currentSong!!.id
if (isStarred) { mediaPlayerController.controller?.setRating(
starMenuItem.icon = hollowStar HeartRating(!isStarred)
currentSong!!.starred = false )?.let {
} else { Futures.addCallback(
starMenuItem.icon = fullStar it,
currentSong!!.starred = true object : FutureCallback<SessionResult> {
override fun onSuccess(result: SessionResult?) {
if (isStarred) {
starMenuItem.icon = hollowStar
currentSong!!.starred = false
} else {
starMenuItem.icon = fullStar
currentSong!!.starred = true
}
}
override fun onFailure(t: Throwable) {
Toast.makeText(context, "SetRating failed", Toast.LENGTH_SHORT)
.show()
}
},
this.executorService
)
} }
Thread {
val musicService = getMusicService()
try {
if (isStarred) {
musicService.unstar(id, null, null)
} else {
musicService.star(id, null, null)
}
} catch (all: Exception) {
Timber.e(all)
}
}.start()
return true return true
} }
R.id.menu_item_bookmark_set -> { R.id.menu_item_bookmark_set -> {

View File

@ -6,11 +6,16 @@ import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.SearchRecentSuggestions import android.provider.SearchRecentSuggestions
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.view.View import android.view.View
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
@ -76,6 +81,7 @@ class SettingsFragment :
private var directoryCacheTime: ListPreference? = null private var directoryCacheTime: ListPreference? = null
private var mediaButtonsEnabled: CheckBoxPreference? = null private var mediaButtonsEnabled: CheckBoxPreference? = null
private var showArtistPicture: CheckBoxPreference? = null private var showArtistPicture: CheckBoxPreference? = null
private var useId3TagsOffline: CheckBoxPreference? = null
private var sharingDefaultDescription: EditTextPreference? = null private var sharingDefaultDescription: EditTextPreference? = null
private var sharingDefaultGreeting: EditTextPreference? = null private var sharingDefaultGreeting: EditTextPreference? = null
private var sharingDefaultExpiration: TimeSpanPreference? = null private var sharingDefaultExpiration: TimeSpanPreference? = null
@ -121,14 +127,39 @@ class SettingsFragment :
pauseOnBluetoothDevice = findPreference(Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE) pauseOnBluetoothDevice = findPreference(Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE)
debugLogToFile = findPreference(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE) debugLogToFile = findPreference(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE)
showArtistPicture = findPreference(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE) showArtistPicture = findPreference(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE)
useId3TagsOffline = findPreference(Constants.PREFERENCES_KEY_ID3_TAGS_OFFLINE)
customCacheLocation = findPreference(Constants.PREFERENCES_KEY_CUSTOM_CACHE_LOCATION) customCacheLocation = findPreference(Constants.PREFERENCES_KEY_CUSTOM_CACHE_LOCATION)
sharingDefaultGreeting?.text = shareGreeting sharingDefaultGreeting?.text = shareGreeting
setupTextColors()
setupClearSearchPreference() setupClearSearchPreference()
setupCacheLocationPreference() setupCacheLocationPreference()
setupBluetoothDevicePreferences() setupBluetoothDevicePreferences()
} }
private fun setupTextColors(enabled: Boolean = shouldUseId3Tags) {
val firstPart = getString(R.string.settings_use_id3_offline_warning)
var secondPart = getString(R.string.settings_use_id3_offline_summary)
// Little hack to circumvent a bug in Android. If we just change the color,
// the text is not refreshed. If we also change the string, it is refreshed.
if (enabled) secondPart += " "
val color = if (enabled) "#bd5164" else "#813b48"
Timber.i(color)
val warning = SpannableString(firstPart + "\n" + secondPart)
warning.setSpan(
ForegroundColorSpan(Color.parseColor(color)), 0, firstPart.length, 0
)
warning.setSpan(
StyleSpan(Typeface.BOLD), 0, firstPart.length, 0
)
useId3TagsOffline?.summary = warning
}
override fun onActivityCreated(savedInstanceState: Bundle?) { override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState) super.onActivityCreated(savedInstanceState)
update() update()
@ -196,7 +227,10 @@ class SettingsFragment :
setDebugLogToFile(sharedPreferences.getBoolean(key, false)) setDebugLogToFile(sharedPreferences.getBoolean(key, false))
} }
Constants.PREFERENCES_KEY_ID3_TAGS -> { Constants.PREFERENCES_KEY_ID3_TAGS -> {
showArtistPicture!!.isEnabled = sharedPreferences.getBoolean(key, false) val enabled = sharedPreferences.getBoolean(key, false)
showArtistPicture?.isEnabled = enabled
useId3TagsOffline?.isEnabled = enabled
setupTextColors(enabled)
} }
Constants.PREFERENCES_KEY_THEME -> { Constants.PREFERENCES_KEY_THEME -> {
RxBus.themeChangedEventPublisher.onNext(Unit) RxBus.themeChangedEventPublisher.onNext(Unit)
@ -372,6 +406,7 @@ class SettingsFragment :
debugLogToFile?.summary = "" debugLogToFile?.summary = ""
} }
showArtistPicture?.isEnabled = shouldUseId3Tags showArtistPicture?.isEnabled = shouldUseId3Tags
useId3TagsOffline?.isEnabled = shouldUseId3Tags
} }
private fun setHideMedia(hide: Boolean) { private fun setHideMedia(hide: Boolean) {

View File

@ -1,6 +1,6 @@
/* /*
* TrackCollectionFragment.kt * TrackCollectionFragment.kt
* Copyright (C) 2009-2021 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@ -31,6 +31,7 @@ import org.moire.ultrasonic.adapters.AlbumHeader
import org.moire.ultrasonic.adapters.AlbumRowBinder import org.moire.ultrasonic.adapters.AlbumRowBinder
import org.moire.ultrasonic.adapters.HeaderViewBinder import org.moire.ultrasonic.adapters.HeaderViewBinder
import org.moire.ultrasonic.adapters.TrackViewBinder import org.moire.ultrasonic.adapters.TrackViewBinder
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.MusicDirectory
@ -47,6 +48,7 @@ import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import timber.log.Timber
/** /**
* Displays a group of tracks, eg. the songs of an album, of a playlist etc. * Displays a group of tracks, eg. the songs of an album, of a playlist etc.
@ -61,11 +63,11 @@ import org.moire.ultrasonic.util.Util
open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() { open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
private var albumButtons: View? = null private var albumButtons: View? = null
internal var selectButton: ImageView? = null private var selectButton: ImageView? = null
internal var playNowButton: ImageView? = null internal var playNowButton: ImageView? = null
private var playNextButton: ImageView? = null private var playNextButton: ImageView? = null
private var playLastButton: ImageView? = null private var playLastButton: ImageView? = null
internal var pinButton: ImageView? = null private var pinButton: ImageView? = null
private var unpinButton: ImageView? = null private var unpinButton: ImageView? = null
private var downloadButton: ImageView? = null private var downloadButton: ImageView? = null
private var deleteButton: ImageView? = null private var deleteButton: ImageView? = null
@ -144,11 +146,10 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
// Update the buttons when the selection has changed // Update the buttons when the selection has changed
viewAdapter.selectionRevision.observe( viewAdapter.selectionRevision.observe(
viewLifecycleOwner, viewLifecycleOwner
{ ) {
enableButtons() enableButtons()
} }
)
} }
internal open fun setupButtons(view: View) { internal open fun setupButtons(view: View) {
@ -267,10 +268,10 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
private val childCount: Int private val childCount: Int
get() { get() {
val count = viewAdapter.getCurrentList().count() val count = viewAdapter.getCurrentList().count()
if (listModel.showHeader) { return if (listModel.showHeader) {
return count - 1 count - 1
} else { } else {
return count count
} }
} }
@ -320,13 +321,13 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
} as List<Track> } as List<Track>
} }
internal fun selectAllOrNone() { private fun selectAllOrNone() {
val someUnselected = viewAdapter.selectedSet.size < childCount val someUnselected = viewAdapter.selectedSet.size < childCount
selectAll(someUnselected, true) selectAll(someUnselected, true)
} }
internal fun selectAll(selected: Boolean, toast: Boolean) { private fun selectAll(selected: Boolean, toast: Boolean) {
var selectedCount = viewAdapter.selectedSet.size * -1 var selectedCount = viewAdapter.selectedSet.size * -1
selectedCount += viewAdapter.setSelectionStatusOfAll(selected) selectedCount += viewAdapter.setSelectionStatusOfAll(selected)
@ -366,7 +367,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
deleteButton?.isVisible = (enabled && deleteEnabled) deleteButton?.isVisible = (enabled && deleteEnabled)
} }
internal fun downloadBackground(save: Boolean) { private fun downloadBackground(save: Boolean) {
var songs = getSelectedSongs() var songs = getSelectedSongs()
if (songs.isEmpty()) { if (songs.isEmpty()) {
@ -426,6 +427,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
override val defaultObserver: (List<MusicDirectory.Child>) -> Unit = { override val defaultObserver: (List<MusicDirectory.Child>) -> Unit = {
Timber.i("Received list")
val entryList: MutableList<MusicDirectory.Child> = it.toMutableList() val entryList: MutableList<MusicDirectory.Child> = it.toMutableList()
if (listModel.currentListIsSortable && Settings.shouldSortByDisc) { if (listModel.currentListIsSortable && Settings.shouldSortByDisc) {
@ -454,9 +456,9 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
moreButton!!.visibility = View.GONE moreButton!!.visibility = View.GONE
} else { } else {
moreButton!!.visibility = View.VISIBLE moreButton!!.visibility = View.VISIBLE
if (arguments?.getInt(Constants.INTENT_RANDOM, 0) ?: 0 > 0) { if ((arguments?.getInt(Constants.INTENT_RANDOM, 0) ?: 0) > 0) {
moreRandomTracks() moreRandomTracks()
} else if (arguments?.getString(Constants.INTENT_GENRE_NAME, "") ?: "" != "") { } else if ((arguments?.getString(Constants.INTENT_GENRE_NAME, "") ?: "") != "") {
moreSongsForGenre() moreSongsForGenre()
} }
} }
@ -497,6 +499,8 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
} }
listModel.currentListIsSortable = true listModel.currentListIsSortable = true
Timber.i("Processed list")
} }
private fun moreSongsForGenre(args: Bundle = requireArguments()) { private fun moreSongsForGenre(args: Bundle = requireArguments()) {
@ -556,6 +560,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
args: Bundle?, args: Bundle?,
refresh: Boolean refresh: Boolean
): LiveData<List<MusicDirectory.Child>> { ): LiveData<List<MusicDirectory.Child>> {
Timber.i("Starting gathering track collection data...")
if (args == null) return listModel.currentList if (args == null) return listModel.currentList
val id = args.getString(Constants.INTENT_ID) val id = args.getString(Constants.INTENT_ID)
val isAlbum = args.getBoolean(Constants.INTENT_IS_ALBUM, false) val isAlbum = args.getBoolean(Constants.INTENT_IS_ALBUM, false)
@ -600,7 +605,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
listModel.getRandom(albumListSize) listModel.getRandom(albumListSize)
} else { } else {
setTitle(name) setTitle(name)
if (!isOffline() && Settings.shouldUseId3Tags) { if (ActiveServerProvider.isID3Enabled()) {
if (isAlbum) { if (isAlbum) {
listModel.getAlbum(refresh2, id!!, name) listModel.getAlbum(refresh2, id!!, name)
} else { } else {
@ -669,7 +674,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
return true return true
} }
internal fun getClickedSong(item: MusicDirectory.Child): List<Track> { private fun getClickedSong(item: MusicDirectory.Child): List<Track> {
// This can probably be done better // This can probably be done better
return viewAdapter.getCurrentList().mapNotNull { return viewAdapter.getCurrentList().mapNotNull {
if (it is Track && (it.id == item.id)) if (it is Track && (it.id == item.id))

View File

@ -39,7 +39,7 @@ class AlbumListModel(application: Application) : GenericListModel(application) {
id: String, id: String,
name: String? name: String?
) { ) {
list.postValue(musicService.getArtist(id, name, refresh)) list.postValue(musicService.getAlbumsOfArtist(id, name, refresh))
} }
override fun load( override fun load(

View File

@ -1,20 +1,8 @@
/* /*
This file is part of Subsonic. * ArtistListModel.kt
* Copyright (C) 2009-2022 Ultrasonic developers
Subsonic is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * Distributed under terms of the GNU GPLv3 license.
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2020 (C) Jozsef Varga
*/ */
package org.moire.ultrasonic.model package org.moire.ultrasonic.model
@ -24,6 +12,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import java.text.Collator import java.text.Collator
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.ArtistOrIndex import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.service.MusicService import org.moire.ultrasonic.service.MusicService
@ -56,12 +45,10 @@ class ArtistListModel(application: Application) : GenericListModel(application)
val musicFolderId = activeServer.musicFolderId val musicFolderId = activeServer.musicFolderId
val result: List<ArtistOrIndex> val result = if (ActiveServerProvider.isID3Enabled()) {
musicService.getArtists(refresh)
if (!isOffline && useId3Tags) {
result = musicService.getArtists(refresh)
} else { } else {
result = musicService.getIndexes(musicFolderId, refresh) musicService.getIndexes(musicFolderId, refresh)
} }
artists.postValue(result.toMutableList().sortedWith(comparator)) artists.postValue(result.toMutableList().sortedWith(comparator))

View File

@ -251,15 +251,15 @@ open class APIDataSource private constructor(
@Suppress("ThrowsCount") @Suppress("ThrowsCount")
@Throws(HttpDataSourceException::class) @Throws(HttpDataSourceException::class)
private fun skipFully(bytesToSkip: Long, dataSpec: DataSpec) { private fun skipFully(bytesToSkip: Long, dataSpec: DataSpec) {
var bytesToSkip = bytesToSkip var bytesToSkipCpy = bytesToSkip
if (bytesToSkip == 0L) { if (bytesToSkipCpy == 0L) {
return return
} }
val skipBuffer = ByteArray(4096) val skipBuffer = ByteArray(4096)
try { try {
while (bytesToSkip > 0) { while (bytesToSkipCpy > 0) {
val readLength = val readLength =
bytesToSkip.coerceAtMost(skipBuffer.size.toLong()).toInt() bytesToSkipCpy.coerceAtMost(skipBuffer.size.toLong()).toInt()
val read = Util.castNonNull(responseByteStream).read(skipBuffer, 0, readLength) val read = Util.castNonNull(responseByteStream).read(skipBuffer, 0, readLength)
if (Thread.currentThread().isInterrupted) { if (Thread.currentThread().isInterrupted) {
throw InterruptedIOException() throw InterruptedIOException()
@ -271,7 +271,7 @@ open class APIDataSource private constructor(
HttpDataSourceException.TYPE_OPEN HttpDataSourceException.TYPE_OPEN
) )
} }
bytesToSkip -= read.toLong() bytesToSkipCpy -= read.toLong()
bytesTransferred(read) bytesTransferred(read)
} }
return return
@ -305,8 +305,8 @@ open class APIDataSource private constructor(
*/ */
@Throws(IOException::class) @Throws(IOException::class)
private fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int { private fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int {
var readLength = readLength var readLengthCpy = readLength
if (readLength == 0) { if (readLengthCpy == 0) {
return 0 return 0
} }
if (bytesToRead != C.LENGTH_UNSET.toLong()) { if (bytesToRead != C.LENGTH_UNSET.toLong()) {
@ -314,9 +314,9 @@ open class APIDataSource private constructor(
if (bytesRemaining == 0L) { if (bytesRemaining == 0L) {
return C.RESULT_END_OF_INPUT return C.RESULT_END_OF_INPUT
} }
readLength = readLength.toLong().coerceAtMost(bytesRemaining).toInt() readLengthCpy = readLengthCpy.toLong().coerceAtMost(bytesRemaining).toInt()
} }
val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLength) val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLengthCpy)
if (read == -1) { if (read == -1) {
return C.RESULT_END_OF_INPUT return C.RESULT_END_OF_INPUT
} }

View File

@ -9,6 +9,9 @@ package org.moire.ultrasonic.playback
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import android.widget.Toast.LENGTH_SHORT
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS
@ -18,11 +21,17 @@ import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Rating
import androidx.media3.session.LibraryResult import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult import androidx.media3.session.SessionResult
import androidx.media3.session.SessionResult.RESULT_ERROR_BAD_VALUE
import androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN
import androidx.media3.session.SessionResult.RESULT_SUCCESS
import com.google.common.collect.ImmutableList import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.FutureCallback
import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -42,6 +51,7 @@ import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.MainThreadExecutor
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import timber.log.Timber import timber.log.Timber
@ -81,19 +91,18 @@ private const val MEDIA_SEARCH_SONG_ITEM = "MEDIA_SEARCH_SONG_ITEM"
private const val DISPLAY_LIMIT = 100 private const val DISPLAY_LIMIT = 100
private const val SEARCH_LIMIT = 10 private const val SEARCH_LIMIT = 10
private const val SEARCH_QUERY_PREFIX_COMPAT = "androidx://media3-session/playFromSearch" // List of available custom SessionCommands
private const val SEARCH_QUERY_PREFIX = "androidx://media3-session/setMediaUri" const val SESSION_CUSTOM_SET_RATING = "SESSION_CUSTOM_SET_RATING"
/** /**
* MediaBrowserService implementation for e.g. Android Auto * MediaBrowserService implementation for e.g. Android Auto
*/ */
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember") @Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
class AutoMediaBrowserCallback(var player: Player) : class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibraryService) :
MediaLibraryService.MediaLibrarySession.MediaLibrarySessionCallback, KoinComponent { MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
private val mediaPlayerController by inject<MediaPlayerController>() private val mediaPlayerController by inject<MediaPlayerController>()
private val activeServerProvider: ActiveServerProvider by inject() private val activeServerProvider: ActiveServerProvider by inject()
private val musicService = MusicServiceFactory.getMusicService()
private val serviceJob = Job() private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
@ -103,6 +112,7 @@ class AutoMediaBrowserCallback(var player: Player) :
private var randomSongsCache: List<Track>? = null private var randomSongsCache: List<Track>? = null
private var searchSongsCache: List<Track>? = null private var searchSongsCache: List<Track>? = null
private val musicService get() = MusicServiceFactory.getMusicService()
private val isOffline get() = ActiveServerProvider.isOffline() private val isOffline get() = ActiveServerProvider.isOffline()
private val useId3Tags get() = Settings.shouldUseId3Tags private val useId3Tags get() = Settings.shouldUseId3Tags
private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId
@ -154,6 +164,25 @@ class AutoMediaBrowserCallback(var player: Player) :
) )
} }
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
val connectionResult = super.onConnect(session, controller)
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
/*
* TODO: Currently we need to create a custom session command, see https://github.com/androidx/media/issues/107
* When this issue is fixed we should be able to remove this method again
*/
availableSessionCommands.add(SessionCommand(SESSION_CUSTOM_SET_RATING, Bundle()))
return MediaSession.ConnectionResult.accept(
availableSessionCommands.build(),
connectionResult.availablePlayerCommands
)
}
override fun onGetItem( override fun onGetItem(
session: MediaLibraryService.MediaLibrarySession, session: MediaLibraryService.MediaLibrarySession,
browser: MediaSession.ControllerInfo, browser: MediaSession.ControllerInfo,
@ -181,39 +210,120 @@ class AutoMediaBrowserCallback(var player: Player) :
return onLoadChildren(parentId) return onLoadChildren(parentId)
} }
private fun setMediaItemFromSearchQuery(query: String) { override fun onCustomCommand(
// Only accept query with pattern "play [Title]" or "[Title]"
// Where [Title]: must be exactly matched
// If no media with exact name found, play a random media instead
val mediaTitle =
if (query.startsWith("play ", ignoreCase = true)) {
query.drop(5)
} else {
query
}
playFromMediaId(mediaTitle)
}
override fun onSetMediaUri(
session: MediaSession, session: MediaSession,
controller: MediaSession.ControllerInfo, controller: MediaSession.ControllerInfo,
uri: Uri, customCommand: SessionCommand,
extras: Bundle args: Bundle
): Int { ): ListenableFuture<SessionResult> {
if (uri.toString().startsWith(SEARCH_QUERY_PREFIX) || var customCommandFuture: ListenableFuture<SessionResult>? = null
uri.toString().startsWith(SEARCH_QUERY_PREFIX_COMPAT)
) {
val searchQuery =
uri.getQueryParameter("query")
?: return SessionResult.RESULT_ERROR_NOT_SUPPORTED
setMediaItemFromSearchQuery(searchQuery)
return SessionResult.RESULT_SUCCESS when (customCommand.customAction) {
} else { SESSION_CUSTOM_SET_RATING -> {
return SessionResult.RESULT_ERROR_NOT_SUPPORTED /*
* It is currently not possible to edit a MediaItem after creation so the isRated value
* is stored in the track.starred value
* See https://github.com/androidx/media/issues/33
*/
val track = mediaPlayerController.currentPlayingLegacy?.track
if (track != null) {
customCommandFuture = onSetRating(
session,
controller,
HeartRating(!track.starred)
)
Futures.addCallback(
customCommandFuture,
object : FutureCallback<SessionResult> {
override fun onSuccess(result: SessionResult) {
track.starred = !track.starred
// This needs to be called on the main Thread
libraryService.onUpdateNotification(session)
}
override fun onFailure(t: Throwable) {
Toast.makeText(
mediaPlayerController.context,
"There was an error updating the rating",
LENGTH_SHORT
).show()
}
},
MainThreadExecutor()
)
}
}
else -> {
Timber.d(
"CustomCommand not recognized %s with extra %s",
customCommand.customAction,
customCommand.customExtras.toString()
)
}
} }
if (customCommandFuture != null)
return customCommandFuture
return super.onCustomCommand(session, controller, customCommand, args)
}
override fun onSetRating(
session: MediaSession,
controller: MediaSession.ControllerInfo,
rating: Rating
): ListenableFuture<SessionResult> {
if (session.player.currentMediaItem != null)
return onSetRating(
session,
controller,
session.player.currentMediaItem!!.mediaId,
rating
)
return super.onSetRating(session, controller, rating)
}
override fun onSetRating(
session: MediaSession,
controller: MediaSession.ControllerInfo,
mediaId: String,
rating: Rating
): ListenableFuture<SessionResult> {
return serviceScope.future {
if (rating is HeartRating) {
try {
if (rating.isHeart) {
musicService.star(mediaId, null, null)
} else {
musicService.unstar(mediaId, null, null)
}
} catch (all: Exception) {
Timber.e(all)
// TODO: Better handle exception
return@future SessionResult(RESULT_ERROR_UNKNOWN)
}
return@future SessionResult(RESULT_SUCCESS)
}
return@future SessionResult(RESULT_ERROR_BAD_VALUE)
}
}
/*
* For some reason the LocalConfiguration of MediaItem are stripped somewhere in ExoPlayer,
* and thereby customarily it is required to rebuild it..
* See also: https://stackoverflow.com/questions/70096715/adding-mediaitem-when-using-the-media3-library-caused-an-error
*/
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>
): ListenableFuture<MutableList<MediaItem>> {
val updatedMediaItems = mediaItems.map { mediaItem ->
mediaItem.buildUpon()
.setUri(mediaItem.requestMetadata.mediaUri)
.build()
}
return Futures.immediateFuture(updatedMediaItems.toMutableList())
} }
@Suppress("ReturnCount", "ComplexMethod") @Suppress("ReturnCount", "ComplexMethod")
@ -525,7 +635,7 @@ class AutoMediaBrowserCallback(var player: Player) :
return serviceScope.future { return serviceScope.future {
val albums = if (!isOffline && useId3Tags) { val albums = if (!isOffline && useId3Tags) {
callWithErrorHandling { musicService.getArtist(id, name, false) } callWithErrorHandling { musicService.getAlbumsOfArtist(id, name, false) }
} else { } else {
callWithErrorHandling { callWithErrorHandling {
musicService.getMusicDirectory(id, name, false).getAlbums() musicService.getMusicDirectory(id, name, false).getAlbums()
@ -554,8 +664,8 @@ class AutoMediaBrowserCallback(var player: Player) :
val songs = listSongsInMusicService(id, name) val songs = listSongsInMusicService(id, name)
if (songs != null) { if (songs != null) {
if (songs.getChildren(includeDirs = true, includeFiles = false).count() == 0 && if (songs.getChildren(includeDirs = true, includeFiles = false).isEmpty() &&
songs.getChildren(includeDirs = false, includeFiles = true).count() > 0 songs.getChildren(includeDirs = false, includeFiles = true).isNotEmpty()
) )
mediaItems.addPlayAllItem(listOf(MEDIA_ALBUM_ITEM, id, name).joinToString("|")) mediaItems.addPlayAllItem(listOf(MEDIA_ALBUM_ITEM, id, name).joinToString("|"))
@ -1096,6 +1206,7 @@ class AutoMediaBrowserCallback(var player: Player) :
album = track.album, album = track.album,
artist = track.artist, artist = track.artist,
genre = track.genre, genre = track.genre,
starred = track.starred
) )
} }
@ -1110,6 +1221,7 @@ class AutoMediaBrowserCallback(var player: Player) :
genre: String? = null, genre: String? = null,
sourceUri: Uri? = null, sourceUri: Uri? = null,
imageUri: Uri? = null, imageUri: Uri? = null,
starred: Boolean = false
): MediaItem { ): MediaItem {
val metadata = val metadata =
MediaMetadata.Builder() MediaMetadata.Builder()
@ -1117,6 +1229,7 @@ class AutoMediaBrowserCallback(var player: Player) :
.setTitle(title) .setTitle(title)
.setArtist(artist) .setArtist(artist)
.setGenre(genre) .setGenre(genre)
.setUserRating(HeartRating(starred))
.setFolderType(folderType) .setFolderType(folderType)
.setIsPlayable(isPlayable) .setIsPlayable(isPlayable)
.setArtworkUri(imageUri) .setArtworkUri(imageUri)

View File

@ -101,8 +101,8 @@ class CachedDataSource(
} }
private fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int { private fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int {
var readLength = readLength var readLengthCpy = readLength
if (readLength == 0) { if (readLengthCpy == 0) {
return 0 return 0
} }
if (bytesToRead != C.LENGTH_UNSET.toLong()) { if (bytesToRead != C.LENGTH_UNSET.toLong()) {
@ -110,9 +110,9 @@ class CachedDataSource(
if (bytesRemaining == 0L) { if (bytesRemaining == 0L) {
return C.RESULT_END_OF_INPUT return C.RESULT_END_OF_INPUT
} }
readLength = readLength.toLong().coerceAtMost(bytesRemaining).toInt() readLengthCpy = readLengthCpy.toLong().coerceAtMost(bytesRemaining).toInt()
} }
val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLength) val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLengthCpy)
if (read == -1) { if (read == -1) {
Timber.i("CachedDatasource: EndOfInput") Timber.i("CachedDatasource: EndOfInput")
return C.RESULT_END_OF_INPUT return C.RESULT_END_OF_INPUT
@ -134,15 +134,15 @@ class CachedDataSource(
@Suppress("ThrowsCount") @Suppress("ThrowsCount")
@Throws(HttpDataSourceException::class) @Throws(HttpDataSourceException::class)
private fun skipFully(bytesToSkip: Long, dataSpec: DataSpec) { private fun skipFully(bytesToSkip: Long, dataSpec: DataSpec) {
var bytesToSkip = bytesToSkip var bytesToSkipCpy = bytesToSkip
if (bytesToSkip == 0L) { if (bytesToSkipCpy == 0L) {
return return
} }
val skipBuffer = ByteArray(4096) val skipBuffer = ByteArray(4096)
try { try {
while (bytesToSkip > 0) { while (bytesToSkipCpy > 0) {
val readLength = val readLength =
bytesToSkip.coerceAtMost(skipBuffer.size.toLong()).toInt() bytesToSkipCpy.coerceAtMost(skipBuffer.size.toLong()).toInt()
val read = Util.castNonNull(responseByteStream).read(skipBuffer, 0, readLength) val read = Util.castNonNull(responseByteStream).read(skipBuffer, 0, readLength)
if (Thread.currentThread().isInterrupted) { if (Thread.currentThread().isInterrupted) {
throw InterruptedIOException() throw InterruptedIOException()
@ -154,7 +154,7 @@ class CachedDataSource(
HttpDataSourceException.TYPE_OPEN HttpDataSourceException.TYPE_OPEN
) )
} }
bytesToSkip -= read.toLong() bytesToSkipCpy -= read.toLong()
bytesTransferred(read) bytesTransferred(read)
} }
return return

View File

@ -50,7 +50,7 @@ class LegacyPlaylistManager : KoinComponent {
for (i in 0 until n) { for (i in 0 until n) {
val item = controller.getMediaItemAt(i) val item = controller.getMediaItemAt(i)
val file = mediaItemCache[item.mediaMetadata.mediaUri.toString()] val file = mediaItemCache[item.requestMetadata.toString()]
if (file != null) if (file != null)
_playlist.add(file) _playlist.add(file)
} }
@ -59,11 +59,11 @@ class LegacyPlaylistManager : KoinComponent {
} }
fun addToCache(item: MediaItem, file: DownloadFile) { fun addToCache(item: MediaItem, file: DownloadFile) {
mediaItemCache.put(item.mediaMetadata.mediaUri.toString(), file) mediaItemCache.put(item.requestMetadata.toString(), file)
} }
fun updateCurrentPlaying(item: MediaItem?) { fun updateCurrentPlaying(item: MediaItem?) {
currentPlaying = mediaItemCache[item?.mediaMetadata?.mediaUri.toString()] currentPlaying = mediaItemCache[item?.requestMetadata.toString()]
} }
@Synchronized @Synchronized

View File

@ -7,144 +7,88 @@
package org.moire.ultrasonic.playback package org.moire.ultrasonic.playback
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.IconCompat import androidx.media3.common.HeartRating
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.Assertions
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util import androidx.media3.session.CommandButton
import androidx.media3.session.MediaController import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaNotification import androidx.media3.session.MediaNotification
import androidx.media3.session.MediaNotification.ActionFactory import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.service.MediaPlayerController
/*
* This is a copy of DefaultMediaNotificationProvider.java with some small changes
* I have opened a bug https://github.com/androidx/media/issues/65 to make it easier to customize
* the icons and actions without creating our own copy of this class..
*/
@UnstableApi @UnstableApi
/* package */ class MediaNotificationProvider(context: Context) :
internal class MediaNotificationProvider(context: Context) : DefaultMediaNotificationProvider(context), KoinComponent {
MediaNotification.Provider {
private val context: Context = context.applicationContext
private val notificationManager: NotificationManager = Assertions.checkStateNotNull(
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
)
@Suppress("LongMethod") /*
override fun createNotification( * It is currently not possible to edit a MediaItem after creation so the isRated value
mediaController: MediaController, * is stored in the track.starred value. See https://github.com/androidx/media/issues/33
actionFactory: ActionFactory, * TODO: Once the bug is fixed remove this circular reference!
onNotificationChangedCallback: MediaNotification.Provider.Callback */
): MediaNotification { private val mediaPlayerController by inject<MediaPlayerController>()
ensureNotificationChannel()
val builder: NotificationCompat.Builder = NotificationCompat.Builder( override fun addNotificationActions(
context, mediaSession: MediaSession,
NOTIFICATION_CHANNEL_ID mediaButtons: MutableList<CommandButton>,
) builder: NotificationCompat.Builder,
// Skip to previous action. actionFactory: MediaNotification.ActionFactory
builder.addAction( ): IntArray {
actionFactory.createMediaAction( val tmp: MutableList<CommandButton> = mutableListOf()
IconCompat.createWithResource( /*
context, * TODO:
R.drawable.media3_notification_seek_to_previous * It is currently not possible to edit a MediaItem after creation so the isRated value
), * is stored in the track.starred value
context.getString(R.string.media3_controls_seek_to_previous_description), * See https://github.com/androidx/media/issues/33
ActionFactory.COMMAND_SKIP_TO_PREVIOUS */
) val rating = mediaPlayerController.currentPlayingLegacy?.track?.starred?.let {
) HeartRating(
if (mediaController.playbackState == Player.STATE_ENDED || it
!mediaController.playWhenReady
) {
// Play action.
builder.addAction(
actionFactory.createMediaAction(
IconCompat.createWithResource(context, R.drawable.media3_notification_play),
context.getString(R.string.media3_controls_play_description),
ActionFactory.COMMAND_PLAY
)
)
} else {
// Pause action.
builder.addAction(
actionFactory.createMediaAction(
IconCompat.createWithResource(context, R.drawable.media3_notification_pause),
context.getString(R.string.media3_controls_pause_description),
ActionFactory.COMMAND_PAUSE
)
) )
} }
// Skip to next action. if (rating is HeartRating) {
builder.addAction( tmp.add(
actionFactory.createMediaAction( CommandButton.Builder()
IconCompat.createWithResource(context, R.drawable.media3_notification_seek_to_next), .setDisplayName("Love")
context.getString(R.string.media3_controls_seek_to_next_description), .setIconResId(
ActionFactory.COMMAND_SKIP_TO_NEXT if (rating.isHeart) R.drawable.ic_star_full_dark
else R.drawable.ic_star_hollow_dark
)
.setSessionCommand(
SessionCommand(
SESSION_CUSTOM_SET_RATING,
HeartRating(rating.isHeart).toBundle()
)
)
.setExtras(HeartRating(rating.isHeart).toBundle())
.setEnabled(true)
.build()
) )
)
// Set metadata info in the notification.
val metadata = mediaController.mediaMetadata
builder.setContentTitle(metadata.title).setContentText(metadata.artist)
if (metadata.artworkData != null) {
val artworkBitmap =
BitmapFactory.decodeByteArray(metadata.artworkData, 0, metadata.artworkData!!.size)
builder.setLargeIcon(artworkBitmap)
} }
val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle() return super.addNotificationActions(
.setShowActionsInCompactView(0, 1, 2) mediaSession,
val notification: Notification = builder mediaButtons + tmp,
.setContentIntent(mediaController.sessionActivity) builder,
.setOnlyAlertOnce(true) actionFactory
.setSmallIcon(getSmallIconResId())
.setStyle(mediaStyle)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(false)
.build()
return MediaNotification(
NOTIFICATION_ID,
notification
) )
} }
override fun handleCustomAction( override fun getMediaButtons(
mediaController: MediaController, playerCommands: Player.Commands,
action: String, customLayout: MutableList<CommandButton>,
extras: Bundle playWhenReady: Boolean
) { ): MutableList<CommandButton> {
// We don't handle custom commands. val commands = super.getMediaButtons(playerCommands, customLayout, playWhenReady)
}
private fun ensureNotificationChannel() { commands.forEachIndexed { index, command ->
if (Util.SDK_INT < Build.VERSION_CODES.O || command.extras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, index)
notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null
) {
return
} }
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
NOTIFICATION_CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
)
channel.setShowBadge(false)
notificationManager.createNotificationChannel(channel) return commands
}
companion object {
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic"
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service"
private const val NOTIFICATION_ID = 3032
private fun getSmallIconResId(): Int {
return R.drawable.ic_stat_ultrasonic
}
} }
} }

View File

@ -11,9 +11,7 @@ import android.content.Intent
import android.os.Build import android.os.Build
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.C.CONTENT_TYPE_MUSIC
import androidx.media3.common.C.USAGE_MEDIA import androidx.media3.common.C.USAGE_MEDIA
import androidx.media3.common.MediaItem
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
@ -38,29 +36,12 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
private lateinit var mediaLibrarySession: MediaLibrarySession private lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var apiDataSource: APIDataSource.Factory private lateinit var apiDataSource: APIDataSource.Factory
private lateinit var librarySessionCallback: MediaLibrarySession.MediaLibrarySessionCallback private lateinit var librarySessionCallback: MediaLibrarySession.Callback
private var rxBusSubscription = CompositeDisposable() private var rxBusSubscription = CompositeDisposable()
private var isStarted = false private var isStarted = false
/*
* For some reason the LocalConfiguration of MediaItem are stripped somewhere in ExoPlayer,
* and thereby customarily it is required to rebuild it..
*/
private class CustomMediaItemFiller : MediaSession.MediaItemFiller {
override fun fillInLocalConfiguration(
session: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItem: MediaItem
): MediaItem {
// Again, set the Uri, so that it will get a LocalConfiguration
return mediaItem.buildUpon()
.setUri(mediaItem.mediaMetadata.mediaUri)
.build()
}
}
override fun onCreate() { override fun onCreate() {
Timber.i("onCreate called") Timber.i("onCreate called")
super.onCreate() super.onCreate()
@ -130,11 +111,10 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
player.experimentalSetOffloadSchedulingEnabled(true) player.experimentalSetOffloadSchedulingEnabled(true)
// Create browser interface // Create browser interface
librarySessionCallback = AutoMediaBrowserCallback(player) librarySessionCallback = AutoMediaBrowserCallback(player, this)
// This will need to use the AutoCalls // This will need to use the AutoCalls
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback) mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setMediaItemFiller(CustomMediaItemFiller())
.setSessionActivity(getPendingIntentForContent()) .setSessionActivity(getPendingIntentForContent())
.build() .build()
@ -171,7 +151,7 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
private fun getAudioAttributes(): AudioAttributes { private fun getAudioAttributes(): AudioAttributes {
return AudioAttributes.Builder() return AudioAttributes.Builder()
.setUsage(USAGE_MEDIA) .setUsage(USAGE_MEDIA)
.setContentType(CONTENT_TYPE_MUSIC) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build() .build()
} }
} }

View File

@ -43,7 +43,6 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
// Old style TimeLimitedCache // Old style TimeLimitedCache
private val cachedMusicDirectories: LRUCache<String, TimeLimitedCache<MusicDirectory?>> private val cachedMusicDirectories: LRUCache<String, TimeLimitedCache<MusicDirectory?>>
private val cachedArtist: LRUCache<String, TimeLimitedCache<List<Album>>>
private val cachedAlbum: LRUCache<String, TimeLimitedCache<MusicDirectory?>> private val cachedAlbum: LRUCache<String, TimeLimitedCache<MusicDirectory?>>
private val cachedUserInfo: LRUCache<String, TimeLimitedCache<UserInfo?>> private val cachedUserInfo: LRUCache<String, TimeLimitedCache<UserInfo?>>
private val cachedLicenseValid = TimeLimitedCache<Boolean>(120, TimeUnit.SECONDS) private val cachedLicenseValid = TimeLimitedCache<Boolean>(120, TimeUnit.SECONDS)
@ -53,7 +52,8 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
private val cachedGenres = TimeLimitedCache<List<Genre>>(10 * 3600, TimeUnit.SECONDS) private val cachedGenres = TimeLimitedCache<List<Genre>>(10 * 3600, TimeUnit.SECONDS)
// New Room Database // New Room Database
private var cachedArtists = metaDatabase.artistsDao() private var cachedArtists = metaDatabase.artistDao()
private var cachedAlbums = metaDatabase.albumDao()
private var cachedIndexes = metaDatabase.indexDao() private var cachedIndexes = metaDatabase.indexDao()
private val cachedMusicFolders = metaDatabase.musicFoldersDao() private val cachedMusicFolders = metaDatabase.musicFoldersDao()
@ -103,10 +103,10 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
var indexes: List<Index> var indexes: List<Index>
if (musicFolderId == null) { indexes = if (musicFolderId == null) {
indexes = cachedIndexes.get() cachedIndexes.get()
} else { } else {
indexes = cachedIndexes.get(musicFolderId) cachedIndexes.get(musicFolderId)
} }
if (indexes.isEmpty()) { if (indexes.isEmpty()) {
@ -120,14 +120,15 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
@Throws(Exception::class) @Throws(Exception::class)
override fun getArtists(refresh: Boolean): List<Artist> { override fun getArtists(refresh: Boolean): List<Artist> {
checkSettingsChanged() checkSettingsChanged()
if (refresh) { if (refresh) {
cachedArtists.clear() cachedArtists.clear()
} }
var result = cachedArtists.get() var result = cachedArtists.get()
if (result.isEmpty()) { if (result.isEmpty()) {
result = musicService.getArtists(refresh) result = musicService.getArtists(refresh)
cachedArtist.clear()
cachedArtists.set(result) cachedArtists.set(result)
} }
return result return result
@ -149,21 +150,29 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
return dir return dir
} }
/*
* Retrieves all albums of the provided artist.
* Cached in the RoomDB
*/
@Throws(Exception::class) @Throws(Exception::class)
override fun getArtist(id: String, name: String?, refresh: Boolean): override fun getAlbumsOfArtist(id: String, name: String?, refresh: Boolean):
List<Album> { List<Album> {
checkSettingsChanged() checkSettingsChanged()
var cache = if (refresh) null else cachedArtist[id]
var dir = cache?.get() var result: List<Album>
if (dir == null) {
dir = musicService.getArtist(id, name, refresh) result = if (refresh) {
cache = TimeLimitedCache( cachedAlbums.clearByArtist(id)
Settings.directoryCacheTime.toLong(), TimeUnit.SECONDS listOf()
) } else {
cache.set(dir) cachedAlbums.byArtist(id)
cachedArtist.put(id, cache)
} }
return dir
if (result.isEmpty()) {
result = musicService.getAlbumsOfArtist(id, name, refresh)
cachedAlbums.upsert(result)
}
return result
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -326,7 +335,8 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
if (!Util.equals(newUrl, restUrl) || !Util.equals(cachedMusicFolderId, newFolderId)) { if (!Util.equals(newUrl, restUrl) || !Util.equals(cachedMusicFolderId, newFolderId)) {
// Switch database // Switch database
metaDatabase = activeServerProvider.getActiveMetaDatabase() metaDatabase = activeServerProvider.getActiveMetaDatabase()
cachedArtists = metaDatabase.artistsDao() cachedArtists = metaDatabase.artistDao()
cachedAlbums = metaDatabase.albumDao()
cachedIndexes = metaDatabase.indexDao() cachedIndexes = metaDatabase.indexDao()
// Clear in memory caches // Clear in memory caches
@ -335,7 +345,6 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
cachedPlaylists.clear() cachedPlaylists.clear()
cachedGenres.clear() cachedGenres.clear()
cachedAlbum.clear() cachedAlbum.clear()
cachedArtist.clear()
cachedUserInfo.clear() cachedUserInfo.clear()
// Set the cache keys // Set the cache keys
@ -472,7 +481,6 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
init { init {
cachedMusicDirectories = LRUCache(MUSIC_DIR_CACHE_SIZE) cachedMusicDirectories = LRUCache(MUSIC_DIR_CACHE_SIZE)
cachedArtist = LRUCache(MUSIC_DIR_CACHE_SIZE)
cachedAlbum = LRUCache(MUSIC_DIR_CACHE_SIZE) cachedAlbum = LRUCache(MUSIC_DIR_CACHE_SIZE)
cachedUserInfo = LRUCache(MUSIC_DIR_CACHE_SIZE) cachedUserInfo = LRUCache(MUSIC_DIR_CACHE_SIZE)
} }

View File

@ -14,6 +14,7 @@ import java.util.PriorityQueue
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.MetaDatabase
import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.playback.LegacyPlaylistManager import org.moire.ultrasonic.playback.LegacyPlaylistManager
@ -402,6 +403,14 @@ class Downloader(
downloadFile.completeFile downloadFile.completeFile
) )
} }
// Hidden feature: If track is toggled between pinned/saved, refresh the metadata..
try {
downloadFile.track.cacheMetadata()
} catch (ignore: Exception) {
Timber.w(ignore)
}
downloadFile.status.postValue(newStatus) downloadFile.status.postValue(newStatus)
return return
} }
@ -457,8 +466,10 @@ class Downloader(
) )
} }
if (downloadFile.track.artistId != null) { try {
cacheMetadata(downloadFile.track.artistId!!) downloadFile.track.cacheMetadata()
} catch (ignore: Exception) {
Timber.w(ignore)
} }
downloadAndSaveCoverArt() downloadAndSaveCoverArt()
@ -510,13 +521,35 @@ class Downloader(
return String.format(Locale.ROOT, "DownloadTask (%s)", downloadFile.track) return String.format(Locale.ROOT, "DownloadTask (%s)", downloadFile.track)
} }
private fun cacheMetadata(artistId: String) { private fun Track.cacheMetadata() {
// TODO: Right now it's caching the track artist. if (artistId.isNullOrEmpty()) return
// Once the albums are cached in db, we should retrieve the album,
// and then cache the album artist. val onlineDB = activeServerProvider.getActiveMetaDatabase()
if (artistId.isEmpty()) return val offlineDB = activeServerProvider.offlineMetaDatabase
var artist: Artist? =
activeServerProvider.getActiveMetaDatabase().artistsDao().get(artistId) cacheArtist(onlineDB, offlineDB, artistId!!)
// Now cache the album
if (albumId?.isNotEmpty() == true) {
// This is a cached call
val albums = musicService.getAlbumsOfArtist(artistId!!, null, false)
val album = albums.find { it.id == albumId }
if (album != null) {
offlineDB.albumDao().insert(album)
// If the album is a Compilation, also cache the Album artist
if (album.artistId != null && album.artistId != artistId)
cacheArtist(onlineDB, offlineDB, album.artistId!!)
}
}
// Now cache the track data
offlineDB.trackDao().insert(this)
}
private fun cacheArtist(onlineDB: MetaDatabase, offlineDB: MetaDatabase, artistId: String) {
var artist: Artist? = onlineDB.artistDao().get(artistId)
// If we are downloading a new album, and the user has not visited the Artists list // If we are downloading a new album, and the user has not visited the Artists list
// recently, then the artist won't be in the database. // recently, then the artist won't be in the database.
@ -527,9 +560,9 @@ class Downloader(
} }
} }
// If we have found an artist, catch it. // If we have found an artist, cache it.
if (artist != null) { if (artist != null) {
activeServerProvider.offlineMetaDatabase.artistsDao().insert(artist) offlineDB.artistDao().insert(artist)
} }
} }

View File

@ -9,13 +9,18 @@ package org.moire.ultrasonic.service
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.widget.Toast
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Timeline import androidx.media3.common.Timeline
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionResult
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.FutureCallback
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.MoreExecutors
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -34,6 +39,7 @@ import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.MainThreadExecutor
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import timber.log.Timber import timber.log.Timber
@ -579,23 +585,30 @@ class MediaPlayerController(
if (legacyPlaylistManager.currentPlaying == null) return if (legacyPlaylistManager.currentPlaying == null) return
val song = legacyPlaylistManager.currentPlaying!!.track val song = legacyPlaylistManager.currentPlaying!!.track
Thread { controller?.setRating(
val musicService = getMusicService() HeartRating(!song.starred)
try { ).let {
if (song.starred) { Futures.addCallback(
musicService.unstar(song.id, null, null) it,
} else { object : FutureCallback<SessionResult> {
musicService.star(song.id, null, null) override fun onSuccess(result: SessionResult?) {
} // Trigger an update
} catch (all: Exception) { // TODO Update Metadata of MediaItem...
Timber.e(all) // localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
} song.starred = !song.starred
}.start() }
// Trigger an update override fun onFailure(t: Throwable) {
// TODO Update Metadata of MediaItem... Toast.makeText(
// localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying) context,
song.starred = !song.starred "There was an error updating the rating",
Toast.LENGTH_SHORT
).show()
}
},
MainThreadExecutor()
)
}
} }
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions @Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
@ -659,16 +672,22 @@ fun Track.toMediaItem(): MediaItem {
val bitrate = Settings.maxBitRate val bitrate = Settings.maxBitRate
val uri = "$id|$bitrate|$filePath" val uri = "$id|$bitrate|$filePath"
val rmd = MediaItem.RequestMetadata.Builder()
.setMediaUri(uri.toUri())
.build()
val metadata = MediaMetadata.Builder() val metadata = MediaMetadata.Builder()
metadata.setTitle(title) metadata.setTitle(title)
.setArtist(artist) .setArtist(artist)
.setAlbumTitle(album) .setAlbumTitle(album)
.setMediaUri(uri.toUri())
.setAlbumArtist(artist) .setAlbumArtist(artist)
.setUserRating(HeartRating(starred))
.build()
val mediaItem = MediaItem.Builder() val mediaItem = MediaItem.Builder()
.setUri(uri) .setUri(uri)
.setMediaId(id) .setMediaId(id)
.setRequestMetadata(rmd)
.setMediaMetadata(metadata.build()) .setMediaMetadata(metadata.build())
return mediaItem.build() return mediaItem.build()

View File

@ -59,7 +59,7 @@ interface MusicService {
fun getMusicDirectory(id: String, name: String?, refresh: Boolean): MusicDirectory fun getMusicDirectory(id: String, name: String?, refresh: Boolean): MusicDirectory
@Throws(Exception::class) @Throws(Exception::class)
fun getArtist(id: String, name: String?, refresh: Boolean): List<Album> fun getAlbumsOfArtist(id: String, name: String?, refresh: Boolean): List<Album>
@Throws(Exception::class) @Throws(Exception::class)
fun getAlbum(id: String, name: String?, refresh: Boolean): MusicDirectory fun getAlbum(id: String, name: String?, refresh: Boolean): MusicDirectory

View File

@ -1,6 +1,6 @@
/* /*
* OfflineMusicService.kt * OfflineMusicService.kt
* Copyright (C) 2009-2021 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@ -23,6 +23,7 @@ import java.util.regex.Pattern
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.MetaDatabase
import org.moire.ultrasonic.domain.Album import org.moire.ultrasonic.domain.Album
import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.ArtistOrIndex import org.moire.ultrasonic.domain.ArtistOrIndex
@ -43,6 +44,7 @@ import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.domain.UserInfo import org.moire.ultrasonic.domain.UserInfo
import org.moire.ultrasonic.util.AbstractFile import org.moire.ultrasonic.util.AbstractFile
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator
import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Storage import org.moire.ultrasonic.util.Storage
import org.moire.ultrasonic.util.Util.safeClose import org.moire.ultrasonic.util.Util.safeClose
@ -52,12 +54,19 @@ import timber.log.Timber
class OfflineMusicService : MusicService, KoinComponent { class OfflineMusicService : MusicService, KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject() private val activeServerProvider: ActiveServerProvider by inject()
private var metaDatabase: MetaDatabase = activeServerProvider.getActiveMetaDatabase()
// New Room Database
private var cachedArtists = metaDatabase.artistDao()
private var cachedAlbums = metaDatabase.albumDao()
private var cachedTracks = metaDatabase.trackDao()
override fun getIndexes(musicFolderId: String?, refresh: Boolean): List<Index> { override fun getIndexes(musicFolderId: String?, refresh: Boolean): List<Index> {
val indexes: MutableList<Index> = ArrayList() val indexes: MutableList<Index> = ArrayList()
val root = FileUtil.musicDirectory val root = FileUtil.musicDirectory
for (file in FileUtil.listFiles(root)) { for (file in FileUtil.listFiles(root)) {
if (file.isDirectory) { if (file.isDirectory) {
val index = Index(file.path) val index = Index(id = file.path)
index.id = file.path index.id = file.path
index.index = file.name.substring(0, 1) index.index = file.name.substring(0, 1)
index.name = file.name index.name = file.name
@ -97,6 +106,13 @@ class OfflineMusicService : MusicService, KoinComponent {
return indexes return indexes
} }
@Throws(OfflineException::class)
override fun getArtists(refresh: Boolean): List<Artist> {
val result = cachedArtists.get()
return result
}
/* /*
* Especially when dealing with indexes, this method can return Albums, Entries or a mix of both! * Especially when dealing with indexes, this method can return Albums, Entries or a mix of both!
*/ */
@ -312,7 +328,8 @@ class OfflineMusicService : MusicService, KoinComponent {
offset: Int, offset: Int,
musicFolderId: String? musicFolderId: String?
): List<Album> { ): List<Album> {
throw OfflineException("getAlbumList2 isn't available in offline mode") // TODO: Implement filtering by musicFolder?
return cachedAlbums.get(size, offset)
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -450,20 +467,39 @@ class OfflineMusicService : MusicService, KoinComponent {
override fun isLicenseValid(): Boolean = true override fun isLicenseValid(): Boolean = true
@Throws(OfflineException::class) @Throws(Exception::class)
override fun getArtists(refresh: Boolean): List<Artist> { override fun getAlbumsOfArtist(id: String, name: String?, refresh: Boolean):
throw OfflineException("getArtists isn't available in offline mode")
}
@Throws(OfflineException::class)
override fun getArtist(id: String, name: String?, refresh: Boolean):
List<Album> { List<Album> {
throw OfflineException("getArtist isn't available in offline mode") val directAlbums = cachedAlbums.byArtist(id)
// The direct albums won't contain any compilations that the artist has participated in
// We need to fetch the tracks of the artist and then gather the compilation albums from that.
val tracks = cachedTracks.byArtist(id)
val albumIds = tracks.map {
it.albumId
}.distinct().filterNotNull()
val compilationAlbums = albumIds.map {
cachedAlbums.get(it)
}
return directAlbums.plus(compilationAlbums).distinct()
} }
@Throws(OfflineException::class) @Throws(OfflineException::class)
override fun getAlbum(id: String, name: String?, refresh: Boolean): MusicDirectory { override fun getAlbum(id: String, name: String?, refresh: Boolean): MusicDirectory {
throw OfflineException("getAlbum isn't available in offline mode")
Timber.i("Starting album query...")
val list = cachedTracks
.byAlbum(id)
.sortedWith(EntryByDiscAndTrackComparator())
val dir = MusicDirectory()
dir.addAll(list)
Timber.i("Returning query.")
return dir
} }
@Throws(OfflineException::class) @Throws(OfflineException::class)

View File

@ -1,6 +1,6 @@
/* /*
* RestMusicService.kt * RESTMusicService.kt
* Copyright (C) 2009-2021 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@ -78,7 +78,7 @@ open class RESTMusicService(
): List<MusicFolder> { ): List<MusicFolder> {
val response = API.getMusicFolders().execute().throwOnFailure() val response = API.getMusicFolders().execute().throwOnFailure()
return response.body()!!.musicFolders.toDomainEntityList() return response.body()!!.musicFolders.toDomainEntityList(activeServerId)
} }
/** /**
@ -91,7 +91,10 @@ open class RESTMusicService(
): List<Index> { ): List<Index> {
val response = API.getIndexes(musicFolderId, null).execute().throwOnFailure() val response = API.getIndexes(musicFolderId, null).execute().throwOnFailure()
return response.body()!!.indexes.toIndexList(musicFolderId) return response.body()!!.indexes.toIndexList(
ActiveServerProvider.getActiveServerId(),
musicFolderId
)
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -100,7 +103,7 @@ open class RESTMusicService(
): List<Artist> { ): List<Artist> {
val response = API.getArtists(null).execute().throwOnFailure() val response = API.getArtists(null).execute().throwOnFailure()
return response.body()!!.indexes.toArtistList() return response.body()!!.indexes.toArtistList(activeServerId)
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -137,18 +140,18 @@ open class RESTMusicService(
): MusicDirectory { ): MusicDirectory {
val response = API.getMusicDirectory(id).execute().throwOnFailure() val response = API.getMusicDirectory(id).execute().throwOnFailure()
return response.body()!!.musicDirectory.toDomainEntity() return response.body()!!.musicDirectory.toDomainEntity(activeServerId)
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun getArtist( override fun getAlbumsOfArtist(
id: String, id: String,
name: String?, name: String?,
refresh: Boolean refresh: Boolean
): List<Album> { ): List<Album> {
val response = API.getArtist(id).execute().throwOnFailure() val response = API.getArtist(id).execute().throwOnFailure()
return response.body()!!.artist.toDomainEntityList() return response.body()!!.artist.toDomainEntityList(activeServerId)
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -159,7 +162,7 @@ open class RESTMusicService(
): MusicDirectory { ): MusicDirectory {
val response = API.getAlbum(id).execute().throwOnFailure() val response = API.getAlbum(id).execute().throwOnFailure()
return response.body()!!.album.toMusicDirectoryDomainEntity() return response.body()!!.album.toMusicDirectoryDomainEntity(activeServerId)
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -189,7 +192,7 @@ open class RESTMusicService(
API.search(null, null, null, criteria.query, criteria.songCount, null, null) API.search(null, null, null, criteria.query, criteria.songCount, null, null)
.execute().throwOnFailure() .execute().throwOnFailure()
return response.body()!!.searchResult.toDomainEntity() return response.body()!!.searchResult.toDomainEntity(activeServerId)
} }
/** /**
@ -205,7 +208,7 @@ open class RESTMusicService(
criteria.songCount, null criteria.songCount, null
).execute().throwOnFailure() ).execute().throwOnFailure()
return response.body()!!.searchResult.toDomainEntity() return response.body()!!.searchResult.toDomainEntity(activeServerId)
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -218,7 +221,7 @@ open class RESTMusicService(
criteria.songCount, null criteria.songCount, null
).execute().throwOnFailure() ).execute().throwOnFailure()
return response.body()!!.searchResult.toDomainEntity() return response.body()!!.searchResult.toDomainEntity(activeServerId)
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -228,7 +231,7 @@ open class RESTMusicService(
): MusicDirectory { ): MusicDirectory {
val response = API.getPlaylist(id).execute().throwOnFailure() val response = API.getPlaylist(id).execute().throwOnFailure()
val playlist = response.body()!!.playlist.toMusicDirectoryDomainEntity() val playlist = response.body()!!.playlist.toMusicDirectoryDomainEntity(activeServerId)
savePlaylist(name, playlist) savePlaylist(name, playlist)
return playlist return playlist
@ -319,7 +322,7 @@ open class RESTMusicService(
"skipped" != podcastEntry.status && "skipped" != podcastEntry.status &&
"error" != podcastEntry.status "error" != podcastEntry.status
) { ) {
val entry = podcastEntry.toTrackEntity() val entry = podcastEntry.toTrackEntity(activeServerId)
entry.track = null entry.track = null
musicDirectory.add(entry) musicDirectory.add(entry)
} }
@ -363,7 +366,7 @@ open class RESTMusicService(
musicFolderId musicFolderId
).execute().throwOnFailure() ).execute().throwOnFailure()
return response.body()!!.albumList.toDomainEntityList() return response.body()!!.albumList.toDomainEntityList(activeServerId)
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -383,7 +386,7 @@ open class RESTMusicService(
musicFolderId musicFolderId
).execute().throwOnFailure() ).execute().throwOnFailure()
return response.body()!!.albumList.toDomainEntityList() return response.body()!!.albumList.toDomainEntityList(activeServerId)
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -399,7 +402,7 @@ open class RESTMusicService(
).execute().throwOnFailure() ).execute().throwOnFailure()
val result = MusicDirectory() val result = MusicDirectory()
result.addAll(response.body()!!.songsList.toDomainEntityList()) result.addAll(response.body()!!.songsList.toDomainEntityList(activeServerId))
return result return result
} }
@ -408,14 +411,14 @@ open class RESTMusicService(
override fun getStarred(): SearchResult { override fun getStarred(): SearchResult {
val response = API.getStarred(null).execute().throwOnFailure() val response = API.getStarred(null).execute().throwOnFailure()
return response.body()!!.starred.toDomainEntity() return response.body()!!.starred.toDomainEntity(activeServerId)
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun getStarred2(): SearchResult { override fun getStarred2(): SearchResult {
val response = API.getStarred2(null).execute().throwOnFailure() val response = API.getStarred2(null).execute().throwOnFailure()
return response.body()!!.starred2.toDomainEntity() return response.body()!!.starred2.toDomainEntity(activeServerId)
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -546,7 +549,7 @@ open class RESTMusicService(
): List<Share> { ): List<Share> {
val response = API.getShares().execute().throwOnFailure() val response = API.getShares().execute().throwOnFailure()
return response.body()!!.shares.toDomainEntitiesList() return response.body()!!.shares.toDomainEntitiesList(activeServerId)
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -567,7 +570,7 @@ open class RESTMusicService(
val response = API.getSongsByGenre(genre, count, offset, null).execute().throwOnFailure() val response = API.getSongsByGenre(genre, count, offset, null).execute().throwOnFailure()
val result = MusicDirectory() val result = MusicDirectory()
result.addAll(response.body()!!.songsList.toDomainEntityList()) result.addAll(response.body()!!.songsList.toDomainEntityList(activeServerId))
return result return result
} }
@ -601,7 +604,7 @@ open class RESTMusicService(
override fun getBookmarks(): List<Bookmark> { override fun getBookmarks(): List<Bookmark> {
val response = API.getBookmarks().execute().throwOnFailure() val response = API.getBookmarks().execute().throwOnFailure()
return response.body()!!.bookmarkList.toDomainEntitiesList() return response.body()!!.bookmarkList.toDomainEntitiesList(activeServerId)
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -626,7 +629,7 @@ open class RESTMusicService(
val response = API.getVideos().execute().throwOnFailure() val response = API.getVideos().execute().throwOnFailure()
val musicDirectory = MusicDirectory() val musicDirectory = MusicDirectory()
musicDirectory.addAll(response.body()!!.videosList.toDomainEntityList()) musicDirectory.addAll(response.body()!!.videosList.toDomainEntityList(activeServerId))
return musicDirectory return musicDirectory
} }
@ -639,7 +642,7 @@ open class RESTMusicService(
): List<Share> { ): List<Share> {
val response = API.createShare(ids, description, expires).execute().throwOnFailure() val response = API.createShare(ids, description, expires).execute().throwOnFailure()
return response.body()!!.shares.toDomainEntitiesList() return response.body()!!.shares.toDomainEntitiesList(activeServerId)
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -663,6 +666,9 @@ open class RESTMusicService(
API.updateShare(id, description, expiresValue).execute().throwOnFailure() API.updateShare(id, description, expiresValue).execute().throwOnFailure()
} }
private val activeServerId: Int
get() = ActiveServerProvider.getActiveServerId()
init { init {
// The client will notice if the minimum supported API version has changed // The client will notice if the minimum supported API version has changed
// By registering a callback we ensure this info is saved in the database as well // By registering a callback we ensure this info is saved in the database as well

View File

@ -269,7 +269,7 @@ class DownloadHandler(
return return
} }
val musicService = getMusicService() val musicService = getMusicService()
val artist = musicService.getArtist(id, "", false) val artist = musicService.getAlbumsOfArtist(id, "", false)
for ((id1) in artist) { for ((id1) in artist) {
val albumDirectory = musicService.getAlbum( val albumDirectory = musicService.getAlbum(
id1, id1,

View File

@ -86,6 +86,7 @@ object Constants {
const val PREFERENCES_KEY_INCREMENT_TIME = "incrementTime" const val PREFERENCES_KEY_INCREMENT_TIME = "incrementTime"
const val PREFERENCES_KEY_SHOW_NOW_PLAYING_DETAILS = "showNowPlayingDetails" const val PREFERENCES_KEY_SHOW_NOW_PLAYING_DETAILS = "showNowPlayingDetails"
const val PREFERENCES_KEY_ID3_TAGS = "useId3Tags" const val PREFERENCES_KEY_ID3_TAGS = "useId3Tags"
const val PREFERENCES_KEY_ID3_TAGS_OFFLINE = "useId3TagsOffline"
const val PREFERENCES_KEY_SHOW_ARTIST_PICTURE = "showArtistPicture" const val PREFERENCES_KEY_SHOW_ARTIST_PICTURE = "showArtistPicture"
const val PREFERENCES_KEY_CHAT_REFRESH_INTERVAL = "chatRefreshInterval" const val PREFERENCES_KEY_CHAT_REFRESH_INTERVAL = "chatRefreshInterval"
const val PREFERENCES_KEY_DIRECTORY_CACHE_TIME = "directoryCacheTime" const val PREFERENCES_KEY_DIRECTORY_CACHE_TIME = "directoryCacheTime"
@ -104,6 +105,7 @@ object Constants {
const val PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE = "pauseOnBluetoothDevice" const val PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE = "pauseOnBluetoothDevice"
const val PREFERENCES_KEY_DEBUG_LOG_TO_FILE = "debugLogToFile" const val PREFERENCES_KEY_DEBUG_LOG_TO_FILE = "debugLogToFile"
const val PREFERENCES_KEY_OVERRIDE_LANGUAGE = "overrideLanguage" const val PREFERENCES_KEY_OVERRIDE_LANGUAGE = "overrideLanguage"
const val PREFERENCES_FIRST_INSTALLED_VERSION = "firstInstalledVersion"
const val PREFERENCE_VALUE_ALL = 0 const val PREFERENCE_VALUE_ALL = 0
const val PREFERENCE_VALUE_A2DP = 1 const val PREFERENCE_VALUE_A2DP = 1
const val PREFERENCE_VALUE_DISABLED = 2 const val PREFERENCE_VALUE_DISABLED = 2

View File

@ -0,0 +1,22 @@
/*
* MainThreadExecutor.java
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.os.Handler
import android.os.Looper
import java.util.concurrent.Executor
/*
* Executor for running Futures on the main thread
* See https://stackoverflow.com/questions/52642246/how-to-get-executor-for-main-thread-on-api-level-28
*/
class MainThreadExecutor : Executor {
private val handler = Handler(Looper.getMainLooper())
override fun execute(r: Runnable) {
handler.post(r)
}
}

View File

@ -1,6 +1,6 @@
/* /*
* Settings.kt * Settings.kt
* Copyright (C) 2009-2021 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@ -13,7 +13,6 @@ import androidx.preference.PreferenceManager
import java.util.regex.Pattern import java.util.regex.Pattern
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.data.ActiveServerProvider
/** /**
* Contains convenience functions for reading and writing preferences * Contains convenience functions for reading and writing preferences
@ -131,6 +130,7 @@ object Settings {
@JvmStatic @JvmStatic
var mediaButtonsEnabled var mediaButtonsEnabled
by BooleanSetting(Constants.PREFERENCES_KEY_MEDIA_BUTTONS, true) by BooleanSetting(Constants.PREFERENCES_KEY_MEDIA_BUTTONS, true)
var resumePlayOnHeadphonePlug var resumePlayOnHeadphonePlug
by BooleanSetting(R.string.setting_keys_resume_play_on_headphones_plug, true) by BooleanSetting(R.string.setting_keys_resume_play_on_headphones_plug, true)
@ -160,9 +160,14 @@ object Settings {
var showNowPlayingDetails var showNowPlayingDetails
by BooleanSetting(Constants.PREFERENCES_KEY_SHOW_NOW_PLAYING_DETAILS, false) by BooleanSetting(Constants.PREFERENCES_KEY_SHOW_NOW_PLAYING_DETAILS, false)
// Normally you don't need to use these Settings directly,
// use ActiveServerProvider.isID3Enabled() instead
@JvmStatic @JvmStatic
var shouldUseId3Tags var shouldUseId3Tags by BooleanSetting(Constants.PREFERENCES_KEY_ID3_TAGS, false)
by BooleanSetting(Constants.PREFERENCES_KEY_ID3_TAGS, false)
// See comment above.
@JvmStatic
var useId3TagsOffline by BooleanSetting(Constants.PREFERENCES_KEY_ID3_TAGS_OFFLINE, false)
var activeServer by IntSetting(Constants.PREFERENCES_KEY_SERVER_INSTANCE, -1) var activeServer by IntSetting(Constants.PREFERENCES_KEY_SERVER_INSTANCE, -1)
@ -170,15 +175,8 @@ object Settings {
var firstRunExecuted by BooleanSetting(Constants.PREFERENCES_KEY_FIRST_RUN_EXECUTED, false) var firstRunExecuted by BooleanSetting(Constants.PREFERENCES_KEY_FIRST_RUN_EXECUTED, false)
val shouldShowArtistPicture: Boolean val shouldShowArtistPicture
get() { by BooleanSetting(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE, false)
val preferences = preferences
val isOffline = ActiveServerProvider.isOffline()
val isId3Enabled = preferences.getBoolean(Constants.PREFERENCES_KEY_ID3_TAGS, false)
val shouldShowArtistPicture =
preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE, false)
return !isOffline && isId3Enabled && shouldShowArtistPicture
}
@JvmStatic @JvmStatic
var chatRefreshInterval by StringIntSetting( var chatRefreshInterval by StringIntSetting(
@ -253,6 +251,9 @@ object Settings {
var useHwOffload by BooleanSetting(Constants.PREFERENCES_KEY_HARDWARE_OFFLOAD, false) var useHwOffload by BooleanSetting(Constants.PREFERENCES_KEY_HARDWARE_OFFLOAD, false)
@JvmStatic
var firstInstalledVersion by IntSetting(Constants.PREFERENCES_FIRST_INSTALLED_VERSION, 0)
// TODO: Remove in December 2022 // TODO: Remove in December 2022
fun migrateFeatureStorage() { fun migrateFeatureStorage() {
val sp = appContext.getSharedPreferences("feature_flags", Context.MODE_PRIVATE) val sp = appContext.getSharedPreferences("feature_flags", Context.MODE_PRIVATE)

View File

@ -1,9 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ripple <ripple
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" android:color="?android:colorControlHighlight">
android:color="?android:colorControlHighlight"
tools:targetApi="lollipop">
<item android:id="@android:id/mask"> <item android:id="@android:id/mask">
<shape android:shape="rectangle"> <shape android:shape="rectangle">

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFF"
android:pathData="m12,3.8438c-4.9703,0 -9,4.0292 -9,9l0,5.0625c0,1.2426 1.0074,2.25 2.25,2.25 1.2426,0 2.25,-1.0074 2.25,-2.25l0,-3.375c0,-1.2426 -1.0074,-2.25 -2.25,-2.25 -0.4067,0 -0.783,0.1164 -1.1121,0.3049C4.2752,8.3573 7.7379,4.9688 12,4.9688 16.2621,4.9688 19.7242,8.3573 19.8621,12.5861 19.5336,12.3977 19.1567,12.2813 18.75,12.2813c-1.2426,0 -2.25,1.0074 -2.25,2.25l0,3.375c0,1.2426 1.0074,2.25 2.25,2.25 1.2426,0 2.25,-1.0074 2.25,-2.25L21,12.8438C21,7.8729 16.9708,3.8438 12,3.8438ZM5.25,13.4063c0.621,0 1.125,0.504 1.125,1.125l0,3.375c0,0.621 -0.504,1.125 -1.125,1.125 -0.621,0 -1.125,-0.504 -1.125,-1.125l0,-3.375c0,-0.621 0.504,-1.125 1.125,-1.125zM19.875,17.9063c0,0.621 -0.504,1.125 -1.125,1.125 -0.621,0 -1.125,-0.504 -1.125,-1.125l0,-3.375c0,-0.621 0.504,-1.125 1.125,-1.125 0.621,0 1.125,0.504 1.125,1.125z"/>
</vector>

View File

@ -1,9 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ripple <ripple
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" android:color="?android:colorControlHighlight">
android:color="?android:colorControlHighlight"
tools:targetApi="lollipop">
<item android:id="@android:id/mask"> <item android:id="@android:id/mask">
<shape android:shape="rectangle"> <shape android:shape="rectangle">

View File

@ -1,9 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ripple <ripple
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" android:color="?android:colorControlHighlight">
android:color="?android:colorControlHighlight"
tools:targetApi="lollipop">
<item android:id="@android:id/mask"> <item android:id="@android:id/mask">
<shape android:shape="oval"> <shape android:shape="oval">

View File

@ -17,6 +17,10 @@
<item>3</item> <item>3</item>
<item>5</item> <item>5</item>
<item>10</item> <item>10</item>
<item>50</item>
<item>100</item>
<item>500</item>
<item>1000</item>
<item>-1</item> <item>-1</item>
</string-array> </string-array>
<string-array name="preloadCountNames" translatable="false"> <string-array name="preloadCountNames" translatable="false">

View File

@ -316,6 +316,9 @@
<string name="settings.show_now_playing_details">Show details in Now Playing</string> <string name="settings.show_now_playing_details">Show details in Now Playing</string>
<string name="settings.use_id3">Browse Using ID3 Tags</string> <string name="settings.use_id3">Browse Using ID3 Tags</string>
<string name="settings.use_id3_summary">Use ID3 tag methods instead of file system based methods</string> <string name="settings.use_id3_summary">Use ID3 tag methods instead of file system based methods</string>
<string name="settings.use_id3_offline">Use ID3 method also when offline</string>
<string name="settings.use_id3_offline_warning">Experimental: If you enable this Setting it will only show the music that you have downloaded with Ultrasonic 4.0 or later.</string>
<string name="settings.use_id3_offline_summary">Earlier downloads don\'t have the necessary metadata downloaded. You can toggle between Pin and Save mode to trigger the download of the missing metadata.</string>
<string name="settings.show_artist_picture">Show artist picture in artist list</string> <string name="settings.show_artist_picture">Show artist picture in artist list</string>
<string name="settings.show_artist_picture_summary">Displays the artist picture in the artist list if available</string> <string name="settings.show_artist_picture_summary">Displays the artist picture in the artist list if available</string>
<string name="main.video" tools:ignore="UnusedResources">Video</string> <string name="main.video" tools:ignore="UnusedResources">Video</string>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Targeting Android 11 or lower -->
<full-backup-content>
<!-- The following "exclude" elements are not part of the auto backup -->
<!--exclude domain="database" path="name.db" /-->
<exclude domain="root"/>
<!-- Exclude specific shared preferences that contain GCM registration Id -->
<!-- The following "include" elements are part of the auto backup -->
<include domain="sharedpref" path="."/>
<include domain="database" path="."/>
</full-backup-content>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Targeting Android 12 or higher -->
<data-extraction-rules>
<cloud-backup disableIfNoEncryptionCapabilities="true">
<!--include domain=["file" | "database" | "sharedpref" | "external" | "root"] path="string"/-->
<!--exclude domain=["file" | "database" | "sharedpref" | "external" | "root"] path="string"/-->
<exclude domain="root"/>
<include domain="sharedpref" path="."/>
<include domain="database" path="."/>
</cloud-backup>
<device-transfer>
<!-- Specifying nothing here means: Include all: https://developer.android.com/guide/topics/data/autobackup#include-exclude-android-11 -->
</device-transfer>
</data-extraction-rules>

View File

@ -59,6 +59,12 @@
a:summary="@string/settings.use_id3_summary" a:summary="@string/settings.use_id3_summary"
a:title="@string/settings.use_id3" a:title="@string/settings.use_id3"
app:iconSpaceReserved="false"/> app:iconSpaceReserved="false"/>
<CheckBoxPreference
a:defaultValue="true"
a:key="useId3TagsOffline"
a:summary="@string/settings.use_id3_offline_summary"
a:title="@string/settings.use_id3_offline"
app:iconSpaceReserved="false"/>
<CheckBoxPreference <CheckBoxPreference
a:defaultValue="true" a:defaultValue="true"
a:key="showArtistPicture" a:key="showArtistPicture"

View File

@ -12,6 +12,8 @@ import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
* Unit test for extension functions in [APIAlbumConverter.kt] file. * Unit test for extension functions in [APIAlbumConverter.kt] file.
*/ */
class APIAlbumConverterTest { class APIAlbumConverterTest {
private val serverId = -1
@Test @Test
fun `Should convert Album to domain entity`() { fun `Should convert Album to domain entity`() {
val entity = Album( val entity = Album(
@ -20,7 +22,7 @@ class APIAlbumConverterTest {
created = Calendar.getInstance(), year = 2017, genre = "some-genre" created = Calendar.getInstance(), year = 2017, genre = "some-genre"
) )
val convertedEntity = entity.toDomainEntity() val convertedEntity = entity.toDomainEntity(serverId)
with(convertedEntity) { with(convertedEntity) {
id `should be equal to` entity.id id `should be equal to` entity.id
@ -46,12 +48,12 @@ class APIAlbumConverterTest {
songList = listOf(MusicDirectoryChild()) songList = listOf(MusicDirectoryChild())
) )
val convertedEntity = entity.toMusicDirectoryDomainEntity() val convertedEntity = entity.toMusicDirectoryDomainEntity(serverId)
with(convertedEntity) { with(convertedEntity) {
name `should be equal to` null name `should be equal to` null
size `should be equal to` entity.songList.size size `should be equal to` entity.songList.size
this[0] `should be equal to` entity.songList[0].toTrackEntity() this[0] `should be equal to` entity.songList[0].toTrackEntity(serverId)
} }
} }
@ -59,12 +61,12 @@ class APIAlbumConverterTest {
fun `Should convert list of Album entities to domain list entities`() { fun `Should convert list of Album entities to domain list entities`() {
val entityList = listOf(Album(id = "455"), Album(id = "1"), Album(id = "1000")) val entityList = listOf(Album(id = "455"), Album(id = "1"), Album(id = "1000"))
val convertedList = entityList.toDomainEntityList() val convertedList = entityList.toDomainEntityList(serverId)
with(convertedList) { with(convertedList) {
size `should be equal to` entityList.size size `should be equal to` entityList.size
forEachIndexed { index, entry -> forEachIndexed { index, entry ->
entry `should be equal to` entityList[index].toDomainEntity() entry `should be equal to` entityList[index].toDomainEntity(serverId)
} }
} }
} }

View File

@ -11,12 +11,12 @@ import org.moire.ultrasonic.api.subsonic.models.Artist
/** /**
* Unit test for extension functions in APIArtistConverter.kt file. * Unit test for extension functions in APIArtistConverter.kt file.
*/ */
class APIArtistConverterTest { class APIArtistConverterTest : BaseTest() {
@Test @Test
fun `Should convert artist entity`() { fun `Should convert artist entity`() {
val entity = Artist(id = "10", name = "artist-name", starred = Calendar.getInstance()) val entity = Artist(id = "10", name = "artist-name", starred = Calendar.getInstance())
val convertedEntity = entity.toDomainEntity() val convertedEntity = entity.toDomainEntity(serverId)
with(convertedEntity) { with(convertedEntity) {
id `should be equal to` entity.id id `should be equal to` entity.id
@ -38,12 +38,12 @@ class APIArtistConverterTest {
) )
) )
val convertedEntity = entity.toMusicDirectoryDomainEntity() val convertedEntity = entity.toMusicDirectoryDomainEntity(serverId)
with(convertedEntity) { with(convertedEntity) {
name `should be equal to` entity.name name `should be equal to` entity.name
getChildren() `should be equal to` entity.albumsList getChildren() `should be equal to` entity.albumsList
.map { it.toDomainEntity() }.toMutableList() .map { it.toDomainEntity(serverId) }.toMutableList()
} }
} }
} }

View File

@ -11,7 +11,8 @@ import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
/** /**
* Unit test for function that converts [Bookmark] api entity to domain. * Unit test for function that converts [Bookmark] api entity to domain.
*/ */
class APIBookmarkConverterTest { class APIBookmarkConverterTest : BaseTest() {
@Test @Test
fun `Should convert to domain entity`() { fun `Should convert to domain entity`() {
val entity = Bookmark( val entity = Bookmark(
@ -19,7 +20,7 @@ class APIBookmarkConverterTest {
Calendar.getInstance(), MusicDirectoryChild(id = "12333") Calendar.getInstance(), MusicDirectoryChild(id = "12333")
) )
val domainEntity = entity.toDomainEntity() val domainEntity = entity.toDomainEntity(serverId)
with(domainEntity) { with(domainEntity) {
position `should be equal to` entity.position.toInt() position `should be equal to` entity.position.toInt()
@ -27,7 +28,7 @@ class APIBookmarkConverterTest {
comment `should be equal to` entity.comment comment `should be equal to` entity.comment
created `should be equal to` entity.created?.time created `should be equal to` entity.created?.time
changed `should be equal to` entity.changed?.time changed `should be equal to` entity.changed?.time
track `should be equal to` entity.entry.toTrackEntity() track `should be equal to` entity.entry.toTrackEntity(serverId)
} }
} }
@ -35,11 +36,11 @@ class APIBookmarkConverterTest {
fun `Should convert list of entities to domain entities`() { fun `Should convert list of entities to domain entities`() {
val entitiesList = listOf(Bookmark(443L), Bookmark(444L)) val entitiesList = listOf(Bookmark(443L), Bookmark(444L))
val domainEntitiesList = entitiesList.toDomainEntitiesList() val domainEntitiesList = entitiesList.toDomainEntitiesList(serverId)
domainEntitiesList.size `should be equal to` entitiesList.size domainEntitiesList.size `should be equal to` entitiesList.size
domainEntitiesList.forEachIndexed({ index, bookmark -> domainEntitiesList.forEachIndexed { index, bookmark ->
bookmark `should be equal to` entitiesList[index].toDomainEntity() bookmark `should be equal to` entitiesList[index].toDomainEntity(serverId)
}) }
} }
} }

View File

@ -11,7 +11,7 @@ import org.moire.ultrasonic.api.subsonic.models.Indexes
/** /**
* Unit tests for extension functions in [APIIndexesConverter.kt]. * Unit tests for extension functions in [APIIndexesConverter.kt].
*/ */
class APIIndexConverterTest { class APIIndexConverterTest : BaseTest() {
@Test @Test
fun `Should convert Indexes entity`() { fun `Should convert Indexes entity`() {
val artistsA = listOf( val artistsA = listOf(
@ -31,9 +31,12 @@ class APIIndexConverterTest {
shortcutList = artistsA shortcutList = artistsA
) )
val convertedEntity = entity.toArtistList() val convertedEntity = entity.toArtistList(serverId)
val expectedArtists = (artistsA + artistsT).map {
it.toDomainEntity(serverId)
}.toMutableList()
val expectedArtists = (artistsA + artistsT).map { it.toDomainEntity() }.toMutableList()
with(convertedEntity) { with(convertedEntity) {
size `should be equal to` expectedArtists.size size `should be equal to` expectedArtists.size
this `should be equal to` expectedArtists this `should be equal to` expectedArtists

View File

@ -11,7 +11,7 @@ import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
/** /**
* Unit test for extension functions in APIMusicDirectoryConverter.kt file. * Unit test for extension functions in APIMusicDirectoryConverter.kt file.
*/ */
class APIMusicDirectoryConverterTest { class APIMusicDirectoryConverterTest : BaseTest() {
@Test @Test
fun `Should convert MusicDirectory entity`() { fun `Should convert MusicDirectory entity`() {
val entity = MusicDirectory( val entity = MusicDirectory(
@ -20,13 +20,13 @@ class APIMusicDirectoryConverterTest {
childList = listOf(MusicDirectoryChild("1"), MusicDirectoryChild("2")) childList = listOf(MusicDirectoryChild("1"), MusicDirectoryChild("2"))
) )
val convertedEntity = entity.toDomainEntity() val convertedEntity = entity.toDomainEntity(serverId)
with(convertedEntity) { with(convertedEntity) {
name `should be equal to` entity.name name `should be equal to` entity.name
size `should be equal to` entity.childList.size size `should be equal to` entity.childList.size
getChildren() `should be equal to` entity.childList getChildren() `should be equal to` entity.childList
.map { it.toTrackEntity() }.toMutableList() .map { it.toTrackEntity(serverId) }.toMutableList()
} }
} }
@ -44,7 +44,7 @@ class APIMusicDirectoryConverterTest {
starred = Calendar.getInstance(), userRating = 3, averageRating = 2.99F starred = Calendar.getInstance(), userRating = 3, averageRating = 2.99F
) )
val convertedEntity = entity.toTrackEntity() val convertedEntity = entity.toTrackEntity(serverId)
with(convertedEntity) { with(convertedEntity) {
id `should be equal to` entity.id id `should be equal to` entity.id
@ -84,7 +84,7 @@ class APIMusicDirectoryConverterTest {
artist = "some-artist", publishDate = Calendar.getInstance() artist = "some-artist", publishDate = Calendar.getInstance()
) )
val convertedEntity = entity.toTrackEntity() val convertedEntity = entity.toTrackEntity(serverId)
with(convertedEntity) { with(convertedEntity) {
id `should be equal to` entity.streamId id `should be equal to` entity.streamId
@ -96,11 +96,11 @@ class APIMusicDirectoryConverterTest {
fun `Should convert list of MusicDirectoryChild to domain entity list`() { fun `Should convert list of MusicDirectoryChild to domain entity list`() {
val entitiesList = listOf(MusicDirectoryChild(id = "45"), MusicDirectoryChild(id = "34")) val entitiesList = listOf(MusicDirectoryChild(id = "45"), MusicDirectoryChild(id = "34"))
val domainList = entitiesList.toDomainEntityList() val domainList = entitiesList.toDomainEntityList(serverId)
domainList.size `should be equal to` entitiesList.size domainList.size `should be equal to` entitiesList.size
domainList.forEachIndexed { index, entry -> domainList.forEachIndexed { index, entry ->
entry `should be equal to` entitiesList[index].toTrackEntity() entry `should be equal to` entitiesList[index].toTrackEntity(serverId)
} }
} }
} }

View File

@ -9,12 +9,12 @@ import org.moire.ultrasonic.api.subsonic.models.MusicFolder
/** /**
* Unit test for extension functions in file APIMusicFolderConverter.kt. * Unit test for extension functions in file APIMusicFolderConverter.kt.
*/ */
class APIMusicFolderConverterTest { class APIMusicFolderConverterTest : BaseTest() {
@Test @Test
fun `Should convert MusicFolder entity`() { fun `Should convert MusicFolder entity`() {
val entity = MusicFolder(id = "10", name = "some-name") val entity = MusicFolder(id = "10", name = "some-name")
val convertedEntity = entity.toDomainEntity() val convertedEntity = entity.toDomainEntity(serverId)
convertedEntity.name `should be equal to` entity.name convertedEntity.name `should be equal to` entity.name
convertedEntity.id `should be equal to` entity.id convertedEntity.id `should be equal to` entity.id
@ -27,7 +27,7 @@ class APIMusicFolderConverterTest {
MusicFolder(id = "4", name = "some-name-4") MusicFolder(id = "4", name = "some-name-4")
) )
val convertedList = entityList.toDomainEntityList() val convertedList = entityList.toDomainEntityList(serverId)
with(convertedList) { with(convertedList) {
size `should be equal to` entityList.size size `should be equal to` entityList.size

View File

@ -11,7 +11,7 @@ import org.moire.ultrasonic.api.subsonic.models.Playlist
/** /**
* Unit test for extension functions that converts api playlist entity to domain. * Unit test for extension functions that converts api playlist entity to domain.
*/ */
class APIPlaylistConverterTest { class APIPlaylistConverterTest : BaseTest() {
@Test @Test
fun `Should convert Playlist to MusicDirectory domain entity`() { fun `Should convert Playlist to MusicDirectory domain entity`() {
val entity = Playlist( val entity = Playlist(
@ -22,13 +22,13 @@ class APIPlaylistConverterTest {
) )
) )
val convertedEntity = entity.toMusicDirectoryDomainEntity() val convertedEntity = entity.toMusicDirectoryDomainEntity(serverId)
with(convertedEntity) { with(convertedEntity) {
name `should be equal to` entity.name name `should be equal to` entity.name
size `should be equal to` entity.entriesList.size size `should be equal to` entity.entriesList.size
this[0] `should be equal to` entity.entriesList[0].toTrackEntity() this[0] `should be equal to` entity.entriesList[0].toTrackEntity(serverId)
this[1] `should be equal to` entity.entriesList[1].toTrackEntity() this[1] `should be equal to` entity.entriesList[1].toTrackEntity(serverId)
} }
} }

View File

@ -15,7 +15,7 @@ import org.moire.ultrasonic.api.subsonic.models.SearchTwoResult
/** /**
* Unit test for extension function in APISearchConverter.kt file. * Unit test for extension function in APISearchConverter.kt file.
*/ */
class APISearchConverterTest { class APISearchConverterTest : BaseTest() {
@Test @Test
fun `Should convert SearchResult to domain entity`() { fun `Should convert SearchResult to domain entity`() {
val entity = SearchResult( val entity = SearchResult(
@ -26,7 +26,7 @@ class APISearchConverterTest {
) )
) )
val convertedEntity = entity.toDomainEntity() val convertedEntity = entity.toDomainEntity(serverId)
with(convertedEntity) { with(convertedEntity) {
albums `should not be equal to` null albums `should not be equal to` null
@ -34,7 +34,7 @@ class APISearchConverterTest {
artists `should not be equal to` null artists `should not be equal to` null
artists.size `should be equal to` 0 artists.size `should be equal to` 0
songs.size `should be equal to` entity.matchList.size songs.size `should be equal to` entity.matchList.size
songs[0] `should be equal to` entity.matchList[0].toTrackEntity() songs[0] `should be equal to` entity.matchList[0].toTrackEntity(serverId)
} }
} }
@ -46,15 +46,15 @@ class APISearchConverterTest {
listOf(MusicDirectoryChild(id = "9118", parent = "112")) listOf(MusicDirectoryChild(id = "9118", parent = "112"))
) )
val convertedEntity = entity.toDomainEntity() val convertedEntity = entity.toDomainEntity(serverId)
with(convertedEntity) { with(convertedEntity) {
artists.size `should be equal to` entity.artistList.size artists.size `should be equal to` entity.artistList.size
artists[0] `should be equal to` entity.artistList[0].toIndexEntity() artists[0] `should be equal to` entity.artistList[0].toIndexEntity(serverId)
albums.size `should be equal to` entity.albumList.size albums.size `should be equal to` entity.albumList.size
albums[0] `should be equal to` entity.albumList[0].toDomainEntity() albums[0] `should be equal to` entity.albumList[0].toDomainEntity(serverId)
songs.size `should be equal to` entity.songList.size songs.size `should be equal to` entity.songList.size
songs[0] `should be equal to` entity.songList[0].toTrackEntity() songs[0] `should be equal to` entity.songList[0].toTrackEntity(serverId)
} }
} }
@ -66,15 +66,15 @@ class APISearchConverterTest {
songList = listOf(MusicDirectoryChild(id = "7123", title = "song1")) songList = listOf(MusicDirectoryChild(id = "7123", title = "song1"))
) )
val convertedEntity = entity.toDomainEntity() val convertedEntity = entity.toDomainEntity(serverId)
with(convertedEntity) { with(convertedEntity) {
artists.size `should be equal to` entity.artistList.size artists.size `should be equal to` entity.artistList.size
artists[0] `should be equal to` entity.artistList[0].toDomainEntity() artists[0] `should be equal to` entity.artistList[0].toDomainEntity(serverId)
albums.size `should be equal to` entity.albumList.size albums.size `should be equal to` entity.albumList.size
albums[0] `should be equal to` entity.albumList[0].toDomainEntity() albums[0] `should be equal to` entity.albumList[0].toDomainEntity(serverId)
songs.size `should be equal to` entity.songList.size songs.size `should be equal to` entity.songList.size
songs[0] `should be equal to` entity.songList[0].toTrackEntity() songs[0] `should be equal to` entity.songList[0].toTrackEntity(serverId)
} }
} }
} }

View File

@ -11,12 +11,12 @@ import org.moire.ultrasonic.api.subsonic.models.Share
/** /**
* Unit test for api to domain share entity converter functions. * Unit test for api to domain share entity converter functions.
*/ */
class APIShareConverterTest { class APIShareConverterTest : BaseTest() {
@Test @Test
fun `Should convert share entity to domain`() { fun `Should convert share entity to domain`() {
val entity = createFakeShare() val entity = createFakeShare()
val domainEntity = entity.toDomainEntity() val domainEntity = entity.toDomainEntity(serverId)
with(domainEntity) { with(domainEntity) {
id `should be equal to` entity.id id `should be equal to` entity.id
@ -27,7 +27,7 @@ class APIShareConverterTest {
lastVisited `should be equal to` shareTimeFormat.format(entity.lastVisited!!.time) lastVisited `should be equal to` shareTimeFormat.format(entity.lastVisited!!.time)
expires `should be equal to` shareTimeFormat.format(entity.expires!!.time) expires `should be equal to` shareTimeFormat.format(entity.expires!!.time)
visitCount `should be equal to` entity.visitCount.toLong() visitCount `should be equal to` entity.visitCount.toLong()
this.getEntries() `should be equal to` entity.items.toDomainEntityList() this.getEntries() `should be equal to` entity.items.toDomainEntityList(serverId)
} }
} }
@ -47,10 +47,10 @@ class APIShareConverterTest {
createFakeShare().copy(id = "554", lastVisited = null) createFakeShare().copy(id = "554", lastVisited = null)
) )
val domainEntityList = entityList.toDomainEntitiesList() val domainEntityList = entityList.toDomainEntitiesList(serverId)
domainEntityList.size `should be equal to` entityList.size domainEntityList.size `should be equal to` entityList.size
domainEntityList[0] `should be equal to` entityList[0].toDomainEntity() domainEntityList[0] `should be equal to` entityList[0].toDomainEntity(serverId)
domainEntityList[1] `should be equal to` entityList[1].toDomainEntity() domainEntityList[1] `should be equal to` entityList[1].toDomainEntity(serverId)
} }
} }

View File

@ -0,0 +1,12 @@
/*
* BaseTest.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain
open class BaseTest {
internal val serverId = -1
}