45 Commits

Author SHA1 Message Date
8a11b9da03 navidrome analyze & generate update 2025-10-30 11:57:40 +01:00
61bc0074c0 navidrome updater 2025-10-30 02:02:15 +01:00
95a6d0d6a1 improve write file + restore ELS format 2025-10-23 10:43:45 +02:00
e761946398 date added 2025-10-23 10:43:35 +02:00
e71346e8f3 choose json style + simplificate write json
Refactoring dict construction
Don't take so much more time...
2025-10-23 10:43:35 +02:00
ce680beeb3 ruff 2025-10-22 10:09:54 +02:00
93b3632bb4 Little py script adaptation 2025-10-22 10:09:03 +02:00
7c7bc48d09 WIP Cover POC 2024-01-30 01:57:12 +01:00
e7af7f471f (front) Update to Angular 17
npm update

man update
2024-01-29 23:43:51 +01:00
eb13944e46 (front) Update to Angular 16
npm audit fix
2024-01-29 23:43:48 +01:00
20f4fdbd39 (typesense) Suggester 2024-01-23 23:54:46 +01:00
b789c925c4 (typesense) Main dashboard
Lot of duplicate to have a simple improvment
2024-01-23 23:54:36 +01:00
1ff074aac3 (front) Use a compatible ng-bootstrap version 2023-09-20 01:17:35 +02:00
f6eb99fa67 (front) Update to Angular 15 2023-02-06 23:58:43 +01:00
46c4a0b40c (front) Update to Angular 14 2023-02-06 23:51:56 +01:00
05cbe19b4d (front) Update package-lock
Why?...
2023-02-06 23:36:50 +01:00
003ad4917e (front) NVM update node version 2023-02-06 23:27:24 +01:00
25828bb789 (front) Service refactoring for sorting operations
- Create an ElsArtistService to use common query
- Move services in a subfolder

Use a function to add sort filter
2023-02-06 23:08:56 +01:00
29350c36cd (front) Another way to get toSort boolean at Service 2023-01-30 01:30:38 +01:00
9f4ef952b3 (front) Use a 'tosort' filter 2023-01-30 01:23:35 +01:00
2ddafaff1b (front) Improve ToSort using album index 2023-01-29 22:34:23 +01:00
7e0a791fbe (back) Add location for albums 2023-01-29 19:38:24 +01:00
cf8cd046f4 (front) To sort: reorganize stats panels 2023-01-09 00:06:56 +01:00
a10c9ae060 (front) Update to Angular 13 2023-01-09 00:06:56 +01:00
754ee9213c (front) Adapt query for ELS 8.5 2023-01-09 00:06:56 +01:00
40aed6039d (front) Run after a long time
Use a nvmrc file to keep in memory node version used
2023-01-09 00:06:56 +01:00
4399018b4a (back) Readme udpate 2023-01-09 00:06:56 +01:00
bb51af68cd (front)(update) Add ng-bootstrap
Reverse 098d095 and take last version
2021-08-23 01:07:24 +02:00
5ee9781463 (front)(update) Upgrading npm from 6.14.14 to 7.21.0
Reverse dc47dfe
2021-08-23 01:07:18 +02:00
00a5427950 (front)(update) Update Angular 2021-08-23 01:07:13 +02:00
fada5f475b (front)(update) Lowering npm to 7.21.0 to 6.14.14
https://github.com/angular/angular-cli/issues/20208
2021-08-23 01:07:07 +02:00
d307012aca (front)(update) Remove ng-bootstrap for update
Avoid dependancy error
2021-08-23 01:06:43 +02:00
755f5e5c24 (front) tsconfig use strict template 2021-08-22 22:31:53 +02:00
f40ee2b6d2 Suggester - Autocomplete Serch
Merge branch 'poc/suggester/ng-bootstrap' into dev
2021-08-22 17:39:30 +02:00
d011d5738f (front) Fix top buttons 2021-08-22 17:39:17 +02:00
e5de38b9d9 (front) Create 'To Sort' component
With a specific ELS Services to query element in a specific folder

TODO: Parameterize folder
2021-08-22 17:39:13 +02:00
f37ce410eb (front) Improve search result stype
Template to show results with glyphicon

(cherry picked from commit 550d8e7b343dc262be87182ae13f231ccc995b55)

Improve style
Stealing CSS from the demo site

(cherry picked from commit 75bebac0ac6080a0210d2b156a14f8a0f70067db)
2021-08-22 17:34:56 +02:00
124a30ad8c (front) Suggester with ng-bootstrap
Really "complex" search method, but yea, it's work :s

(cherry picked from commit 0789a92b0a9153b6112ad39b1f61090bdbeab197)
2021-08-22 17:33:33 +02:00
52a0cde401 (front) Autocomplete search on dashboard with Datalist
Create Elasticsearch service to search suggestions

Send to page when choose a suggestion - bad way
It's impossible to use Object with datalist
So it's impossible to distinguish an album from an artist if they have
the same name
2021-08-22 17:04:29 +02:00
5fbcfbae68 (front) Update package-lock 2021-08-22 17:04:25 +02:00
74c6f99fa5 (back) Suggester: Change ELS search analyzer for specific char
Implementing search in dashboard revealed problems when search with specific char (francois perusse)
Note: it's also possible to specify an analyzer for ingestion

(cherry picked from commit 24da127750c995c55d54876a1be8a4ad3bf9ce9c)
2021-08-22 17:02:14 +02:00
60e1fb2e74 (back) Suggester: Improve processor - more generic
Process album & artist with a calculated fields
Separate main
Show progress

(cherry picked from commit fc8407cc6a51fe18b14169b3a3f0e4fc363beb4f)
2021-08-22 17:01:19 +02:00
520d0be595 (back) Suggester V3: Process album in a separate way
(cherry picked from commit ebbeeccfb8535dbb67240d2c68c7dc9a4da7e7f8)
2021-08-22 17:01:04 +02:00
8121f3d751 (back) Suggester V2: Process album data
(cherry picked from commit dd322405d047d49e51d528341cbd008d7a98b6ab)
2021-08-22 17:01:00 +02:00
436edaf3f2 (back) Suggester V1: Process artist data
(cherry picked from commit 02f5705fde37e1aaef5c68de62aafe45fc86d490)
2021-08-22 17:00:01 +02:00
61 changed files with 14553 additions and 11607 deletions

8
.gitignore vendored
View File

@@ -1,8 +1,8 @@
node_modules/
.vscode/
/es-albums.json
/es-artists.json
/es-songs.json
/es-albums.*
/es-artists.*
/es-songs.*
/iTunesLibrary.xml
# Working files
@@ -16,3 +16,5 @@ dashboard/resultToStudy.json
sand_box.py
rating_test.py
iTunesGraphParser.my.py
iTunes Library.xml
navidrome/navidrome.db

11
Justfile Normal file
View File

@@ -0,0 +1,11 @@
parse:
-rm -v es-*
python3 iTunesParser.py -f iTunes\ Library.xml -F json
send-all:
just send songs
just send artists
just send albums
send type:
curl -X POST 'localhost:7700/indexes/itunes-{{type}}/documents?primaryKey=Persistent%20ID' -H 'Content-Type: application/json' -H 'Authorization: Bearer aSampleMasterKey' --data-binary @es-{{type}}.json

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
# Classic data
1. Analyze data :
```sh
python3 iTunesParser.py
```
2. Send data to ELS :
```sh
python3 send_data.py -D -A
```
# Suggester
To use suggester, create index with data in `suggester.es` file.
Then :
```sh
python3 suggester.py
```

View File

@@ -25,6 +25,7 @@
!.vscode/extensions.json
# misc
/.angular/cache
/.sass-cache
/connect.lock
/coverage

1
dashboard/.nvmrc Normal file
View File

@@ -0,0 +1 @@
v18.12.1

View File

@@ -18,7 +18,6 @@
"main": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"polyfills": "src/polyfills.ts",
"aot": true,
"assets": [
"src/assets",
"src/favicon.ico"
@@ -27,7 +26,13 @@
"src/styles.css",
"node_modules/bootstrap/dist/css/bootstrap.min.css"
],
"scripts": []
"scripts": [],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"configurations": {
"production": {
@@ -57,23 +62,24 @@
}
]
}
}
},
"defaultConfiguration": ""
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "dashboard:build"
"buildTarget": "dashboard:build"
},
"configurations": {
"production": {
"browserTarget": "dashboard:build:production"
"buildTarget": "dashboard:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "dashboard:build"
"buildTarget": "dashboard:build"
}
},
"test": {
@@ -94,19 +100,6 @@
"scripts": []
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"tsconfig.app.json",
"tsconfig.spec.json",
"e2e/tsconfig.json"
],
"exclude": [
"**/node_modules/**"
]
}
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
@@ -121,6 +114,5 @@
}
}
}
},
"defaultProject": "dashboard"
}
}

22217
dashboard/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,36 +11,38 @@
},
"private": true,
"dependencies": {
"@angular/animations": "~11.0.4",
"@angular/common": "~11.0.4",
"@angular/compiler": "~11.0.4",
"@angular/core": "~11.0.4",
"@angular/forms": "~11.0.4",
"@angular/platform-browser": "~11.0.4",
"@angular/platform-browser-dynamic": "~11.0.4",
"@angular/router": "~11.0.4",
"rxjs": "~6.6.0",
"zone.js": "~0.10.2",
"bootstrap": "^3.3.7",
"tslib": "^2.0.0"
"@angular/animations": "^17.1.0",
"@angular/common": "^17.1.0",
"@angular/compiler": "^17.1.0",
"@angular/core": "^17.1.0",
"@angular/forms": "^17.1.0",
"@angular/localize": "^17.1.0",
"@angular/platform-browser": "^17.1.0",
"@angular/platform-browser-dynamic": "^17.1.0",
"@angular/router": "^17.1.0",
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
"bootstrap": "^3.4.1",
"rxjs": "~7.8.1",
"tslib": "^2.6.2",
"zone.js": "^0.14.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.1100.4",
"@angular/cli": "~11.0.4",
"@angular/compiler-cli": "~11.0.4",
"@types/jasmine": "~3.6.0",
"@types/node": "^12.11.1",
"codelyzer": "^6.0.0",
"jasmine-core": "~3.6.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~5.1.0",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage": "~2.0.3",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"@angular-devkit/build-angular": "^17.1.0",
"@angular/cli": "^17.1.0",
"@angular/compiler-cli": "^17.1.0",
"@types/jasmine": "~5.1.4",
"@types/node": "^20.11.5",
"codelyzer": "^6.0.2",
"jasmine-core": "~5.1.1",
"jasmine-spec-reporter": "~7.0.0",
"karma": "~6.4.2",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "^2.1.0",
"protractor": "~7.0.0",
"ts-node": "~8.3.0",
"ts-node": "~10.9.2",
"tslint": "~6.1.0",
"typescript": "~4.0.2"
"typescript": "~5.3.3"
}
}

View File

@@ -1,8 +1,15 @@
<div class="container">
<h1>{{albumName}}</h1>
<div *ngIf="sortFilter" class="alert alert-danger">
To sort filter applyed. Songs are filter on location. <br/>
Album folder: {{album['Location']}}
</div>
<div class="alert alert-warning">
<img class="images" src="data:image/png;base64, {{album.Cover}}" height="500"/>
<h3>Debug Zone</h3>
ID: {{album.id}}<br />
Returned song: {{songs.length}}<br />
Theorical number song: {{ album ? album['Track Count'] : "" }}
<span *ngIf="album && (songs.length == album['Track Count'])" class="glyphicon glyphicon-ok" style="color:green"></span>
@@ -20,7 +27,7 @@
<div class="col-xs-9 text-right">
<div>
<h3 *ngIf="!album"><span class="glyphicon glyphicon-refresh loading"></span></h3>
<h3 *ngIf="album">{{album.Rating}}/100</h3>
<h3 *ngIf="album">{{album['Album Rating']}}/100</h3>
</div>
<div><br>Rating</div>
</div>

View File

@@ -1,12 +1,13 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { Location } from '@angular/common';
import { ElsService } from './../els.service';
import { Song } from './../model/song';
import { Album } from './../model/album';
import { SongTableComponent } from '../song-table/song-table.component';
import { TsService } from '../ts-service/ts.service';
import { TsAlbumService } from '../ts-service/ts-album.service';
@Component({
selector: 'app-album',
templateUrl: './album.component.html',
@@ -19,24 +20,24 @@ export class AlbumComponent implements OnInit {
albumName = '';
songs: Array<Song> = [];
album: Album = new Album(); // If album not found, will be replaced by 'undefined'
sortFilter: boolean = false; // Show only song to sort
// Prevent useless data load + activate button in interface var
moreDataAvailable = true;
lockLoadData = false;
constructor(
private elsService: ElsService,
private route: ActivatedRoute,
private location: Location
private tsAlbumService: TsAlbumService,
private route: ActivatedRoute
) { }
ngOnInit(): void {
this.route.params
.subscribe((params: Params) => this.albumName = params['name']);
this.route.params.subscribe((params: Params) => this.albumName = params['name']);
this.route.queryParams.subscribe(params => { this.sortFilter = params.tosort ?? false; });
this.loadSongs();
this.elsService.getAlbum(this.albumName).subscribe(data => this.album = data);
this.tsAlbumService.getAlbum(this.albumName).subscribe(data => this.album = data);
}
loadSongs(): void {
@@ -50,9 +51,9 @@ export class AlbumComponent implements OnInit {
}
this.lockLoadData = true;
this.elsService.getAlbumSongs(this.albumName, this.songs.length).subscribe(
this.tsAlbumService.getAlbumSongs(this.albumName, this.songs.length, this.sortFilter).subscribe(
data => {
this.moreDataAvailable = data.length === ElsService.DEFAULT_SIZE;
this.moreDataAvailable = data.length === TsService.DEFAULT_SIZE;
// Erase song array with result for first load, then add elements one by one
// instead use concat => concat will sort table at each load, very consuming! and not user friendly

View File

@@ -1,71 +1,77 @@
import { Component, OnInit } from '@angular/core';
import { ElsAlbumService } from '../els-album.service';
import { Component, OnInit } from "@angular/core";
import { Album } from '../model/album';
import { Album } from "../model/album";
import { Utils } from '../utils';
import { Utils } from "../utils";
import { TsAlbumService } from "../ts-service/ts-album.service";
enum query_edit_type {
exclude = 'must_not',
select = 'must'
exclude = "must_not",
select = "must",
}
@Component({
selector: 'app-albums',
templateUrl: './albums.component.html',
styleUrls: ['./albums.component.css']
selector: "app-albums",
templateUrl: "./albums.component.html",
styleUrls: ["./albums.component.css"],
})
export class AlbumsComponent implements OnInit {
numberToArray = Utils.numberToArray;
numberToArray = Utils.numberToArray; // For star representation
albums: Album[] = [];
filterQuery = Object.assign({}, ElsAlbumService.GET_ALBUMS_DEFAULT_QUERY);
queryEdited = false;
filterParams = TsAlbumService.GET_ALBUMS_DEFAULT_PARAMS();
queryEdited = false; // Show reset button if true
constructor(private elsService : ElsAlbumService) { }
constructor(private tsService: TsAlbumService) {}
ngOnInit(): void {
this.loadData();
}
private editQuery(field: string, value: string, type: query_edit_type): void {
private editQuery(field: string, value: Album, type: query_edit_type): void {
// TODO Move this method to a service
if (value[field] instanceof Array) {
value[field] = value[field][0]
value[field] = value[field][0];
}
// If firt edit, add needed fields in ELS Query
if (!this.filterQuery['query']) {
this.filterQuery['query']['bool'][type].push({ 'must': [] })
this.filterQuery['query']['bool'][type].push({ 'must_not': [] })
if (type == query_edit_type.exclude) {
// Filter can be cumulated
// TODO Specific treatment for array? https://typesense.org/docs/0.25.1/api/search.html#filter-parameters
this.filterParams = this.filterParams.append(
"filter_by",
field + ":!=`" + value[field] + "`"
);
}
this.filterQuery['query']['bool'][type].push({
'match_phrase': {
[field]: value[field]
}
})
if (type == query_edit_type.select) {
this.filterParams = this.filterParams
.delete("q")
.append("q", value[field])
.delete("query_by")
.append("query_by", field);
}
this.queryEdited = true;
}
exlude(field: string, value: string): void {
this.editQuery(field, value, query_edit_type.exclude)
this.loadData()
exlude(field: string, value: Album): void {
this.editQuery(field, value, query_edit_type.exclude);
this.loadData();
}
select(field: string, value: string): void {
this.editQuery(field, value, query_edit_type.select)
this.loadData()
select(field: string, value: Album): void {
this.editQuery(field, value, query_edit_type.select);
this.loadData();
}
resetQuery(): void {
this.filterQuery = Object.assign({}, ElsAlbumService.GET_ALBUMS_DEFAULT_QUERY);
this.filterParams = TsAlbumService.GET_ALBUMS_DEFAULT_PARAMS();
this.loadData();
}
loadData(): void {
// console.log(JSON.stringify(this.filterQuery))
this.elsService.getAlbums(this.filterQuery).subscribe(data => this.albums = data);
console.log(this.filterParams.toString());
this.tsService
.getAlbums(this.filterParams)
.subscribe((data) => (this.albums = data));
}
}

View File

@@ -7,6 +7,7 @@ import { ArtistComponent } from './artist/artist.component';
import { GenreComponent } from './genre/genre.component';
import { TopPlayedComponent } from './top-played/top-played.component';
import { AlbumsComponent } from './albums/albums.component';
import { ToSortComponent } from './to-sort/to-sort.component';
const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
@@ -15,7 +16,8 @@ const routes: Routes = [
{ path: 'album', component: AlbumsComponent },
{ path: 'artist/:name', component: ArtistComponent },
{ path: 'genre/:name', component: GenreComponent },
{ path: 'top-played', component: TopPlayedComponent }
{ path: 'top-played', component: TopPlayedComponent },
{ path: 'to-sort', component: ToSortComponent }
];
@NgModule({

View File

@@ -36,7 +36,8 @@ nav a.active {
/* FIXME Code Repetition */
.btn-top {
display: inline-block;
display: flex;
flex-direction: column;
position: fixed;
z-index: 1;
-webkit-transition: opacity .3s 0s, visibility 0s 0s;

View File

@@ -1,5 +1,8 @@
<nav>
<a class="btn-top" routerLink="/dashboard" routerLinkActive="active"><span class="glyphicon glyphicon-home"></span></a><br />
<a class="btn-top" routerLink="/album" routerLinkActive="active"><span class="glyphicon glyphicon-cd"></span></a>
<div class="btn-top">
<a routerLink="/dashboard" routerLinkActive="active"><span class="glyphicon glyphicon-home"></span></a><br />
<a routerLink="/album" routerLinkActive="active"><span class="glyphicon glyphicon-cd"></span></a>
<a routerLink="/to-sort" routerLinkActive="active"><span class="glyphicon glyphicon-sort"></span></a>
</div>
</nav>
<router-outlet></router-outlet>

View File

@@ -1,6 +1,7 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { FormsModule } from '@angular/forms'; // <-- NgModel lives here
import { AppComponent } from './app.component';
import { DashboardComponent } from './dashboard/dashboard.component';
@@ -10,8 +11,9 @@ import { GenreComponent } from './genre/genre.component';
import { SongTableComponent } from './song-table/song-table.component';
import { TopPlayedComponent } from './top-played/top-played.component';
import { ElsService } from './els.service';
import { ElsAlbumService } from './els-album.service';
import { TsService } from './ts-service/ts.service';
import { TsAlbumService } from './ts-service/ts-album.service';
import { TsArtistService } from './ts-service/ts-artist.service';
import { AppRoutingModule } from './app-routing.module';
@@ -21,12 +23,17 @@ import { SortByPipe } from './pipes/sort-by.pipe';
import { ConvertSizeToStringPipe } from './pipes/convert-size-to-string.pipe';
import { RoundPipe } from './pipes/round.pipe';
import { AlbumsComponent } from './albums/albums.component';
import { ToSortComponent } from './to-sort/to-sort.component';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
@NgModule({
imports: [
BrowserModule,
HttpClientModule,
AppRoutingModule
FormsModule,
AppRoutingModule,
NgbModule
],
declarations: [
AppComponent,
@@ -41,11 +48,13 @@ import { AlbumsComponent } from './albums/albums.component';
SortByPipe,
ConvertSizeToStringPipe,
TopPlayedComponent,
RoundPipe
RoundPipe,
ToSortComponent
],
providers: [
ElsService,
ElsAlbumService
TsService,
TsAlbumService,
TsArtistService
],
bootstrap: [ AppComponent ]
})

View File

@@ -1,12 +1,13 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { Location } from '@angular/common';
import { ElsService } from './../els.service';
import { Song } from './../model/song';
import { Artist } from './../model/artist';
import { SongTableComponent } from '../song-table/song-table.component';
import { TsService } from '../ts-service/ts.service';
import { TsArtistService } from '../ts-service/ts-artist.service';
@Component({
selector: 'app-artist',
templateUrl: './artist.component.html',
@@ -25,19 +26,26 @@ export class ArtistComponent implements OnInit {
artist: Artist = new Artist();
lockLoadData = false;
countSong: number;
toSortFilter: boolean = false; // Show only song to sort
constructor(
private elsService: ElsService,
private elsService: TsArtistService,
private route: ActivatedRoute,
private location: Location
) { }
ngOnInit(): void {
this.route.params.subscribe((params: Params) => this.artistName = params['name']);
this.route.queryParams.subscribe(params => { this.toSortFilter = params.tosort ?? false; });
this.elsService.getArtist(this.artistName).subscribe(data => this.artist = data);
this.elsService.getCountArtistSong(this.artistName).subscribe(data => this.countSong = data);
this.elsService.getCountArtistSong(this.artistName, this.toSortFilter).subscribe(data => {
this.countSong = data
this.artist['Track Count'] = this.countSong;
// TODO Async problem: some time, get updated data, some time no
// TODO Use only this value?
// TODO ==> Show each time if there are unsorted songs
});
this.loadSongs();
}
@@ -54,9 +62,11 @@ export class ArtistComponent implements OnInit {
}
this.lockLoadData = true;
this.elsService.getArtistSongs(this.artistName, this.songs.length).subscribe(
this.elsService.getArtistSongs(this.artistName, this.songs.length, this.toSortFilter).subscribe(
data => {
this.moreDataAvailable = data.length === ElsService.DEFAULT_SIZE;
this.moreDataAvailable = data.length === TsService.DEFAULT_SIZE;
console.log(data.length)
console.log(this.moreDataAvailable)
// Erase song array with result for first load, then add elements one by one
// instead use concat => concat will sort table at each load, very consuming! and not user friendly

View File

@@ -34,5 +34,44 @@
border-radius: 50%;
background-color: #9b59b6;
margin: 0 auto;
}
/* Style for autocompletion */
/* Stolen from https://ng-bootstrap.github.io/#/components/typeahead/examples */
::ng-deep .dropdown-item {
display: block;
width: 100%;
padding: .25rem 1.5rem;
clear: both;
font-weight: 400;
color: #212529;
text-align: inherit;
white-space: nowrap;
background-color: transparent;
border: 0
}
::ng-deep .dropdown-item:focus,
::ng-deep .dropdown-item:hover {
color: #16181b;
text-decoration: none;
background-color: #f8f9fa
}
::ng-deep .dropdown-item.active,
::ng-deep .dropdown-item:active {
color: #fff;
text-decoration: none;
background-color: #007bff
}
::ng-deep .dropdown-item.disabled,
::ng-deep .dropdown-item:disabled {
color: #6c757d;
pointer-events: none;
background-color: transparent
}
::ng-deep .dropdown-menu.show {
display: block
}

View File

@@ -77,6 +77,30 @@
</div>
</div>
<ng-template #rt let-r="result" let-t="term">
<!-- glyphicon glyphicon-cd -->
<span *ngIf="r.type == 'artist'" class="glyphicon glyphicon-user"></span>
<span *ngIf="r.type == 'album'" class="glyphicon glyphicon-cd"></span>
&nbsp; <ngb-highlight [result]="r.name" [term]="t"></ngb-highlight>
</ng-template>
<form class="navbar-form">
<div class="form-group" style="display:inline;">
<div class="input-group" style="display:table;">
<span class="input-group-addon" style="width:1%;">
<span class="glyphicon glyphicon-search"></span>
</span>
<input class="form-control" type="text" name="search" placeholder="Search Here" autocomplete="off" autofocus="autofocus"
id="searchSuggest"
[(ngModel)]="searchTerm"
[ngbTypeahead]="search"
[resultTemplate]="rt"
(selectItem)="onSelectSearch($event)">
</div>
</div>
</form>
<div class="col-md-12">
<table class="table table-striped">
<thead>
@@ -88,9 +112,9 @@
</thead>
<tbody>
<tr *ngFor="let album of lastAddedAlbums">
<td><a [routerLink]="['/album', album.key]">{{album.key}}</a></td>
<td>{{album.doc_count}}</td>
<td><a [routerLink]="['/artist', albumArtists[album.key]]">{{albumArtists[album.key]}}</a></td>
<td><a [routerLink]="['/album/', album.group_key[0]]">{{album.group_key[0]}}</a></td>
<td>{{album.found}}</td>
<td><a [routerLink]="['/artist', albumArtists[album.group_key[0]]]">{{albumArtists[album.group_key[0]]}}</a></td>
</tr>
</tbody>
</table>
@@ -132,8 +156,8 @@
</thead>
<tbody>
<tr *ngFor="let genre of topGenres">
<td><a [routerLink]="['/genre', genre.key]">{{genre.key}}</a></td>
<td>{{genre.doc_count}}</td>
<td><a [routerLink]="['/genre', genre.value]">{{genre.value}}</a></td>
<td>{{genre.count}}</td>
</tr>
</tbody>
</table>
@@ -149,8 +173,8 @@
</thead>
<tbody>
<tr *ngFor="let genre of bottomGenres">
<td><a [routerLink]="['/genre', genre.key]">{{genre.key}}</a></td>
<td>{{genre.doc_count}}</td>
<td><a [routerLink]="['/genre', genre.value]">{{genre.value}}</a></td>
<td>{{genre.count}}</td>
</tr>
</tbody>
</table>

View File

@@ -1,17 +1,21 @@
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { ElsService } from './../els.service';
import { ElsService } from '../els-service/els.service';
import { Song } from './../model/song';
import { Bucket } from './../model/bucket';
import { Album } from './../model/album';
import { Artist } from './../model/artist';
import { Suggested } from '../model/suggested';
import {Observable, of, OperatorFunction} from 'rxjs';
import {catchError, debounceTime, distinctUntilChanged, map, tap, switchMap} from 'rxjs/operators';
import { TsService } from '../ts-service/ts.service';
import { TsBucket } from '../model/tsBucket';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: [ './dashboard.component.css' ]
})
export class DashboardComponent implements OnInit {
totalTime = 0;
totalSize = 0;
@@ -22,45 +26,42 @@ export class DashboardComponent implements OnInit {
albumArtistCount = 0;
topGenres: Bucket[] = [];
bottomGenres: Bucket[] = [];
topGenres: TsBucket[] = [];
bottomGenres: TsBucket[] = [];
mostPlayedSongs: Song[] = [];
lastAddedAlbums: Bucket[] = [];
lastAddedAlbums: TsBucket[] = [];
albumArtists = [];
constructor(private elsService: ElsService) { }
searchTerm = ''
suggested : Suggested[] = []
constructor(private tsService: TsService, private route: Router) { }
ngOnInit(): void {
this.elsService.getTime().then(result => {
this.tsService.getTime().then(result => {
this.totalTime = result;
});
this.elsService.getSize().then(result => this.totalSize = result);
this.tsService.getSize().then(result => this.totalSize = result);
this.elsService.getCountSong(ElsService.SONG_INDEX_NAME)
this.tsService.getCountSong(ElsService.SONG_INDEX_NAME)
.then(result => this.trackCountSong = result);
// TODO: Unused information
// this.elsService.getCountSong(ElsService.ARTIST_INDEX_NAME)
// .then(result => this.trackCountArtist = result);
// this.elsService.getCountSong(ElsService.ALBUM_INDEX_NAME)
// .then(result => this.trackCountAlbum = result);
this.elsService.getCountNeverListenSong()
this.tsService.getCountNeverListenSong()
.then(result => this.neverListenSong = result);
this.elsService.getMostPlayedTrack().subscribe(
this.tsService.getMostPlayedTrack().subscribe(
data => this.mostPlayedSongs = data
);
this.elsService.getGenres().subscribe(data => this.topGenres = data);
this.elsService.getGenres('asc').subscribe(data => this.bottomGenres = data);
// this.elsService.getGenreCount().subscribe(data => console.log(data));
this.tsService.getGenres().subscribe(data => this.topGenres = data);
this.tsService.getGenres('asc').subscribe(data => this.bottomGenres = data);
this.tsService.getGenreCount().subscribe(data => console.log(data));
const lastAddedAlbumsTemp: Bucket[] = [];
const BreakException = {};
this.elsService.getLastAddedAlbums(6).subscribe(buckets => {
const lastAddedAlbumsTemp: TsBucket[] = [];
this.tsService.getLastAddedAlbums(6).subscribe(buckets => {
buckets.forEach(bucket => {
// console.log(bucket);
@@ -69,8 +70,8 @@ export class DashboardComponent implements OnInit {
} else {
let found = false;
lastAddedAlbumsTemp.forEach(element => {
if (element.key === bucket.key) {
element.doc_count += bucket.doc_count;
if (element.group_key === bucket.group_key) {
element.found += bucket.found;
found = true;
}
});
@@ -79,24 +80,22 @@ export class DashboardComponent implements OnInit {
}
}
});
// console.log("alors");
// console.log(lastAddedAlbumsTemp);
this.lastAddedAlbums = lastAddedAlbumsTemp;
this.lastAddedAlbums.forEach(bucket => this.getArtistName(bucket));
});
}
private getArtistName(albumBucket: Bucket) {
private getArtistName(albumBucket: TsBucket) {
// For each bucket.key (album name), search artist.
// Use track count to compare
this.elsService.getArtistFromAlbumName(albumBucket.key).subscribe(albums => {
this.tsService.getArtistFromAlbumName(albumBucket.group_key[0]).subscribe(albums => {
// Identification of the good album
let goodAlbum;
if (albums.length > 1) {
// More than one result for an album name: search good by track count
albums.forEach(album => {
if (album['Track Count'] === albumBucket.doc_count) {
if (album['Track Count'] === albumBucket.found) {
goodAlbum = album;
}
});
@@ -113,4 +112,27 @@ export class DashboardComponent implements OnInit {
}
});
}
onSelectSearch($event) {
this.route.navigate(['/' + $event.item.type + '/' + $event.item.name])
}
searching = false;
searchFailed = false;
search: OperatorFunction<string, readonly string[]> = (text$: Observable<string>) =>
text$.pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => this.searching = true),
switchMap(term =>
this.tsService.getSuggest(term).pipe(
tap(() => this.searchFailed = false),
catchError(() => {
this.searchFailed = true;
return of([]);
}))
),
tap(() => this.searching = false)
)
}

View File

@@ -1,10 +1,10 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http'
import { HttpClient } from '@angular/common/http'
import { Observable } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { Album } from './model/album';
import { Album } from './../model/album';
import { ElsService } from './els.service';
@Injectable()

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ElsArtistService } from './els-artist.service';
describe('ElsArtistService', () => {
let service: ElsArtistService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ElsArtistService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,121 @@
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { catchError, map } from "rxjs/operators";
import { Artist } from "../model/artist";
import { Song } from "../model/song";
import { ElsService } from "./els.service";
@Injectable()
export class ElsArtistService extends ElsService {
constructor(protected http: HttpClient) {
super(http);
}
private getQuerySongsWithArtistName(
artistName: string,
sortFilter: boolean = false,
size: number = 0,
from: number = 0
) {
let query = {
query: {
bool: {
should: [
{ match_phrase: { "Album Artist": artistName } },
{ match_phrase: { Artist: artistName } },
],
must_not: [],
},
},
};
if (sortFilter) {
console.log("ElsArtistService- TO SORT filter enabled");
query = this.addSortFilterToQuery(query);
}
if (size) {
query["size"] = size;
}
if (from) {
query["from"] = from;
}
return query;
}
public getArtist(artistName: string): Observable<Artist> {
return this.http
.post(
this.elsUrl + ElsService.ARTIST_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
query: {
match_phrase: { Artist: artistName },
},
size: ElsService.DEFAULT_SIZE,
}),
{ headers: this.headers }
)
.pipe(
map((res) => this.responseToOneTypedResult<Artist>(res, artistName)),
catchError((error) =>
this.handleError(error, "getArtist(" + artistName + ")")
)
);
}
public getArtistSongs(
artistName: string,
from: number = 0,
sortFilter = false
): Observable<Song[]> {
console.info(
"getArtistSongs- Artist name: " + artistName + " - from: " + from
);
let query = this.getQuerySongsWithArtistName(
artistName,
sortFilter,
ElsService.DEFAULT_SIZE,
from
);
return this.http
.post(
this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify(query),
{ headers: this.headers }
)
.pipe(
map((res) => this.responseToSongs(res)),
catchError((error) =>
this.handleError(
error,
"getArtistSongs(" + artistName + "," + from + ")"
)
)
);
}
public getCountArtistSong(
artistName: string,
sortFilter = false
): Observable<number> {
console.log("artistname: " + artistName);
const query = this.getQuerySongsWithArtistName(artistName, sortFilter);
return this.http
.post<any>(
this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_COUNT,
JSON.stringify(query),
{ headers: this.headers }
)
.pipe(
map((res) => res.count as number),
catchError((error) =>
this.handleError(error, "getCountArtistSong" + artistName + ")")
)
);
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ElsSortService } from './els-sort.service';
describe('ElsSortService', () => {
let service: ElsSortService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ElsSortService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,169 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { ElsService } from './els.service';
import { Album } from './../model/album';
@Injectable({
providedIn: 'root'
})
export class ElsSortService extends ElsService {
constructor(protected http: HttpClient) {
super(http);
}
getTime(): Promise<number> {
return this.http
.post<any>(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
query: {
bool: {
must_not: [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
},
aggs: {
sum_time: {
sum: { field: 'Total Time'}
}
},
'size': 0
}), {headers: this.headers})
.toPromise()
.then(res => res.aggregations.sum_time.value as number)
.catch(error => this.handleError(error, 'getTime()'));
}
getSize(): Promise<number> {
return this.http
.post<any>(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
query: {
bool: {
must_not: [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
},
aggs: {
sum_time: {
sum: { field: 'Size' }
}
},
'size': 0
}), {headers: this.headers})
.toPromise()
.then(res => res.aggregations.sum_time.value as number)
.catch(error => this.handleError(error, 'getSize()'));
}
getCountSong(): Promise<number> {
return this.http
.post<any>(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_COUNT,
JSON.stringify({
query: {
bool: {
must_not: [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
}
}), {headers: this.headers})
.toPromise()
.then(res => res.count as number)
.catch(error => this.handleError(error, 'getCountSong()'));
}
getCountNeverListenSong(): Promise<number> {
return this.http
.post<any>(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_COUNT,
JSON.stringify({
'query': {
'bool': {
'must_not': [
{
'exists': { 'field': 'Play Count'}
}, {
term: {
"Location.tree": "/F:/Musique"
} }
]
}
}
}), {headers: this.headers})
.toPromise()
.then(res => res.count as number)
.catch(error => this.handleError(error, 'getCountNeverListenSong()'));
}
getNbAlbums(): Promise<number> {
return this.http
.post<any>(this.elsUrl + ElsService.ALBUM_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
'query': {
'bool': {
'must_not': [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
},
size: 0,
"aggs": {
"album_count": {
"cardinality": {
"field": "Album.raw"
}
}
}
}), {headers: this.headers})
.toPromise()
.then(res => res.aggregations.album_count.value as number )
.catch(error => this.handleError(error, 'getNbAlbums()'));
}
getAlbums(): Observable<Album[]> {
return this.http
.post(this.elsUrl + ElsService.ALBUM_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
query: {
bool: {
must_not: [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
},
'size': 550,
"sort": [
{ "Play Count": "desc"}
]
}), {headers: this.headers})
.pipe(
map(res => this.responseToAlbums(res)),
catchError(error => this.handleError(error, 'getAlbums'))
);
}
}

View File

@@ -1,13 +1,14 @@
import { Injectable } from '@angular/core';
import { Inject, Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http'
import { Observable } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { Song } from './model/song';
import { Album } from './model/album';
import { Artist } from './model/artist';
import { Bucket } from './model/bucket';
import { Song } from '../model/song';
import { Album } from '../model/album';
import { Artist } from '../model/artist';
import { Bucket } from '../model/bucket';
import { Suggested } from '../model/suggested';
@Injectable()
export class ElsService {
@@ -15,12 +16,14 @@ export class ElsService {
public static readonly SONG_INDEX_NAME = '/itunes-songs';
public static readonly ARTIST_INDEX_NAME = '/itunes-artists';
public static readonly ALBUM_INDEX_NAME = '/itunes-albums';
public static readonly SUGGEST_INDEX_NAME = '/itunes-suggest';
protected static readonly ACTION_SEARCH = '/_search';
protected static readonly ACTION_COUNT = '/_count';
protected elsUrl = 'http://localhost:9200';
protected elsUrl = 'http://192.168.1.20:9200';
protected headers = new HttpHeaders({'Content-Type': 'application/json'});
protected defaultLocation = "/F:/Musique"; // TODO Use conf
constructor(protected http: HttpClient) { }
@@ -170,20 +173,48 @@ export class ElsService {
);
}
getAlbumSongs(albumName: string, from: number = 0): Observable<Song[]> {
console.info('getAlbumSongs- Album name: ' + albumName + ' - from: ' + from);
return this.http
.post(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
'query': {
'match_phrase': { 'Album': albumName }
getAlbumSongs(albumName: string, from: number = 0, toSortFilter = false): Observable<Song[]> {
// TODO Move in els-album service
console.info(
"getAlbumSongs- Album name: " + albumName + " - from: " + from
);
let query = {
query: {
bool: {
must: [
{
match_phrase: {
"Album.raw": albumName,
},
'size': ElsService.DEFAULT_SIZE,
'from': from
}), {headers: this.headers})
},
],
must_not: [],
},
},
size: ElsService.DEFAULT_SIZE,
from: from,
};
if (toSortFilter) {
console.log("getAlbumSongs- TO SORT filter enabled");
query = this.addSortFilterToQuery(query)
}
return this.http
.post(
this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify(query),
{ headers: this.headers }
)
.pipe(
map(res => this.responseToSongs(res)),
catchError(error => this.handleError(error, 'getAlbumSongs(' + albumName + ',' + from + ')'))
map((res) => this.responseToSongs(res)),
catchError((error) =>
this.handleError(
error,
"getAlbumSongs(" + albumName + "," + from + ")"
)
)
);
}
@@ -201,31 +232,11 @@ export class ElsService {
}), {headers: this.headers})
.pipe(
map(res => this.responseToSongs(res)),
catchError(error => this.handleError(error, 'getAlbumSongs(' + genreName + ',' + from + ')'))
catchError(error => this.handleError(error, 'getGenreSongs(' + genreName + ',' + from + ')'))
);
}
getArtistSongs(artistName: string, from: number = 0): Observable<Song[]> {
console.info('getArtistSongs- Artist name: ' + artistName + ' - from: ' + from);
return this.http
.post(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
'query': {
'bool': {
'should': [
{'match_phrase' : { 'Album Artist' : artistName }},
{'match_phrase' : { 'Artist' : artistName }}
]
}
},
'size': ElsService.DEFAULT_SIZE,
'from': from
}), {headers: this.headers})
.pipe(
map(res => this.responseToSongs(res)),
catchError(error => this.handleError(error, 'getArtistSongs(' + artistName + ',' + from + ')'))
);
}
getAlbum(albumName: string): Observable<Album> {
// TODO Why this is used on album pages?
@@ -244,21 +255,6 @@ export class ElsService {
);
}
getArtist(artistName: string): Observable<Artist> {
return this.http
.post(this.elsUrl + ElsService.ARTIST_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
'query': {
'match_phrase': { 'Artist': artistName }
},
'size': ElsService.DEFAULT_SIZE
}), {headers: this.headers})
.pipe(
map(res => this.responseToOneTypedResult<Artist>(res, artistName)),
catchError(error => this.handleError(error, 'getArtist(' + artistName + ')'))
);
}
getGenres(ordering: string = 'desc'): Observable<Bucket[]> {
return this.http
.post(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_SEARCH,
@@ -281,25 +277,6 @@ export class ElsService {
);
}
// getGenreCount(): Observable<number> {
// return this.http
// .post<any>(this.elsUrl + 'song' + ElsService.ACTION_SEARCH,
// JSON.stringify({
// 'aggs' : {
// 'genres' : {
// 'cardinality' : {
// 'field' : 'Genre.original',
// 'missing': 'N/A',
// }
// }
// },
// 'size': 0
// }), {headers: this.headers})
// .pipe(
// map(res => res.aggregations.genres.value)
// );
// }
getLastAddedAlbums(month: number): Observable<Bucket[]> {
return this.http
.post(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_SEARCH,
@@ -317,7 +294,7 @@ export class ElsService {
'date' : {
'terms': {
'field' : 'Date Added',
'order': { '_term': 'desc' },
'order': { '_key': 'desc' },
'size': 20
},
'aggs': {
@@ -337,7 +314,7 @@ export class ElsService {
// TODO Take in consideration "sum_other_doc_count"
}
getArtistFromAlbumName(albumname: string): Observable<Album[]> {
getArtistFromAlbumName(albumname: string): Observable<Album[]> { // TODO Rename ?
return this.http
.post<any>(this.elsUrl + ElsService.ALBUM_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
@@ -361,24 +338,32 @@ export class ElsService {
);
}
getCountArtistSong(artistName: string): Observable<number> {
console.log('artistname: ' + artistName);
getSuggest(text: string): Observable<Suggested[]> {
console.log('search sugget: ' + text);
return this.http
.post<any>(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_COUNT,
.post<any>(this.elsUrl + ElsService.SUGGEST_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
'query': {
'bool': {
'should': [
{'match_phrase' : { 'Album Artist' : artistName }},
{'match_phrase' : { 'Artist' : artistName }}
]
'_source': ['album', 'artist'],
'suggest': {
'album-suggest': {
'prefix': text,
'completion': {
'field': 'album_suggest'
}
},
'artist-suggest': {
'prefix': text,
'completion': {
'field': 'artist_suggest'
}
}
}
}), {headers: this.headers})
}), {headers: this.headers})
.pipe(
map(res => res.count as number),
catchError(error => this.handleError(error, 'getCountArtistSong' + artistName + ')'))
map(res => this.responseSuggesterToSuggested(res, 'album-suggest', 'artist-suggest')),
catchError(error => this.handleError(error, 'getSuggest(' + text + ')'))
);
}
/** Process a result to return just one result.
@@ -388,7 +373,7 @@ export class ElsService {
* @param res Response to process
* @param name The searched name - for console output
*/
private responseToOneTypedResult<T>(res: any, name: string): T {
protected responseToOneTypedResult<T>(res: any, name: string): T {
const hits = res.hits.hits;
if (hits.length < 1) {
@@ -406,7 +391,7 @@ export class ElsService {
*
* @param res Response to process
*/
private responseToSongs(res: any): Song[] {
protected responseToSongs(res: any): Song[] {
const result: Array<Song> = [];
res.hits.hits.forEach((hit) => {
result.push(hit._source);
@@ -444,7 +429,7 @@ export class ElsService {
* @param res Response to process
* @param name Name of aggregation
*/
private responseAggregationToBucket(res: any, name: string): Bucket[] {
protected responseAggregationToBucket(res: any, name: string): Bucket[] {
const result: Array<Bucket> = [];
res.aggregations[name].buckets.forEach((bucket) => {
result.push(bucket);
@@ -462,6 +447,20 @@ export class ElsService {
return result;
}
protected responseSuggesterToSuggested(res: any, ...suggestName: string[]): Suggested[] {
const result: Array<Suggested> = []
suggestName.forEach(sname => {
res['suggest'][sname][0]['options'].forEach(option => {
let suggest = new Suggested()
// TODO If more than one key, raise exception
suggest.type = String(Object.keys(option['_source']))
suggest.name = option['_source'][suggest.type]
result.push(suggest)
});
});
return result;
}
protected handleError(error: any, origin: string): Promise<any> {
console.error('An error occurred!');
console.error('Origin function: ', origin);
@@ -469,4 +468,13 @@ export class ElsService {
console.error(error); // for demo purposes only
return Promise.reject(error.message || error);
}
protected addSortFilterToQuery(query) {
query.query.bool.must_not.push({
term: {
"Location.tree": this.defaultLocation,
},
});
return query;
}
}

View File

@@ -1,7 +1,7 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { ElsService } from '../els.service';
import { TsService } from '../ts-service/ts.service';
import { SongTableComponent } from '../song-table/song-table.component';
import { Song } from '../model/song';
@@ -17,7 +17,7 @@ export class GenreComponent implements OnInit {
songs: Array<Song> = [];
constructor(
private elsService: ElsService,
private tsService: TsService,
private route: ActivatedRoute
) { }
@@ -29,7 +29,7 @@ export class GenreComponent implements OnInit {
}
loadSongs(): any {
this.elsService.getGenreSongs(this.genreName, this.songs.length).subscribe(
this.tsService.getGenreSongs(this.genreName, this.songs.length).subscribe(
data => {
// this.moreDataAvailable = data.length === ElsService.DEFAULT_SIZE;

View File

@@ -1,5 +1,7 @@
export class Album {
id: number;
Name: string;
Cover: string;
Album: string;
Artist: Array<string>;
Rating: number;

View File

@@ -0,0 +1,4 @@
export class Suggested {
name: string;
type: string;
}

View File

@@ -0,0 +1,6 @@
export class TsBucket {
value: string;
count: number;
group_key: string[];
found: number;
}

View File

@@ -0,0 +1,38 @@
.panel-stat {
min-height: 102px;
}
.panel-green {
background-color: #16a085;
color:white;
}
.panel-yellow {
background-color: #f1c40f;
color:white;
}
.panel-red {
background-color: #d9534f;
color:white;
}
.panel-purple {
background-color: #9b59b6;
color:white;
}
.panel-blue {
background-color: #3498db;
color:white;
}
.stats_icon {
font-size: 3em;
}
.circle {
border-radius: 50%;
background-color: #9b59b6;
margin: 0 auto;
}

View File

@@ -0,0 +1,132 @@
<div class="container">
<h1>To sort Songs - </h1>
<br />
<div class="row cardAdmin">
<div class="col-lg-3 col-md-3">
<div class="panel panel-yellow">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<span class="glyphicon glyphicon-play stats_icon"></span>
</div>
<div class="col-xs-9 text-right">
<div>
<h3 *ngIf="!trackCountSong"><span class="glyphicon glyphicon-refresh loading"></span></h3>
<h3 *ngIf="trackCountSong">{{trackCountSong}} songs</h3>
</div>
<div>Total songs
<br>~{{totalTime | convertMs}}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-3">
<div class="panel panel-blue">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<span class="glyphicon glyphicon-cd stats_icon"></span>
</div>
<div class="col-xs-9 text-right">
<div>
<h3 *ngIf="!neverListenSong"><span class="glyphicon glyphicon-refresh loading"></span></h3>
<h3 *ngIf="neverListenSong">{{nbAlbums}}</h3>
</div>
<div><br>Albums</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-3">
<div class="panel panel-green">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<span class="glyphicon glyphicon-hdd stats_icon"></span>
</div>
<div class="col-xs-9 text-right">
<div>
<h3 *ngIf="!totalSize"><span class="glyphicon glyphicon-refresh loading"></span></h3>
<h3 *ngIf="totalSize">{{totalSize | convertSizeToString}}</h3>
</div>
<div><br>Total size</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-3">
<div class="panel panel-red">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<span class="glyphicon glyphicon-stop stats_icon"></span>
</div>
<div class="col-xs-9 text-right">
<div>
<h3 *ngIf="!neverListenSong"><span class="glyphicon glyphicon-refresh loading"></span></h3>
<h3 *ngIf="neverListenSong">{{neverListenSong}}</h3>
</div>
<div><br>Never list. songs (~{{neverListenSong / trackCountSong * 100 | round}}%)</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-12">
<table class="table table-striped" style="white-space: nowrap;">
<thead>
<tr>
<th>Album</th>
<th>Track Count</th>
<th>Artist/Album Artist</th>
<th>Play Count</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let album of albums">
<td>
<a [routerLink]="['/album', album.Name]" [queryParams]="{tosort: true}">{{album.Name}}</a>&nbsp;
</td>
<td>{{album['Track Count']}}</td>
<ng-template [ngIf]="album['Album Artist']" [ngIfElse]="artistSection">
<td>
<a [routerLink]="['/artist', album['Album Artist']]" [queryParams]="{tosort: true}">{{album['Album Artist']}}</a>&nbsp;
</td>
</ng-template>
<ng-template #artistSection>
<td>
<a [routerLink]="['/artist', album.Artist[0]]">{{album.Artist}}</a>&nbsp;
</td>
</ng-template>
<ng-template #artistSection>
<td>
<a [routerLink]="['/artist', albums[album.key].Artist[0]]">{{albums[album.key].Artist}}</a>&nbsp;
</td>
</ng-template>
<td>
{{album['Play Count']}} ({{album['Play Count']/album['Track Count'] | number:'1.0-0'}}/songs)
</td>
<td class="star" [title]="(album['Album Rating Computed']?'Computed Rating: ':'Rating: ') + album['Album Rating']">
<span *ngFor="let item of numberToArray(album['Album Rating'], 20)">
<span class="glyphicon" [ngClass]="album['Album Rating Computed']?'glyphicon-star-empty':'glyphicon-star'"></span>
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ToSortComponent } from './to-sort.component';
describe('ToSortComponent', () => {
let component: ToSortComponent;
let fixture: ComponentFixture<ToSortComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ToSortComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ToSortComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,38 @@
import { Component, OnInit } from '@angular/core';
import { ElsSortService } from '../els-service/els-sort.service';
import { Bucket } from '../model/bucket';
import { Utils } from '../utils';
@Component({
selector: 'app-to-sort',
templateUrl: './to-sort.component.html',
styleUrls: ['./to-sort.component.css']
})
export class ToSortComponent implements OnInit {
numberToArray = Utils.numberToArray;
// Stats var
totalTime = 0;
totalSize = 0;
trackCountSong = 0;
neverListenSong = 0;
nbAlbums = 0;
// Global var
toSortAlbum: Bucket[] = [];
albums = []
constructor(private elsService: ElsSortService) { }
ngOnInit(): void {
// **** GET STATS ****//
this.elsService.getTime().then(result => this.totalTime = result);
this.elsService.getSize().then(result => this.totalSize = result);
this.elsService.getCountSong().then(result => this.trackCountSong = result);
this.elsService.getCountNeverListenSong().then(result => this.neverListenSong = result);
this.elsService.getNbAlbums().then(result => this.nbAlbums = result);
this.elsService.getAlbums().subscribe(data => this.albums = data)
}
}

View File

@@ -1,6 +1,6 @@
import { Component, OnInit } from '@angular/core';
import { ElsService } from '../els.service';
import { TsService } from '../ts-service/ts.service';
import { Album } from '../model/album';
import { Artist } from '../model/artist';
@@ -15,10 +15,10 @@ export class TopPlayedComponent implements OnInit {
mostPlayedArtistsNaive: Artist[] = [];
mostPlayedArtists: Artist[] = [];
constructor(private elsService: ElsService) { }
constructor(private tsService: TsService) { }
ngOnInit() {
this.elsService.getMostPlayedAlbumNaive()
this.tsService.getMostPlayedAlbumNaive()
.then(result => {
result.forEach(album => {
if (album.Artist.length <= 10) {
@@ -29,29 +29,29 @@ export class TopPlayedComponent implements OnInit {
this.mostPlayedAlbumsNaive.sort((a: any, b: any) => this.sortByAveragePlay(a, b)).splice(10);
});
this.elsService.getMostPlayedAlbum().subscribe(result => {
this.mostPlayedAlbums = result;
// this.elsService.getMostPlayedAlbum().subscribe(result => {
// this.mostPlayedAlbums = result;
this.mostPlayedAlbums.sort((a: any, b: any) => this.sortByAveragePlay(a, b)).splice(10);
// TODO Load more! (Use a )
});
// this.mostPlayedAlbums.sort((a: any, b: any) => this.sortByAveragePlay(a, b)).splice(10);
// // TODO Load more! (Use a )
// });
this.elsService.getMostPlayedArtistNaive()
this.tsService.getMostPlayedArtistNaive()
.then(result => {
this.mostPlayedArtistsNaive = result;
this.mostPlayedArtistsNaive.sort((a: any, b: any) => this.sortByAveragePlay(a, b)).splice(10);
});
this.elsService.getMostPlayedArtist().subscribe(result => {
result.forEach(artist => {
if (artist['Track Count'] > 10) {
this.mostPlayedArtists.push(artist);
}
});
// this.tsService.getMostPlayedArtist().subscribe(result => {
// result.forEach(artist => {
// if (artist['Track Count'] > 10) {
// this.mostPlayedArtists.push(artist);
// }
// });
this.mostPlayedArtists.sort((a: any, b: any) => this.sortByAveragePlay(a, b)).splice(10);
});
// this.mostPlayedArtists.sort((a: any, b: any) => this.sortByAveragePlay(a, b)).splice(10);
// });
}
sortByAveragePlay(a: any, b: any) {

View File

@@ -0,0 +1,101 @@
import { HttpClient, HttpParams } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { catchError, map } from "rxjs/operators";
import { TsService } from "./ts.service";
import { Album } from "../model/album";
import { Song } from "../model/song";
@Injectable()
export class TsAlbumService extends TsService {
constructor(protected http: HttpClient) {
super(http);
}
getAlbum(albumName: string): Observable<Album> {
let queryParams = new HttpParams();
queryParams = queryParams.append("q", albumName);
queryParams = queryParams.append("query_by", "Name");
console.log("coucou");
return this.http
.get(
this.tsUrl +
TsService.ALBUM_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH,
{ headers: this.headers, params: queryParams }
)
.pipe(
map((res) => this.responseToOneTypedResult<Album>(res, albumName)),
catchError((error) =>
this.handleError(error, "getAlbum(" + albumName + ")")
)
);
}
getAlbumSongs(
albumName: string,
from: number = 0,
toSortFilter = false
): Observable<Song[]> {
// TODO Move in els-album service
console.info(
"getAlbumSongs- Album name: " + albumName + " - from: " + from
);
let queryParams = new HttpParams();
queryParams = queryParams.append("q", albumName);
queryParams = queryParams.append("query_by", "Album");
queryParams = queryParams.append("per_page", TsService.DEFAULT_SIZE);
queryParams = queryParams.append("offset", from);
return this.http
.get<any>(
this.tsUrl +
TsService.SONG_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH,
{ headers: this.headers, params: queryParams }
)
.pipe(
map((res) => this.responseToSongs(res)),
catchError((error) =>
this.handleError(
error,
"getAlbumSongs(" + albumName + "," + from + ")"
)
)
);
}
public static GET_ALBUMS_DEFAULT_PARAMS(): HttpParams {
let queryParams = new HttpParams();
queryParams = queryParams.append("q", "*");
queryParams = queryParams.append(
"sort_by",
"Play Count:desc,Avg Bit Rate:desc"
);
queryParams = queryParams.append("filter_by", "Min Bit Rate:<128");
queryParams = queryParams.append("per_page", "100");
return queryParams;
}
getAlbums(queryParams: HttpParams): Observable<Album[]> {
return this.http
.get<any>(
this.tsUrl +
TsService.ALBUM_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH,
{ headers: this.headers, params: queryParams }
)
.pipe(
map((res) => this.responseToAlbums(res)),
catchError((error) => this.handleError(error, "getAlbums"))
);
}
}

View File

@@ -0,0 +1,140 @@
import { HttpClient, HttpParams } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { catchError, map } from "rxjs/operators";
import { TsService } from "./ts.service";
import { Artist } from "../model/artist";
import { Song } from "../model/song";
@Injectable()
export class TsArtistService extends TsService {
constructor(protected http: HttpClient) {
super(http);
}
private getQuerySongsWithArtistName(
artistName: string,
sortFilter: boolean = false,
size: number = 0,
from: number = 0
) {
let query = {
query: {
bool: {
should: [
{ match_phrase: { "Album Artist": artistName } },
{ match_phrase: { Artist: artistName } },
],
must_not: [],
},
},
};
if (sortFilter) {
console.log("ElsArtistService- TO SORT filter enabled");
query = this.addSortFilterToQuery(query);
}
if (size) {
query["size"] = size;
}
if (from) {
query["from"] = from;
}
return query;
}
public getArtist(artistName: string): Observable<Artist> {
console.log("getArtist");
let queryParams = new HttpParams();
queryParams = queryParams.append("q", artistName);
queryParams = queryParams.append("query_by", "Name");
return this.http
.get<any>(
this.tsUrl +
TsService.ARTIST_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH,
{ headers: this.headers, params: queryParams }
)
.pipe(
map((res) => this.responseToOneTypedResult<Artist>(res, artistName)),
catchError((error) =>
this.handleError(error, "getArtist(" + artistName + ")")
)
);
}
public getArtistSongs(
artistName: string,
from: number = 0,
sortFilter = false
): Observable<Song[]> {
console.info(
"getArtistSongs- Artist name: " + artistName + " - from: " + from
);
let query = this.getQuerySongsWithArtistName(
artistName,
sortFilter,
TsService.DEFAULT_SIZE,
from
);
let queryParams = new HttpParams();
queryParams = queryParams.append("q", artistName);
queryParams = queryParams.append("query_by", "Artist,Album Artist");
queryParams = queryParams.append("per_page", TsService.DEFAULT_SIZE);
queryParams = queryParams.append("offset", from);
return this.http
.get<any>(
this.tsUrl +
TsService.SONG_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH,
{ headers: this.headers, params: queryParams }
)
.pipe(
map((res) => this.responseToSongs(res)),
catchError((error) =>
this.handleError(
error,
"getArtistSongs(" + artistName + "," + from + ")"
)
)
);
}
public getCountArtistSong(
artistName: string,
sortFilter = false
): Observable<number> {
console.log("artistname: " + artistName);
const query = this.getQuerySongsWithArtistName(artistName, sortFilter);
let queryParams = new HttpParams();
queryParams = queryParams.append("q", artistName);
queryParams = queryParams.append("query_by", "Artist"); // QUESTION ArtistName?
return this.http
.get<any>(
this.tsUrl +
TsService.SONG_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH,
{ headers: this.headers, params: queryParams }
)
.pipe(
map((res) => res.found as number),
catchError((error) =>
this.handleError(error, "getCountArtistSong" + artistName + ")")
)
);
}
}

View File

@@ -0,0 +1,169 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { TsService } from './ts.service';
import { Album } from '../model/album';
@Injectable({
providedIn: 'root'
})
export class TsSortService extends TsService {
constructor(protected http: HttpClient) {
super(http);
}
getTime(): Promise<number> {
return this.http
.post<any>(this.tsUrl + TsService.SONG_INDEX_NAME + TsService.ACTION_SEARCH,
JSON.stringify({
query: {
bool: {
must_not: [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
},
aggs: {
sum_time: {
sum: { field: 'Total Time'}
}
},
'size': 0
}), {headers: this.headers})
.toPromise()
.then(res => res.aggregations.sum_time.value as number)
.catch(error => this.handleError(error, 'getTime()'));
}
getSize(): Promise<number> {
return this.http
.post<any>(this.tsUrl + TsService.SONG_INDEX_NAME + TsService.ACTION_SEARCH,
JSON.stringify({
query: {
bool: {
must_not: [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
},
aggs: {
sum_time: {
sum: { field: 'Size' }
}
},
'size': 0
}), {headers: this.headers})
.toPromise()
.then(res => res.aggregations.sum_time.value as number)
.catch(error => this.handleError(error, 'getSize()'));
}
getCountSong(): Promise<number> {
return this.http
.post<any>(this.tsUrl + TsService.SONG_INDEX_NAME + TsService.ACTION_COUNT,
JSON.stringify({
query: {
bool: {
must_not: [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
}
}), {headers: this.headers})
.toPromise()
.then(res => res.count as number)
.catch(error => this.handleError(error, 'getCountSong()'));
}
getCountNeverListenSong(): Promise<number> {
return this.http
.post<any>(this.tsUrl + TsService.SONG_INDEX_NAME + TsService.ACTION_COUNT,
JSON.stringify({
'query': {
'bool': {
'must_not': [
{
'exists': { 'field': 'Play Count'}
}, {
term: {
"Location.tree": "/F:/Musique"
} }
]
}
}
}), {headers: this.headers})
.toPromise()
.then(res => res.count as number)
.catch(error => this.handleError(error, 'getCountNeverListenSong()'));
}
getNbAlbums(): Promise<number> {
return this.http
.post<any>(this.tsUrl + TsService.ALBUM_INDEX_NAME + TsService.ACTION_SEARCH,
JSON.stringify({
'query': {
'bool': {
'must_not': [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
},
size: 0,
"aggs": {
"album_count": {
"cardinality": {
"field": "Album.raw"
}
}
}
}), {headers: this.headers})
.toPromise()
.then(res => res.aggregations.album_count.value as number )
.catch(error => this.handleError(error, 'getNbAlbums()'));
}
getAlbums(): Observable<Album[]> {
return this.http
.post(this.tsUrl + TsService.ALBUM_INDEX_NAME + TsService.ACTION_SEARCH,
JSON.stringify({
query: {
bool: {
must_not: [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
},
'size': 550,
"sort": [
{ "Play Count": "desc"}
]
}), {headers: this.headers})
.pipe(
map(res => this.responseToAlbums(res)),
catchError(error => this.handleError(error, 'getAlbums'))
);
}
}

View File

@@ -0,0 +1,456 @@
import { HttpClient, HttpHeaders, HttpParams } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { catchError, map } from "rxjs/operators";
import { Album } from "../model/album";
import { Artist } from "../model/artist";
import { Song } from "../model/song";
import { Suggested } from "../model/suggested";
import { TsBucket } from "../model/tsBucket";
@Injectable()
export class TsService {
public static readonly DEFAULT_SIZE: number = 20;
public static readonly SONG_INDEX_NAME = "/songs";
public static readonly ARTIST_INDEX_NAME = "/artists";
public static readonly ALBUM_INDEX_NAME = "/albums";
public static readonly SUGGEST_INDEX_NAME = "/suggest";
protected static readonly ACTION_SEARCH = "/search";
protected static readonly ACTION_COUNT = "/_count";
protected tsUrl = "http://localhost:8108/collections";
protected tsUrlPure = "http://localhost:8108/";
protected headers = new HttpHeaders({
"Content-Type": "application/json",
"X-TYPESENSE-API-KEY": "toto",
});
protected defaultLocation = "/F:/Musique"; // TODO Use conf
constructor(protected http: HttpClient) {}
getTime(): Promise<number> {
let queryParams = new HttpParams();
queryParams = queryParams.append("q", "*");
queryParams = queryParams.append("limit", "0");
queryParams = queryParams.append(
"facet_by",
"Total Time(Range:[0,99999999999999999])"
);
return this.http
.get<any>(
this.tsUrl +
TsService.SONG_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH,
{ headers: this.headers, params: queryParams }
)
.toPromise()
.then((res) => res.facet_counts[0].stats.sum as number)
.catch((error) => this.handleError(error, "getTime()"));
}
getSize(): Promise<number> {
let queryParams = new HttpParams();
queryParams = queryParams.append("q", "*");
queryParams = queryParams.append("limit", "0");
queryParams = queryParams.append(
"facet_by",
"Size(Range:[0,99999999999999999])"
);
return this.http
.get<any>(
this.tsUrl +
TsService.SONG_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH,
{ headers: this.headers, params: queryParams }
)
.toPromise()
.then((res) => res.facet_counts[0].stats.sum as number)
.catch((error) => this.handleError(error, "getSize()"));
}
getCountSong(index: string): Promise<number> {
return this.http
.get<any>(this.tsUrl + TsService.SONG_INDEX_NAME, {
headers: this.headers,
})
.toPromise()
.then((res) => res.num_documents as number)
.catch((error) => this.handleError(error, "getCountSong(" + index + ")"));
}
getCountNeverListenSong(): Promise<number> {
// TODO Impossible sans valeur par defaut dans Play Count
return new Promise((resolve) => {
resolve(0);
});
}
getMostPlayedTrack(): Observable<Song[]> {
let queryParams = new HttpParams();
queryParams = queryParams.append("q", "*");
queryParams = queryParams.append("sort_by", "Play Count:desc");
queryParams = queryParams.append("limit", 5);
return this.http
.get<any>(
this.tsUrl +
TsService.SONG_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH,
{ headers: this.headers, params: queryParams }
)
.pipe(
map((res) => this.responseToSongs(res)),
catchError((error) => this.handleError(error, "getMostPlayedTrack()"))
);
}
getMostPlayedArtist(): Observable<Artist[]> {
return this.http
.post(
this.tsUrl + TsService.ARTIST_INDEX_NAME + TsService.ACTION_SEARCH,
JSON.stringify({
sort: [
{
_script: {
type: "number",
script: {
inline: "doc['Play Count'].value / doc['Track Count'].value",
},
order: "desc",
},
},
],
size: 100,
}),
{ headers: this.headers }
)
.pipe(
map((res) => this.responseToArtists(res)),
catchError((error) => this.handleError(error, "getMostPlayedArtist()"))
);
}
/**
* A basic get of albums ordered by 'Play Count' field.
*/
getMostPlayedAlbumNaive(): Promise<Album[]> {
return this.http
.get(
this.tsUrl +
TsService.ALBUM_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH +
"?sort_by=Play%20Count%3Adesc,Track%20Count%3Adesc&limit=20&q=*",
{ headers: this.headers }
)
.toPromise()
.then((res) => this.responseToAlbums(res))
.catch((error) => this.handleError(error, "getMostPlayedAlbumNaive"));
// TODO Excluse 'Divers' + compilation
}
getMostPlayedArtistNaive(): Promise<Artist[]> {
return this.http
.get(
this.tsUrl +
TsService.ARTIST_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH +
"?sort_by=Play%20Count%3Adesc&limit=20&q=*",
{ headers: this.headers }
)
.toPromise()
.then((res) => this.responseToAlbums(res))
.catch((error) => this.handleError(error, "getMostPlayedArtistNaive"));
// TODO Excluse 'Divers' + compilation
}
getGenreSongs(genreName: string, from: number = 0): Observable<Song[]> {
console.info(
"getGenreSongs- Genre name: " + genreName + " - from: " + from
);
let queryParams = new HttpParams();
queryParams = queryParams.append("q", genreName);
queryParams = queryParams.append("query_by", "Genre");
queryParams = queryParams.append("per_page", TsService.DEFAULT_SIZE);
queryParams = queryParams.append("offset", from);
return this.http
.get<any>(
this.tsUrl +
TsService.SONG_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH,
{ headers: this.headers, params: queryParams }
)
.pipe(
map((res) => this.responseToSongs(res)),
catchError((error) =>
this.handleError(
error,
"getGenreSongs(" + genreName + "," + from + ")"
)
)
);
}
getGenres(ordering: string = "desc"): Observable<TsBucket[]> {
let queryParams = new HttpParams();
queryParams = queryParams.append("q", "*");
queryParams = queryParams.append("facet_by", "Genre");
queryParams = queryParams.append("max_facet_values", "5");
queryParams = queryParams.append("limit", "0");
return this.http
.get<any>(
this.tsUrl +
TsService.SONG_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH,
{ headers: this.headers, params: queryParams }
)
.pipe(
map((res) => this.responseAggregationToBucket(res)),
catchError((error) =>
this.handleError(error, "getGenres(" + ordering + ")")
)
);
}
getGenreCount(ordering: string = "desc"): Observable<number> {
let queryParams = new HttpParams();
queryParams = queryParams.append("q", "*");
queryParams = queryParams.append("facet_by", "Genre");
queryParams = queryParams.append("max_facet_values", "0");
queryParams = queryParams.append("limit", "0");
return this.http
.get<any>(
this.tsUrl +
TsService.SONG_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH,
{ headers: this.headers, params: queryParams }
)
.pipe(
map((res) => res.facet_counts[0].stats.total_values as number),
catchError((error) =>
this.handleError(error, "getGenres(" + ordering + ")")
)
);
}
getLastAddedAlbums(month: number): Observable<TsBucket[]> {
let queryParams = new HttpParams();
queryParams = queryParams.append("q", "*");
queryParams = queryParams.append("sort_by", "Track ID:desc");
queryParams = queryParams.append("group_by", "Album");
// TODO Deal with mounth?
return this.http
.get(
this.tsUrl +
TsService.SONG_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH,
{ headers: this.headers, params: queryParams }
)
.pipe(
map((res) => this.responseSubAggregationToBucket(res)),
catchError((error) =>
this.handleError(error, "getLastAddedAlbums(" + month + ")")
)
);
}
getArtistFromAlbumName(albumname: string): Observable<Album[]> {
let queryParams = new HttpParams();
queryParams = queryParams.append("q", albumname);
queryParams = queryParams.append("query_by", "Name");
return this.http
.get<any>(
this.tsUrl +
TsService.ALBUM_INDEX_NAME +
"/documents" +
TsService.ACTION_SEARCH,
{ headers: this.headers, params: queryParams }
)
.pipe(
map((res) => res.hits),
map((hits: Array<any>) => {
const result: Array<Album> = [];
hits.forEach((hit) => {
result.push(hit.document);
});
return result;
}),
catchError((error) =>
this.handleError(error, "getArtistFromAlbumName(" + albumname + ")")
)
);
}
getSuggest(text: string): Observable<Suggested[]> {
let queryParameters = new HttpParams();
queryParameters = queryParameters.append("query_by", "Name");
queryParameters = queryParameters.append(
"sort_by",
"_text_match:desc,Play Count:desc"
);
queryParameters = queryParameters.append("limit", 5);
console.log("search sugget: " + text);
return this.http
.post<any>(
this.tsUrlPure + "multi_search",
JSON.stringify({
searches: [
{
collection: "albums",
q: text,
},
{
collection: "artists",
q: text,
},
],
}),
{ headers: this.headers, params: queryParameters }
)
.pipe(
map((res) => this.responseSuggesterToSuggested(res)),
catchError((error) =>
this.handleError(error, "getSuggest(" + text + ")")
)
);
}
/** Process a result to return just one result.
* Used to get an album or an artist.
* Take a name to put in console output if no result or more than one result.
*
* @param res Response to process
* @param name The searched name - for console output
*/
protected responseToOneTypedResult<T>(res: any, name: string): T {
const hits = res.hits;
if (hits.length < 1) {
console.info('No result found for name: "' + name);
return undefined;
}
if (hits.length > 1) {
// TODO Cumul results (for album)
console.error(
'More than one result for name: "' +
name +
'". Found (' +
hits.length +
"), return the first."
);
}
return hits[0].document;
}
/** Process a response to a array of songs.
*
* @param res Response to process
*/
protected responseToSongs(res: any): Song[] {
const result: Array<Song> = [];
res.hits.forEach((hit) => {
result.push(hit.document);
});
return result;
}
/** Process a response to a array of songs.
*
* @param res Response to process
*/
private responseToArtists(res: any): Artist[] {
const result: Array<Artist> = [];
res.hits.hits.forEach((hit) => {
result.push(hit._source);
});
return result;
}
/** Process an aggregation response to an array of Bucket.
*
* @param res Response to process
* @param name Name of aggregation
*/
protected responseAggregationToBucket(res: any): TsBucket[] {
const result: Array<TsBucket> = [];
res.facet_counts[0].counts.forEach((bucket) => {
result.push(bucket);
});
return result;
}
private responseSubAggregationToBucket(res: any): TsBucket[] {
const result: Array<TsBucket> = [];
res.grouped_hits.forEach((tmp) => {
result.push(tmp);
});
return result;
}
protected responseSuggesterToSuggested(res: any): Suggested[] {
const suggesteds: Array<Suggested> = [];
res.results.forEach((result) => {
let type = result.request_params.collection_name;
type = type.slice(0, type.length - 1);
result.hits.forEach((hit) => {
let suggested = new Suggested();
suggested.type = type;
suggested.name = hit.document.Name;
suggesteds.push(suggested);
});
});
return suggesteds;
}
protected handleError(error: any, origin: string): Promise<any> {
console.error("An error occurred!");
console.error("Origin function: ", origin);
console.error("An error occurred!", error); // for demo purposes only
console.error(error); // for demo purposes only
return Promise.reject(error.message || error);
}
protected addSortFilterToQuery(query) {
query.query.bool.must_not.push({
term: {
"Location.tree": this.defaultLocation,
},
});
return query;
}
/** Process a response to a array of songs.
*
* @param res Response to process
*/
protected responseToAlbums(res: any): Album[] {
const result: Array<Album> = [];
res.hits.forEach((hit) => {
result.push(hit.document);
});
return result;
}
}

View File

@@ -13,4 +13,4 @@ export const environment = {
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.
// import 'zone.js/plugins/zone-error'; // Included with Angular CLI.

View File

@@ -1,3 +1,7 @@
/***************************************************************************************************
* Load `$localize` onto the global scope - used if i18n tags appear in Angular templates.
*/
import '@angular/localize/init';
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
@@ -18,16 +22,6 @@
* BROWSER POLYFILLS
*/
/** IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/**
* Web Animations `@angular/platform-browser/animations`
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
*/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
@@ -55,7 +49,7 @@
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js/dist/zone'; // Included with Angular CLI.
import 'zone.js'; // Included with Angular CLI.
/***************************************************************************************************

View File

@@ -1,25 +1,16 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/dist/zone-testing';
import 'zone.js/testing';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
declare const require: {
context(path: string, deep?: boolean, filter?: RegExp): {
keys(): string[];
<T>(id: string): T;
};
};
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
platformBrowserDynamicTesting(), {
teardown: { destroyAfterEach: false }
}
);
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);

View File

@@ -10,11 +10,15 @@
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "es2015",
"target": "ES2022",
"module": "es2020",
"lib": [
"es2018",
"dom"
]
],
"useDefineForClassFields": false
},
"angularCompilerOptions": {
"strictTemplates": true
}
}

View File

@@ -42,17 +42,16 @@ for use in the D3.js JavaScript library.
"""
import argparse
import datetime
import hashlib
import io
import json
import os
import plistlib
import sys
import argparse
import hashlib
class SetEncoder(json.JSONEncoder):
class JsonCustomEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, set):
return list(obj)
@@ -64,12 +63,8 @@ class SetEncoder(json.JSONEncoder):
class ITunesParser:
"""
Parse an iTunes Library and produce JSON - for ELS
Parse an iTunes Library and produce JSON - for ELS
"""
SONG_INDEX = 'itunes-songs'
ALBUM_INDEX = 'itunes-albums'
ARTIST_INDEX = 'itunes-artists'
# TODO Put variables in a config files or in a python library
def __init__(self):
self._tracks = {}
@@ -78,265 +73,290 @@ class ITunesParser:
def _read_tracks(self, library_file):
"""
Read library file and return Tracks key of dict.
Dict may contains
- Major Version
- Minor Version
- Date
- Application Version
- Features
- Show Content Ratings
- Music Folder
- Library Persistent ID
- Tracks
- ...
Read library file and return Tracks key of dict.
Dict may contains
- Major Version
- Minor Version
- Date
- Application Version
- Features
- Show Content Ratings
- Music Folder
- Library Persistent ID
- Tracks
- ...
"""
plist = plistlib.load(open(library_file, 'rb'))
return plist['Tracks']
plist = plistlib.load(open(library_file, "rb"))
return plist["Tracks"]
def parse(self, library_file):
def parse(self, library_file) -> dict:
"""
Return an output JSON for an ELS Bulk request - Not a correct format
This method call process_album & process_artist
TODO Just return a _correct_ JSON and treat in another place/class
Return an output JSON for an ELS Bulk request - Not a correct format
This method call process_album & process_artist
TODO Just return a _correct_ JSON and treat in another place/class
"""
tracks = self._read_tracks(library_file)
for _, track in tracks.items():
# Filter out any non-music
if track['Track Type'] != 'File':
if track["Track Type"] != "File":
continue
if 'Podcast' in track or 'Has Video' in track:
if "Podcast" in track or "Has Video" in track:
continue
# Each keeped track are stored
self._tracks[track['Persistent ID']] = track
self._tracks[track["Persistent ID"]] = track
# Retrieve for each track artist information
self._process_artist(track)
# Retrieve for each track album information
self._process_album(track)
ret = {
'songs': self._tracks,
'albums': self._albums,
'artists': self._artists
}
ret = {"songs": self._tracks, "albums": self._albums, "artists": self._artists}
return ret
def _process_artist(self, track):
"""
Process artists in the track part of library and return a JSON formated for a bulk ELS request
Process artists in the track part of library and return a JSON formated for a bulk ELS request
"""
if 'Album Artist' not in track and 'Artist' not in track:
if "Album Artist" not in track and "Artist" not in track:
return
akey = track['Album Artist'] if 'Album Artist' in track else track['Artist']
# Add artist
if akey not in self._artists:
a_id = self.calc_id(akey)
akey = track["Album Artist"] if "Album Artist" in track else track["Artist"]
persistent_id = self.calc_id(akey)
if persistent_id not in self._artists:
# Key is used to increment/precise some information
# So we use artist name as a key to avoid calculating an ID for each track
self._artists[akey] = {
'Persistent ID': a_id,
'Name': akey,
'Artist': akey,
'Track Count': 0,
'Play Count': 0,
'Rating': 0,
'Genre': set(),
'Album': set()
}
# Compute information
play_count = track['Play Count'] if 'Play Count' in track else 0
rating = track['Rating'] if 'Rating' in track else 0
rating = self.calc_average(rating, self._artists[akey]['Rating'], self._artists[akey]['Track Count'])
self._artists[akey]['Track Count'] += 1
self._artists[akey]['Rating'] = rating
self._artists[akey]['Play Count'] += play_count
if 'Genre' in track:
# Split up the Genres
genre_parts = track['Genre'].split('/')
self._artists[akey]['Genre'] |= set(genre_parts)
if 'Album' in track:
self._artists[akey]['Album'].add(track['Album'])
def _process_album(self, track):
"""
Process albums in the track part of library and return a JSON formated for a bulk ELS request
"""
if 'Album' not in track:
return
akey = track['Album']
if akey not in self._albums:
a_id = self.calc_id(akey)
# Key is used to increment/precise some information
# So we use album name as a key to avoid calculating an ID for each track
self._albums[akey] = {
'Persistent ID': a_id,
'Name': akey,
'Album': akey,
'Track Count': 0,
'Play Count': 0,
'Genre': set(),
'Artist': set(),
'Avg Bit Rate': track['Bit Rate'],
'Min Bit Rate': track['Bit Rate'],
# 'Album Artist': '',
'Total Time': 0
self._artists[persistent_id] = {
"Persistent ID": persistent_id,
"Name": akey,
"Artist": akey,
"Track Count": 0,
"Play Count": 0,
"Rating": 0,
"Genre": set(),
"Album": set(),
}
# Compute information
play_count = track['Play Count'] if 'Play Count' in track else 0
play_count = track["Play Count"] if "Play Count" in track else 0
total_time = track['Total Time'] if 'Total Time' in track else 0
rating = track["Rating"] if "Rating" in track else 0
rating = self.calc_average(
rating,
self._artists[persistent_id]["Rating"],
self._artists[persistent_id]["Track Count"],
)
avg_bitrate = self.calc_average(track['Bit Rate'], self._albums[akey]['Avg Bit Rate'], self._albums[akey]['Track Count'])
self._artists[persistent_id]["Track Count"] += 1
self._artists[persistent_id]["Rating"] = rating
self._artists[persistent_id]["Play Count"] += play_count
self._albums[akey]['Avg Bit Rate'] = avg_bitrate
self._albums[akey]['Track Count'] += 1
self._albums[akey]['Play Count'] += play_count
self._albums[akey]['Total Time'] += total_time
if self._albums[akey]['Min Bit Rate'] > track['Bit Rate']:
self._albums[akey]['Min Bit Rate'] = track['Bit Rate']
if 'Genre' in track:
if "Genre" in track:
# Split up the Genres
genre_parts = track['Genre'].split('/')
self._albums[akey]['Genre'] |= set(genre_parts)
genre_parts = track["Genre"].split("/")
self._artists[persistent_id]["Genre"] |= set(genre_parts)
if 'Artist' in track:
self._albums[akey]['Artist'].add(track['Artist'])
if "Album" in track:
self._artists[persistent_id]["Album"].add(track["Album"])
if 'Album Rating' in track:
self._albums[akey]['Album Rating'] = track['Album Rating']
def _process_album(self, track):
"""
Process albums in the track part of library and return a JSON formated for a bulk ELS request
"""
if "Album" not in track:
return
if 'Album Rating Computed' in track:
self._albums[akey]['Album Rating Computed'] = track['Album Rating Computed']
akey = track["Album"]
persistent_id = self.calc_id(akey)
if 'Album Artist' in track:
self._albums[akey]['Album Artist'] = track['Album Artist']
if persistent_id not in self._albums:
# Key is used to increment/precise some information
# So we use album name as a key to avoid calculating an ID for each track
self._albums[persistent_id] = {
"Persistent ID": persistent_id,
"Name": akey,
"Album": akey,
"Track Count": 0,
"Play Count": 0,
"Genre": set(),
"Artist": set(),
"Avg Bit Rate": track["Bit Rate"],
"Min Bit Rate": track["Bit Rate"],
# 'Album Artist': '',
"Total Time": 0,
"Location": "",
"Date Added": track["Date Added"],
}
# Compute information
play_count = track["Play Count"] if "Play Count" in track else 0
total_time = track["Total Time"] if "Total Time" in track else 0
avg_bitrate = self.calc_average(
track["Bit Rate"],
self._albums[persistent_id]["Avg Bit Rate"],
self._albums[persistent_id]["Track Count"],
)
self._albums[persistent_id]["Avg Bit Rate"] = avg_bitrate
self._albums[persistent_id]["Track Count"] += 1
self._albums[persistent_id]["Play Count"] += play_count
self._albums[persistent_id]["Total Time"] += total_time
self._albums[persistent_id]["Location"] = os.path.dirname(track["Location"])
if self._albums[persistent_id]["Min Bit Rate"] > track["Bit Rate"]:
self._albums[persistent_id]["Min Bit Rate"] = track["Bit Rate"]
if "Genre" in track:
# Split up the Genres
genre_parts = track["Genre"].split("/")
self._albums[persistent_id]["Genre"] |= set(genre_parts)
if "Artist" in track:
self._albums[persistent_id]["Artist"].add(track["Artist"])
if "Album Rating" in track:
self._albums[persistent_id]["Album Rating"] = track["Album Rating"]
if "Album Rating Computed" in track:
self._albums[persistent_id]["Album Rating Computed"] = track[
"Album Rating Computed"
]
if "Album Artist" in track:
self._albums[persistent_id]["Album Artist"] = track["Album Artist"]
if "Date Added" in track:
if track["Date Added"] < self._albums[persistent_id]["Date Added"]:
print(f"Lower date for {akey} - {track['Album']}")
self._albums[persistent_id]["Date Added"] = track["Date Added"]
@classmethod
def calc_average(cls, added_value, current_value, nb_values):
"""
Calculate average value from a current value, a value to add and the number of values
Calculate average value from a current value, a value to add and the number of values
"""
return (current_value * nb_values + added_value) / (nb_values + 1)
@classmethod
def calc_id(cls, key):
"""
Calculate a MD5 sum from a key as ID
Calculate a MD5 sum from a key as ID
"""
md5 = hashlib.md5()
md5.update(key.encode('UTF-8'))
md5.update(key.encode("UTF-8"))
return md5.hexdigest()
class WriteElsJson:
@staticmethod
def write_artists(artists, output_file):
def write_elements(
elements: list,
element_type: str,
json_style: str,
):
"""
Write artists data to another JSON file
Write songs to a JSON
"""
output_filename = f"es-{element_type}.{json_style}"
if json_style == "els":
output_filename += ".json"
file_artist = io.open(output_file, 'wb')
for _, artist in artists.items():
persistent_id = artist['Persistent ID']
artist['Rating'] = round(artist['Rating'])
json_dump_option = {"ensure_ascii": False, "cls": JsonCustomEncoder}
json_track_index = {
"index": {"_index": ITunesParser.ARTIST_INDEX, "_id": persistent_id}
}
with open(output_filename, "w", encoding="utf-8") as ofile:
match json_style:
case "json":
json_str = json.dumps(elements, **json_dump_option)
ofile.write(
json_str.replace("}, {", "},\n{")
) # One line = one record
case "jsonl":
for el in elements:
json.dump(el, ofile, **json_dump_option)
ofile.write("\n")
case "els":
for el in elements:
json_track_index = {
"index": {
"_index": f"itunes-{element_type}",
"_id": el["Persistent ID"],
}
}
json.dump(json_track_index, ofile, **json_dump_option)
ofile.write("\n")
json.dump(el, ofile, **json_dump_option)
ofile.write("\n")
case _:
print("ERROR: no write format")
file_artist.write(bytes(json.dumps(json_track_index, indent=None, cls=SetEncoder), 'UTF-8'))
file_artist.write(bytes("\n", 'UTF-8'))
file_artist.write(bytes(json.dumps(artist, indent=None, cls=SetEncoder), 'UTF-8'))
file_artist.write(bytes("\n", 'UTF-8'))
file_artist.close()
@staticmethod
def write_albums(albums, output_file):
"""
Write albums data to another JSON file
"""
file_albums = io.open(output_file, 'wb')
for _, album in albums.items():
persistent_id = album['Persistent ID']
album['Avg Bit Rate'] = round(album['Avg Bit Rate'])
json_track_index = {
"index": {"_index": ITunesParser.ALBUM_INDEX, "_id": persistent_id}
}
file_albums.write(bytes(json.dumps(json_track_index, indent=None, cls=SetEncoder), 'UTF-8'))
file_albums.write(bytes("\n", 'UTF-8'))
file_albums.write(bytes(json.dumps(album, indent=None, cls=SetEncoder), 'UTF-8'))
file_albums.write(bytes("\n", 'UTF-8'))
file_albums.close()
@staticmethod
def write_songs(songs, output_file):
"""
Write songs to a JSON
"""
file = io.open(output_file, 'wb')
for persistent_id, song in songs.items():
json_track_index = {
"index": {"_index": ITunesParser.SONG_INDEX, "_id": persistent_id}
}
file.write(bytes(json.dumps(json_track_index, indent=None, cls=SetEncoder), 'UTF-8'))
file.write(bytes("\n", 'UTF-8'))
file.write(bytes(json.dumps(song, indent=None, cls=SetEncoder), 'UTF-8'))
file.write(bytes("\n", 'UTF-8'))
file.close()
#### main block ####
# Default input & output files
DEFAULT_LIBRARY_FILE_NAME = 'iTunesLibrary.xml'
DEFAULT_OUTPUT_FILE_NAME = '/es-music-data.json'
DEFAULT_LIBRARY_FILE_NAME = "iTunesLibrary.xml"
DEFAULT_OUTPUT_FILE_NAME = "/es-music-data.json"
DEFAULT_LIBRARY_FILE = os.path.expanduser(DEFAULT_LIBRARY_FILE_NAME)
DEFAULT_OUTPUT_FILE = os.path.dirname(os.path.realpath(__file__)) + DEFAULT_OUTPUT_FILE_NAME
DEFAULT_OUTPUT_FILE = (
os.path.dirname(os.path.realpath(__file__)) + DEFAULT_OUTPUT_FILE_NAME
)
# Get options
parser = argparse.ArgumentParser(description="""
parser = argparse.ArgumentParser(
description="""
Parse an iTunes XML library file to produce JSON file for ELS bulk operation.
""")
parser.add_argument('-f', '--file', default=DEFAULT_LIBRARY_FILE,
help='iTunes Library XML file path (default: ./' + DEFAULT_LIBRARY_FILE_NAME + ')')
parser.add_argument('-o', '--output', default=DEFAULT_OUTPUT_FILE,
help='Output to file (default: .' + DEFAULT_OUTPUT_FILE_NAME + ')')
parser.add_argument('-c', '--console', action='store_true',
help='Output to console instead of file')
"""
)
parser.add_argument(
"-f",
"--file",
default=DEFAULT_LIBRARY_FILE,
help="iTunes Library XML file path (default: ./" + DEFAULT_LIBRARY_FILE_NAME + ")",
)
parser.add_argument(
"-o",
"--output",
default=DEFAULT_OUTPUT_FILE,
help="Output to file (default: ." + DEFAULT_OUTPUT_FILE_NAME + ")",
)
parser.add_argument(
"-c", "--console", action="store_true", help="Output to console instead of file"
)
parser.add_argument(
"-F",
"--format",
choices=["json", "jsonl", "els"],
default="json",
help="Choose JSON style",
)
# parser.add_argument('-v', '--verbose', action='store_true',
# help='Verbose output')
# help='Verbose output')
if __name__ == '__main__':
if __name__ == "__main__":
args = parser.parse_args()
print("Parsing file '{}'...".format(args.file))
print(f"Parsing file '{args.file}'...")
itunes_parser = ITunesParser().parse(args.file)
print("Writing JSON files...")
WriteElsJson.write_songs(itunes_parser['songs'], "es-songs.json")
WriteElsJson.write_artists(itunes_parser['artists'], "es-artists.json")
WriteElsJson.write_albums(itunes_parser['albums'], "es-albums.json")
print('Done!')
WriteElsJson.write_elements(
[x for _, x in itunes_parser["songs"].items()], "songs", args.format
)
WriteElsJson.write_elements(
[x for _, x in itunes_parser["artists"].items()], "artists", args.format
)
WriteElsJson.write_elements(
[x for _, x in itunes_parser["albums"].items()], "albums", args.format
)
print("Done!")
# if args.console:
# print(output)

95
ingest_cover.py Normal file
View File

@@ -0,0 +1,95 @@
import base64
import os
from typing import List, Optional
import requests
import json
import re
from urllib import parse
TYPESENSE_URL = "http://localhost:8108"
COLLECTION = "albums"
HEADERS = {"X-TYPESENSE-API-KEY": "toto", "Content-Type": "application/json"}
def get_albums(offset = 0) -> List[tuple[str, str, str]]:
params = {"q": "*", "per_page": 250, "offset": offset}
q_url = f"{TYPESENSE_URL}/collections/{COLLECTION}/documents/search"
# print(q_url)
req = requests.get(q_url, params=params, headers=HEADERS)
ret = [ (hit['document']['id'], hit['document']['Album'], hit['document']['Artist']) for hit in req.json()['hits']]
return ret
def get_album_location(id: int) -> str:
q_url = f"{TYPESENSE_URL}/collections/{COLLECTION}/documents/{id}"
# print(q_url)
req = requests.get(q_url, headers=HEADERS)
if "Cover" in req.json():
return
return req.json()["Location"]
def patch_album(id: int, cover: bytes) -> bool:
q_url = f"{TYPESENSE_URL}/collections/{COLLECTION}/documents/{id}"
# print(q_url)
payload = {"Cover": cover.decode()}
payload = json.dumps(payload)
req = requests.patch(q_url, headers=HEADERS, data=payload)
return req.status_code == 200
def sanitize_location(location: str) -> Optional[str]:
result = parse.unquote(location, encoding='utf-8', errors='replace')
result = result.replace("file://localhost/", "")
result = result.replace(":", "")
result = re.sub("^([A-Z])", lambda m: m.group(0).lower(), result)
result = result.replace("%20", " ")
result = "/mnt/" + result
result = re.sub('/Disc [0-9]*$', "", result)
for file in ["/Pochette.jpg", "/cover.jpg", "/Pochette.png"]:
if os.path.isfile(result + file):
result = result + file
return result
return None
def get_base64(location: str) -> bytes:
with open(location, "rb") as image_file:
encoded_cover = base64.b64encode(image_file.read())
return encoded_cover
def process_one_album(id: int) -> bool:
raw_loc = get_album_location(id)
if not raw_loc:
return False
loc = sanitize_location(raw_loc)
if not loc:
return False
cover = get_base64(loc)
if patch_album(id, cover):
return True
return False
if __name__ == "__main__":
# process_one_album(2151)
elements = get_albums()
processed = 0
while len(elements) == 250:
for id, name, artist in elements:
processed += 1
raw_loc = get_album_location(id)
if not raw_loc:
continue
loc = sanitize_location(raw_loc)
if not loc:
continue
print(f"Try {id} - {name} ({artist})")
cover = get_base64(loc)
if patch_album(id, cover):
print(f"Album {id} - {name} ({artist}) patched successfully")
elements = get_albums(offset = processed)

View File

@@ -1,4 +1,28 @@
{
"settings": {
"analysis": {
"analyzer": {
"custom_path_tree": {
"tokenizer": "custom_hierarchy"
},
"custom_path_tree_reversed": {
"tokenizer": "custom_hierarchy_reversed"
}
},
"tokenizer": {
"custom_hierarchy": {
"type": "path_hierarchy",
"delimiter": "/",
"skip": 3
},
"custom_hierarchy_reversed": {
"type": "path_hierarchy",
"delimiter": "/",
"reverse": "true"
}
}
}
},
"mappings" : {
"properties": {
"Artist": {
@@ -21,6 +45,19 @@
},
"Genre": {
"type": "keyword"
},
"Location": {
"type": "text",
"fields": {
"tree": {
"type": "text",
"analyzer": "custom_path_tree"
},
"tree_reversed": {
"type": "text",
"analyzer": "custom_path_tree_reversed"
}
}
}
}
}

View File

@@ -1,70 +1,556 @@
{
"settings": {
"analysis": {
"analyzer": {
"custom_path_tree": {
"tokenizer": "custom_hierarchy"
},
"custom_path_tree_reversed": {
"tokenizer": "custom_hierarchy_reversed"
}
},
"tokenizer": {
"custom_hierarchy": {
"type": "path_hierarchy",
"delimiter": "/",
"skip": 3
},
"custom_hierarchy_reversed": {
"type": "path_hierarchy",
"delimiter": "/",
"reverse": "true"
}
}
}
},
"mappings" : {
"properties": {
"Artist": {
"type": "text",
"fields": {
"raw": {"type": "keyword"}
}
},
"Album Artist": {
"type": "text",
"fields": {
"raw": {"type": "keyword"}
}
},
"Album": {
"type": "text",
"fields": {
"raw": {"type": "keyword"}
}
},
"Bit Rate": {
"type": "integer"
},
"Genre": {
"type": "keyword"
},
"Kind": {
"type": "keyword"
},
"Location": {
"type": "text",
"fields": {
"tree": {
"type": "text",
"analyzer": "custom_path_tree"
},
"tree_reversed": {
"type": "text",
"analyzer": "custom_path_tree_reversed"
}
}
}
}
}
"name": "songs",
"token_separators": [":", "/", "."],
"fields": [
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Album",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Album Rating",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Album Rating Computed",
"optional": true,
"sort": true,
"type": "bool"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Artist",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Artwork Count",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Bit Rate",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Date Added",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Date Modified",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "File Folder Count",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": true,
"index": true,
"infix": false,
"locale": "",
"name": "Genre",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Kind",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Library Folder Count",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Location",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Name",
"optional": false,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": false,
"infix": false,
"locale": "",
"name": "Persistent ID",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Play Count",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Play Date",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Play Date UTC",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Rating",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Rating Computed",
"optional": true,
"sort": true,
"type": "bool"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Sample Rate",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": true,
"index": true,
"infix": false,
"locale": "",
"name": "Size",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Skip Count",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Skip Date",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": true,
"index": true,
"infix": false,
"locale": "",
"name": "Total Time",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Track ID",
"optional": false,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Track Number",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Track Type",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Year",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Composer",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Disabled",
"optional": true,
"sort": true,
"type": "bool"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Disc Count",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Disc Number",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Album Artist",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Sort Name",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Sort Album",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Comments",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Sort Artist",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Sort Composer",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Loved",
"optional": true,
"sort": true,
"type": "bool"
},
{
"facet": false,
"index": false,
"infix": false,
"locale": "",
"name": "Volume Adjustment",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Compilation",
"optional": true,
"sort": true,
"type": "bool"
},
{
"facet": false,
"index": false,
"infix": false,
"locale": "",
"name": "Part Of Gapless Album",
"optional": true,
"sort": true,
"type": "bool"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Track Count",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Sort Album Artist",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Album Loved",
"optional": true,
"sort": true,
"type": "bool"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "BPM",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Grouping",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Series",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Purchased",
"optional": true,
"sort": true,
"type": "bool"
},
{
"facet": false,
"index": true,
"infix": false,
"locale": "",
"name": "Release Date",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": false,
"infix": false,
"locale": "",
"name": "Movement Count",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": false,
"infix": false,
"locale": "",
"name": "Movement Name",
"optional": true,
"sort": false,
"type": "string"
},
{
"facet": false,
"index": false,
"infix": false,
"locale": "",
"name": "Movement Number",
"optional": true,
"sort": true,
"type": "int64"
},
{
"facet": false,
"index": false,
"infix": false,
"locale": "",
"name": "Work",
"optional": true,
"sort": false,
"type": "string"
}
]
}

View File

@@ -0,0 +1 @@
3.13

0
navidrome/README.md Normal file
View File

78
navidrome/analyzer.py Normal file
View File

@@ -0,0 +1,78 @@
import sqlite3
from datetime import datetime
import meilisearch
def tzunit(dt) -> datetime:
if dt.tzinfo is not None:
return dt.replace(tzinfo=None)
return dt
client = meilisearch.Client("http://127.0.0.1:7700", "aSampleMasterKey")
index = client.index("itunes-albums")
con = sqlite3.connect("navidrome.db")
cur = con.cursor()
instertor = con.cursor()
update_statement = """UPDATE album SET
persistent_id = ?,
itunes_date = ?,
meili_nb_res = ?,
meili_title = ?,
meili_artist = ?,
meili_doc = ?,
meili_song_count = ?,
validated = ?
WHERE id = ?"""
for row in cur.execute("SELECT * FROM album;"):
msearch = index.search(
row[1], {"filter": [f'Artist="{row[2]}"'], "showRankingScore": True}
)
meili_result_len = len(msearch["hits"])
if meili_result_len > 0:
meili_hit = msearch["hits"][0]
if meili_hit["_rankingScore"] > 0.5:
meili_artist = (
[meili_hit["Album Artist"]]
if "Album Artist" in meili_hit
else meili_hit["Artist"]
)
nv_date = tzunit(datetime.fromisoformat(row[4]))
itunes_date = tzunit(datetime.fromisoformat(meili_hit["Date Added"]))
timedelta = nv_date - itunes_date
val_score = 0
if timedelta.days < 1:
val_score = -1
else:
if meili_result_len == 1:
val_score += 50
if meili_hit["Track Count"] == row[3]:
val_score += 40
if meili_hit["Name"] == row[1]:
val_score += 30
if row[2] in meili_artist:
val_score += 10
meili_artist = (
str(meili_artist[0]) if len(meili_artist) == 1 else str(meili_artist)
)
data = (
meili_hit["Persistent ID"],
meili_hit["Date Added"],
meili_result_len,
meili_hit["Name"],
str(meili_artist),
str(meili_hit),
meili_hit["Track Count"],
val_score,
row[0],
)
print(f"Update {row[1]}")
instertor.execute(update_statement, data)
con.commit()

View File

@@ -0,0 +1,41 @@
ALTER TABLE album DROP COLUMN 'size';
ALTER TABLE album DROP COLUMN 'embed_art_path';
ALTER TABLE album DROP COLUMN 'min_year';
ALTER TABLE album DROP COLUMN 'max_year';
ALTER TABLE album DROP COLUMN 'compilation';
ALTER TABLE album DROP COLUMN 'duration';
ALTER TABLE album DROP COLUMN 'genre';
ALTER TABLE album DROP COLUMN 'updated_at';
ALTER TABLE album DROP COLUMN 'full_text';
ALTER TABLE album DROP COLUMN 'album_artist_id';
ALTER TABLE album DROP COLUMN 'description';
ALTER TABLE album DROP COLUMN 'small_image_url';
ALTER TABLE album DROP COLUMN 'medium_image_url';
ALTER TABLE album DROP COLUMN 'large_image_url';
ALTER TABLE album DROP COLUMN 'external_url';
ALTER TABLE album DROP COLUMN 'external_info_updated_at';
ALTER TABLE album DROP COLUMN 'date';
ALTER TABLE album DROP COLUMN 'min_original_year';
ALTER TABLE album DROP COLUMN 'max_original_year';
ALTER TABLE album DROP COLUMN 'original_date';
ALTER TABLE album DROP COLUMN 'release_date';
ALTER TABLE album DROP COLUMN 'releases';
ALTER TABLE album DROP COLUMN 'order_album_name';
ALTER TABLE album DROP COLUMN 'order_album_artist_name';
ALTER TABLE album DROP COLUMN 'sort_album_name';
ALTER TABLE album DROP COLUMN 'sort_album_artist_name';
ALTER TABLE album DROP COLUMN 'catalog_num';
ALTER TABLE album DROP COLUMN 'comment';
ALTER TABLE album DROP COLUMN 'mbz_album_id';
ALTER TABLE album DROP COLUMN 'mbz_album_artist_id';
ALTER TABLE album DROP COLUMN 'mbz_album_type';
ALTER TABLE album DROP COLUMN 'mbz_album_comment';
ALTER TABLE album DROP COLUMN 'discs';
ALTER TABLE album DROP COLUMN 'library_id';
ALTER TABLE album DROP COLUMN 'imported_at';
ALTER TABLE album DROP COLUMN 'missing';
ALTER TABLE album DROP COLUMN 'mbz_release_group_id';
ALTER TABLE album DROP COLUMN 'tags';
ALTER TABLE album DROP COLUMN 'participants';
ALTER TABLE album DROP COLUMN 'folder_ids';
ALTER TABLE album DROP COLUMN 'explicit_status';

View File

@@ -0,0 +1,8 @@
ALTER TABLE album ADD COLUMN 'persistent_id' TEXT;
ALTER TABLE album ADD COLUMN 'itunes_date' TEXT;
ALTER TABLE album ADD COLUMN 'meili_nb_res' INTEGER;
ALTER TABLE album ADD COLUMN 'meili_title' TEXT;
ALTER TABLE album ADD COLUMN 'meili_artist' TEXT;
ALTER TABLE album ADD COLUMN 'meili_doc' BLOB;
ALTER TABLE album ADD COLUMN 'meili_song_count' INTEGER;
ALTER TABLE album ADD COLUMN 'validated' INTEGER;

21
navidrome/generator.py Normal file
View File

@@ -0,0 +1,21 @@
import sqlite3
from datetime import datetime
def tzunit(dt) -> datetime:
if dt.tzinfo is not None:
return dt.replace(tzinfo=None)
return dt
con = sqlite3.connect("navidrome.db")
cur = con.cursor()
for row in cur.execute("SELECT * FROM album where persistent_id is not null;"):
if row[12] >= 100:
nv_date = tzunit(datetime.fromisoformat(row[4]))
itunes_date = tzunit(datetime.fromisoformat(row[6]))
timedelta = nv_date - itunes_date
if timedelta.days > 1:
sql_st = f"UPDATE album SET created_at = '{itunes_date.isoformat()}' WHERE id = '{row[0]}';"
print(sql_st)

23
navidrome/init_db.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
cp ~/workspace/litestream/navidrome/navidrome.db .
echo
echo "Removing tables..."
for table in $(sqlite3 navidrome.db "SELECT name FROM sqlite_master WHERE name != 'album' and type='table'"); do
echo Process $table
sqlite3 navidrome.db "DROP TABLE $table;" || echo "Failed for table $table"
done
echo
echo "Removing index..."
for index in $(sqlite3 navidrome.db "SELECT name FROM sqlite_master WHERE type='index'"); do
echo $index
sqlite3 navidrome.db "DROP INDEX $index;" || echo "Failed for index $index"
done
echo
echo "Apply scripts..."
sqlite3 navidrome.db < clean_cols.sql.sql
sqlite3 navidrome.db < create_cols.sql

9
navidrome/pyproject.toml Normal file
View File

@@ -0,0 +1,9 @@
[project]
name = "navidrome"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"meilisearch>=0.37.0",
]

218
navidrome/uv.lock generated Normal file
View File

@@ -0,0 +1,218 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "camel-converter"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/dd/f8df35e1d2b6360a27c1613937bde2f68db41b8a67cd856f105d2031a3ed/camel_converter-5.0.0.tar.gz", hash = "sha256:97d0cf15a75b40abab288c526c71143b468376dd61690fd1cb0a49d9e471f111", size = 58926, upload-time = "2025-10-07T18:01:04.577Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/67/283543fc3ac6d6d8e9cd710e331ebf2323a284f66be304ac786c3b7bdeb4/camel_converter-5.0.0-py3-none-any.whl", hash = "sha256:5a64900bcba1ca047c504ee88cefa905a5bcac15cec06f6d6f9d1ebad46d4e80", size = 6254, upload-time = "2025-10-07T18:01:03.248Z" },
]
[package.optional-dependencies]
pydantic = [
{ name = "pydantic" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "meilisearch"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "camel-converter", extra = ["pydantic"] },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/03/4b/36c1c288b1485c9a7a507ea139f1c65a1dee66ae60e820a1437507a3d899/meilisearch-0.37.0.tar.gz", hash = "sha256:a8c0e947d9029b26e833d91ba818cdd56be478756521ce8fe6bcc3178e916ed7", size = 28646, upload-time = "2025-08-20T07:48:57.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/25/ff33395a9c34960e7f875eec70bc75d80bb53a75acb743f9963e53254e9f/meilisearch-0.37.0-py3-none-any.whl", hash = "sha256:313d5046fffaec4d89480298d3680e64d3c1e7c7577b7f6765a092fcf56d8571", size = 29165, upload-time = "2025-08-20T07:48:56.263Z" },
]
[[package]]
name = "navidrome"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "meilisearch" },
]
[package.metadata]
requires-dist = [{ name = "meilisearch", specifier = ">=0.37.0" }]
[[package]]
name = "pydantic"
version = "2.12.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" },
{ url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" },
{ url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" },
{ url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" },
{ url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" },
{ url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" },
{ url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" },
{ url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" },
{ url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" },
{ url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" },
{ url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" },
{ url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" },
{ url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" },
{ url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" },
{ url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" },
{ url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" },
{ url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" },
{ url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" },
{ url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" },
{ url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" },
{ url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" },
{ url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" },
{ url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" },
{ url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" },
{ url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" },
{ url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" },
{ url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" },
{ url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" },
{ url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" },
{ url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" },
{ url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" },
{ url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]

137
suggester.es Normal file
View File

@@ -0,0 +1,137 @@
DELETE itunes-suggest
PUT /itunes-suggest
{
"settings": {
"analysis": {
"filter": {
"french_stop": {
"type": "stop",
"stopwords": "_french_"
},
"english_stop": {
"type": "stop",
"stopwords": "_english_"
}
},
"analyzer": {
"names": {
"tokenizer": "standard",
"filter": [
"lowercase",
"asciifolding",
"french_stop",
"english_stop"
]
}
}
}
},
"mappings": {
"properties": {
"artist_suggest": {
"type": "completion",
"search_analyzer": "names"
},
"artist": {
"type": "keyword"
},
"album_suggest": {
"type": "completion",
"search_analyzer": "names"
},
"album": {
"type": "keyword"
}
}
}
}
// Also possible to specify analyze for ingesting => https://stackoverflow.com/questions/48304499/elasticsearch-completion-suggester-not-working-with-whitespace-analyzer
// Problem with word EP, SP
GET itunes-suggest/_analyze
{
"analyzer": "names",
"text": "the servent"
}
GET itunes-suggest/_search
POST itunes-suggest/_search
{
"_source" : "artist",
"suggest": {
"name-suggest": {
"prefix": "sou",
"completion": {
"field": "artist_suggest"
}
}
}
}
POST itunes-suggest/_search
{
"_source" : "album",
"suggest": {
"name-suggest": {
"prefix": "new",
"completion": {
"field": "album_suggest",
"size": 20
}
}
}
}
POST itunes-suggest/_search
{
"_source": ["album", "artist"],
"suggest": {
"alb-suggest": {
"prefix": "sou",
"completion": {
"field": "album_suggest"
}
},
"ar-suggest": {
"prefix": "sou",
"completion": {
"field": "artist_suggest"
}
}
}
}
POST itunes-suggest/_search
{
"_source": ["album", "artist"],
"suggest": {
"alb-suggest": {
"prefix": "Francois",
"completion": {
"field": "album_suggest"
}
},
"ar-suggest": {
"prefix": "Francois",
"completion": {
"field": "artist_suggest"
}
}
}
}
POST itunes-suggest/_search
{
"suggest": {
"ar-suggest": {
"prefix": "Femme",
"completion": {
"field": "artist_suggest"
}
}
}
}

83
suggester.py Normal file
View File

@@ -0,0 +1,83 @@
import requests
import json
import sys
ELS_URL ='http://localhost:9200'
INDEX = 'itunes-suggest'
class NoGoodDataException(Exception):
def __init__(self, message):
super().__init__(message)
def get_tokens(data: str) -> list:
if not data:
return []
query = {
"analyzer": "names",
"text" : data
}
url = '{}/{}/_analyze'.format(ELS_URL, INDEX)
r = requests.get(url, json=query)
if not 'tokens' in r.json():
print('ERROR: Not tokens in result')
print('Input: ' + str(data))
print('Request: ' + str(r.json()))
raise NoGoodDataException('Data is not correct to get tokens')
return [t['token'] for t in r.json()['tokens']]
def post_document(name: str, input: list, field_name: str) -> bool:
suggest_name = field_name + '_suggest'
element = {
field_name: name,
suggest_name: input
}
# Filter empty keys
# element = {k: v for k, v in element.items() if v}
url = '{}/{}/_doc'.format(ELS_URL, INDEX)
resp = requests.post(url, json=element)
if resp.status_code != 201:
print('ELS Response KO')
print(resp.status_code)
print(resp.text)
return
el_id = resp.json()['_id']
# print('Post_element - Element created: ' + el_id)
return el_id
def process_file(file_name: str, field_name: str) -> int:
print('Process file: ' + file_name)
with open(file_name, 'r') as o_file:
lines = o_file.readlines()
count = 0
i = 0
for line in lines:
i += 1
sys.stdout.write(str(int((i/len(lines))*100)) + '%')
sys.stdout.flush()
sys.stdout.write("\b" * (40+1)) # return to start of line, after '['
data = json.loads(line)
if "Artist" in data:
try :
input = get_tokens(data[field_name])
post_document(name=data[field_name], input=input, field_name=field_name.lower())
count += 1
except NoGoodDataException:
print('ERROR WITH DATA')
print(str(data))
print('File processed\n')
return count
if __name__ == '__main__':
# Using readlines()
count = 0
count += process_file('/home/budd/workspace/iTunes/es-albums.json', 'Album')
count += process_file('/home/budd/workspace/iTunes/es-artists.json', 'Artist')
print('Created documents: ' + str(count))