26 Commits

Author SHA1 Message Date
95534c92b2 Refactor send data argparse 2021-09-05 02:21:24 +02:00
0230bf260b Process suggestion according to option 2021-09-05 02:13:47 +02:00
928efb659e Use send data to process suggestion
Not all suggested document is created in ELS
Need to refactor send data
2021-08-31 02:31:32 +02:00
88025347ec Pylint suggester 2021-08-30 19:32:12 +02:00
67e1f8bd0c Working suggester ingester 2021-08-30 19:13:30 +02:00
042c2558ae Update ES files - POST -> get 2021-08-23 01:22:58 +02:00
ad0487943a Process for album
Adds too many uninteresting results
Eg. all albums for one artist
=> prevents finding interesting information
2021-08-23 01:22:58 +02:00
56050d0a49 Suggester: take Album Artist
But it's not OK in dashboard ->
For example, search 'ayache' (for Superbus)
=> Result display 'Superbus' and we don't understand why
2021-08-23 01:22:58 +02: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
28 changed files with 27046 additions and 5277 deletions

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,7 +62,8 @@
}
]
}
}
},
"defaultConfiguration": ""
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",

31097
dashboard/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,29 +11,32 @@
},
"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",
"@angular/animations": "~12.2.2",
"@angular/common": "~12.2.2",
"@angular/compiler": "~12.2.2",
"@angular/core": "~12.2.2",
"@angular/forms": "~12.2.2",
"@angular/localize": "~12.2.2",
"@angular/platform-browser": "~12.2.2",
"@angular/platform-browser-dynamic": "~12.2.2",
"@angular/router": "~12.2.2",
"@ng-bootstrap/ng-bootstrap": "^10.0.0",
"bootstrap": "^3.3.7",
"tslib": "^2.0.0"
"rxjs": "~6.6.0",
"tslib": "^2.0.0",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.1100.4",
"@angular/cli": "~11.0.4",
"@angular/compiler-cli": "~11.0.4",
"@angular-devkit/build-angular": "~12.2.2",
"@angular/cli": "~12.2.2",
"@angular/compiler-cli": "~12.2.2",
"@angular/localize": "^12.2.2",
"@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": "~6.3.4",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage": "~2.0.3",
"karma-jasmine": "~4.0.0",
@@ -41,6 +44,6 @@
"protractor": "~7.0.0",
"ts-node": "~8.3.0",
"tslint": "~6.1.0",
"typescript": "~4.0.2"
"typescript": "~4.3.5"
}
}

View File

@@ -27,7 +27,7 @@ export class AlbumsComponent implements OnInit {
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]
@@ -49,12 +49,12 @@ export class AlbumsComponent implements OnInit {
this.queryEdited = true;
}
exlude(field: string, value: string): void {
exlude(field: string, value: Album): void {
this.editQuery(field, value, query_edit_type.exclude)
this.loadData()
}
select(field: string, value: string): void {
select(field: string, value: Album): void {
this.editQuery(field, value, query_edit_type.select)
this.loadData()
}

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';
@@ -21,12 +22,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,7 +47,8 @@ import { AlbumsComponent } from './albums/albums.component';
SortByPipe,
ConvertSizeToStringPipe,
TopPlayedComponent,
RoundPipe
RoundPipe,
ToSortComponent
],
providers: [
ElsService,

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>

View File

@@ -1,17 +1,19 @@
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { ElsService } from './../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';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: [ './dashboard.component.css' ]
})
export class DashboardComponent implements OnInit {
totalTime = 0;
totalSize = 0;
@@ -30,7 +32,10 @@ export class DashboardComponent implements OnInit {
lastAddedAlbums: Bucket[] = [];
albumArtists = [];
constructor(private elsService: ElsService) { }
searchTerm = ''
suggested : Suggested[] = []
constructor(private elsService: ElsService, private route: Router) { }
ngOnInit(): void {
this.elsService.getTime().then(result => {
@@ -113,4 +118,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.elsService.getSuggest(term).pipe(
tap(() => this.searchFailed = false),
catchError(() => {
this.searchFailed = true;
return of([]);
}))
),
tap(() => this.searching = false)
)
}

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,147 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { ElsService } from './els.service';
import { Album } from './model/album';
import { Bucket } from './model/bucket';
import { Song } from './model/song';
@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()'));
}
getAlbums(): Observable<Bucket[]> {
return this.http
.post(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
query: {
bool: {
must_not: [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
},
'size': 0,
'aggs': {
'albums' : {
'terms': {
'field' : 'Album.raw',
'order': { '_term': 'asc' },
'size': 50
}
}
}
}), {headers: this.headers})
.pipe(
map(res => this.responseAggregationToBucket(res, "albums")),
catchError(error => this.handleError(error, 'getAlbums'))
);
}
}

View File

@@ -8,6 +8,7 @@ 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,6 +16,7 @@ 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';
@@ -337,7 +339,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({
@@ -381,6 +383,34 @@ export class ElsService {
);
}
getSuggest(text: string): Observable<Suggested[]> {
console.log('search sugget: ' + text);
return this.http
.post<any>(this.elsUrl + ElsService.SUGGEST_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
'_source': ['album', 'artist'],
'suggest': {
'album-suggest': {
'prefix': text,
'completion': {
'field': 'album_suggest'
}
},
'artist-suggest': {
'prefix': text,
'completion': {
'field': 'artist_suggest'
}
}
}
}), {headers: this.headers})
.pipe(
map(res => this.responseSuggesterToSuggested(res, 'album-suggest', 'artist-suggest')),
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.
@@ -406,7 +436,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 +474,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 +492,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);

View File

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

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,130 @@
<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-blue">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<span class="glyphicon glyphicon-time stats_icon"></span>
</div>
<div class="col-xs-9 text-right">
<div>
<h3 *ngIf="!totalTime"><span class="glyphicon glyphicon-refresh loading"></span></h3>
<h3 *ngIf="totalTime">{{totalTime | convertMs}}</h3>
</div>
<div><br>Total time ({{totalTime | convertMoreExact}})</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-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}}</h3>
</div>
<div><br>Total Songs</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 toSortAlbum">
<td>
<a [routerLink]="['/album', album.key]">{{album.key}}</a>&nbsp;
</td>
<td>{{albums[album.key]['Track Count']}}</td>
<ng-template [ngIf]="albums[album.key]['Album Artist']" [ngIfElse]="artistSection">
<td>
<a [routerLink]="['/artist', albums[album.key]['Album Artist']]">{{albums[album.key]['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>
<ng-template #artistSection>
<td>
<a [routerLink]="['/artist', albums[album.key].Artist[0]]">{{albums[album.key].Artist}}</a>&nbsp;
</td>
</ng-template>
<td>
{{albums[album.key]['Play Count']}} ({{albums[album.key]['Play Count']/albums[album.key]['Track Count'] | number:'1.0-0'}}/songs)
</td>
<td class="star" [title]="(albums[album.key]['Album Rating Computed']?'Computed Rating: ':'Rating: ') + albums[album.key]['Album Rating']">
<span *ngFor="let item of numberToArray(albums[album.key]['Album Rating'], 20)">
<span class="glyphicon" [ngClass]="albums[album.key]['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,89 @@
import { Component, OnInit } from '@angular/core';
import { ElsSortService } from '../els-sort.service';
import { Album } from '../model/album';
import { Bucket } from '../model/bucket';
import { Song } from '../model/song';
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;
// 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);
// **** GET ALBUMS ****//
const tmpToSortAlbums: Bucket[] = [];
this.elsService.getAlbums().subscribe(buckets => {
buckets.forEach(bucket => {
if (tmpToSortAlbums.length === 0) {
tmpToSortAlbums.push(bucket);
} else {
let found = false;
tmpToSortAlbums.forEach(element => {
if (element.key === bucket.key) {
element.doc_count += bucket.doc_count;
found = true;
}
});
if (!found) {
tmpToSortAlbums.push(bucket);
}
}
});
this.toSortAlbum = tmpToSortAlbums;
this.toSortAlbum.forEach(bucket => this.getAlbum(bucket));
// console.log(this.toSortAlbum)
// console.log(this.albums)
});
}
private getAlbum(albumBucket: Bucket) {
// For each bucket.key (album name), get Album document
// Use track count to compare
this.elsService.getArtistFromAlbumName(albumBucket.key).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) {
goodAlbum = album;
}
});
} else {
// Just one result for album name
goodAlbum = albums[0];
}
// TODO Crap security if no good album found
if (goodAlbum == undefined) {
goodAlbum = albums[0]
}
this.albums[albumBucket.key] = goodAlbum;
});
}
}

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.
@@ -55,7 +59,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,6 +1,6 @@
// 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,

View File

@@ -16,5 +16,8 @@
"es2018",
"dom"
]
},
"angularCompilerOptions": {
"strictTemplates": true
}
}

View File

@@ -147,7 +147,8 @@ class ITunesParser:
'Play Count': 0,
'Rating': 0,
'Genre': set(),
'Album': set()
'Album': set(),
'Album Artist': set()
}
# Compute information
@@ -168,6 +169,9 @@ class ITunesParser:
if 'Album' in track:
self._artists[akey]['Album'].add(track['Album'])
if 'Album Artist' in track:
self._artists[akey]['Album Artist'].add(track['Artist'])
def _process_album(self, track):
"""
Process albums in the track part of library and return a JSON formated for a bulk ELS request

48
mapping.suggest.json Normal file
View File

@@ -0,0 +1,48 @@
{
"settings": {
"index": {
"number_of_replicas": 0
},
"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"
}
}
}
}

View File

@@ -10,6 +10,8 @@ import json
import time
import requests
from suggester import process_file
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
@@ -22,16 +24,18 @@ class bcolors:
UNDERLINE = '\033[4m'
# Default file names
DEFAULT_SONG_FILE = 'es-songs.json'
DEFAULT_ALBUM_FILE = 'es-albums.json'
DEFAULT_ARTIST_FILE = 'es-artists.json'
DEFAULT_MAPPING_SONGS_FILE = 'mapping.songs.json'
DEFAULT_MAPPING_ARTISTS_FILE = 'mapping.artists.json'
DEFAULT_MAPPING_ALBUMS_FILE = 'mapping.albums.json'
SONG_FILE = 'es-songs.json'
ALBUM_FILE = 'es-albums.json'
ARTIST_FILE = 'es-artists.json'
MAPPING_SONGS_FILE = 'mapping.songs.json'
MAPPING_ARTISTS_FILE = 'mapping.artists.json'
MAPPING_ALBUMS_FILE = 'mapping.albums.json'
MAPPING_SUGGEST_FILE = 'mapping.suggest.json'
SONG_INDEX = 'itunes-songs'
ALBUM_INDEX = 'itunes-albums'
ARTIST_INDEX = 'itunes-artists'
SUGGEST_INDEX = 'itunes-suggest'
# TODO Put variables in a config files or in a python library
# Global values / set as default values
@@ -47,8 +51,8 @@ def main():
args = create_args_parser().parse_args()
if not args.song and args.ALL:
print(__file__ + ': error: argument -A/--ALL: not allowed with argument -s/--song')
if args.ALL and args.no_song:
print(__file__ + ': error: argument -A/--ALL: not allowed with argument --no-song')
sys.exit(-1)
# Overloaded setting value
@@ -68,16 +72,16 @@ def main():
check_is_ok = []
# Send song data
if args.song or args.ALL:
if not args.no_song:
if args.DELETE:
mapping_song = load_file(args.mapping_song, DEFAULT_MAPPING_SONGS_FILE)
mapping_song = load_file(args.mapping_song, MAPPING_SONGS_FILE)
if not args.quiet:
print("Mapping of song index file: '{}'".format(mapping_song.name))
delete_index(SONG_INDEX, args.quiet)
put_mapping(SONG_INDEX, mapping_song, args.quiet)
song_file = load_file(args.song_file, DEFAULT_SONG_FILE)
song_file = load_file(args.song_file, SONG_FILE)
if not args.quiet:
print("Song file: '{}'".format(song_file.name))
@@ -96,7 +100,7 @@ def main():
# Send artist data
if args.artist_file or args.ALL:
if args.DELETE:
mapping_artist = load_file(args.mapping_artist, DEFAULT_MAPPING_ARTISTS_FILE)
mapping_artist = load_file(args.mapping_artist, MAPPING_ARTISTS_FILE)
if not args.quiet:
print("Mapping of artist index file: '{}'".format(mapping_artist.name))
@@ -107,7 +111,7 @@ def main():
if not artist_file:
if not args.quiet:
print('No artist file specified, take default file...')
artist_file = open(DEFAULT_ARTIST_FILE, 'r')
artist_file = open(ARTIST_FILE, 'r')
if not args.quiet:
print("Artist file: '{}'".format(artist_file.name))
@@ -122,7 +126,7 @@ def main():
if args.album_file or args.ALL:
if args.DELETE:
mapping_album = load_file(args.mapping_album, DEFAULT_MAPPING_ALBUMS_FILE)
mapping_album = load_file(args.mapping_album, MAPPING_ALBUMS_FILE)
if not args.quiet:
print("Mapping of artist index file: '{}'".format(mapping_album.name))
@@ -133,7 +137,7 @@ def main():
if not album_file:
if not args.quiet:
print('No album file specified, take default file...')
album_file = open(DEFAULT_ALBUM_FILE, 'r')
album_file = open(ALBUM_FILE, 'r')
if not args.quiet:
print("Take file '{}' to send song data".format(album_file.name))
@@ -145,6 +149,28 @@ def main():
else:
print('Album sent')
if not args.no_suggest:
print("Process suggestion:")
if args.DELETE:
delete_index(SUGGEST_INDEX, args.quiet)
if not args.ALL and not args.album_file and not args.artist_file:
print('Only song file processed. No suggestion to process.')
else:
if args.DELETE:
mapping_suggest = load_file(args.mapping_suggest, MAPPING_SUGGEST_FILE)
if not args.quiet:
print("Mapping of suggest index file: '{}'".format(mapping_suggest.name))
put_mapping(SUGGEST_INDEX, mapping_suggest, args.quiet)
suggs_docs = 0
if args.album_file or args.ALL:
suggs_docs += process_file(ALBUM_FILE, 'Album')
print('Created suggestion documents: ' + str(suggs_docs))
if args.artist_file or args.ALL:
suggs_docs += process_file(ARTIST_FILE, 'Artist', 'Album Artist')
print('Created suggestion documents: ' + str(suggs_docs))
print("I'm done!")
if check_is_ok.count(False) > 0:
print('Some problems occurs')
@@ -175,13 +201,12 @@ def create_args_parser():
description='''
Send JSON files formated for bulk Elasticsearch operation to an Elasticsearch.
By default: send song data enable, send album & artist data disabled.
Check that all the data has been sent.
By default: send only song data. See option to send album/artist/suggest data.
Detect if index doesn't exist and create it with a mapping file (see -map and -idx argument).
Remeber : it's cumulative! If you want to remove songs/artits/albums,
you have to delete and re-create the index (use -D option).
'''
Create index if -D option activated with a mapping file (see -map).
It's cumulative! If you want to remove songs/artits/albums, you have to delete and re-create the index (use -D option).''',
formatter_class=argparse.RawTextHelpFormatter
)
# Bulk
parser.add_argument('-q', '--quiet', action='store_true',
@@ -190,38 +215,43 @@ def create_args_parser():
sending_group = parser.add_argument_group("Sending options")
song_group = sending_group.add_mutually_exclusive_group()
song_group.add_argument('-sf', '--song-file', type=argparse.FileType('r'),
help='Song file data to send (default: \'{}\').'.format(DEFAULT_SONG_FILE))
sending_group.add_argument('-al', '--album-file', nargs='?', type=argparse.FileType('r'), const=DEFAULT_ALBUM_FILE,
help='Song file data to send (default: \'{}\').'.format(SONG_FILE))
sending_group.add_argument('-al', '--album-file', nargs='?', type=argparse.FileType('r'), const=ALBUM_FILE,
help='Enable sending album data. Optionally, precise the album data file (default: \'{}\')'
.format(DEFAULT_ALBUM_FILE))
sending_group.add_argument('-ar', '--artist-file', nargs='?', type=argparse.FileType('r'), const=DEFAULT_ARTIST_FILE,
.format(ALBUM_FILE))
sending_group.add_argument('-ar', '--artist-file', nargs='?', type=argparse.FileType('r'), const=ARTIST_FILE,
help='Enable sending artist data. Optionally, precise the artist data file (default: \'{}\')'
.format(DEFAULT_ARTIST_FILE))
song_group.add_argument('-s', '--song', action='store_false',
help='Disable sending song data')
.format(ARTIST_FILE))
# Mode
mode_group = parser.add_argument_group('Mode')
mode_group.add_argument('-A', '--ALL', action='store_true',
help='Send all possible data: song, artist and album')
help='Send all possible data: song, artist, album and suggest. Use default file if not specified')
mode_group.add_argument('-D', '--DELETE', action='store_true',
help='''Delete old index and create a new.
See -idx argument to set index name.
See -map arguement to set mapping file.''')
help='Delete index and create new. See -map arguement to set mapping file')
mode_group.add_argument('--no-song', action='store_true',
help='''Disable sending song data.
Not allowed with -A option.''')
mode_group.add_argument('--no-suggest', action='store_true',
help='Disable sending suggest data. Allowed with -A option')
# Mapping
mapping_group = parser.add_argument_group('Mapping files')
mode_group.add_argument('-ms', '--mapping-song', type=argparse.FileType('r'), const=DEFAULT_MAPPING_SONGS_FILE, nargs='?',
help='Mapping file for songs (default: \'{}\')'.format(DEFAULT_MAPPING_SONGS_FILE))
mode_group.add_argument('-mr', '--mapping-artist', type=argparse.FileType('r'), const=DEFAULT_ARTIST_FILE, nargs='?',
help='Mapping file for artists (default: \'{}\')'.format(DEFAULT_MAPPING_ARTISTS_FILE))
mode_group.add_argument('-ml', '--mapping-album', type=argparse.FileType('r'), const=DEFAULT_MAPPING_ALBUMS_FILE, nargs='?',
help='Mapping file for albums (default: \'{}\')'.format(DEFAULT_MAPPING_ALBUMS_FILE))
# CAUTION default values cannot be used because they necessarily activate the option
# QUESTION Use a for with a list of default mapping file?
mapping_group.add_argument('-ms', '--mapping-song', type=argparse.FileType('r'), const=MAPPING_SONGS_FILE, nargs='?',
help='Mapping file for songs (default: \'{}\')'.format(MAPPING_SONGS_FILE))
mapping_group.add_argument('-mr', '--mapping-artist', type=argparse.FileType('r'), const=ARTIST_FILE, nargs='?',
help='Mapping file for artists (default: \'{}\')'.format(MAPPING_ARTISTS_FILE))
mapping_group.add_argument('-ml', '--mapping-album', type=argparse.FileType('r'), const=MAPPING_ALBUMS_FILE, nargs='?',
help='Mapping file for albums (default: \'{}\')'.format(MAPPING_ALBUMS_FILE))
mapping_group.add_argument('-mg', '--mapping-suggest', type=argparse.FileType('r'), const=MAPPING_SUGGEST_FILE, nargs='?',
help='Mapping file for suggest (default: \'{}\')'.format(MAPPING_SUGGEST_FILE))
# Global Settings
g_settings_group = parser.add_argument_group('Global Settings')
g_settings_group.add_argument('-els', '--elasticsearch-url', default=ELASTICSEARCH_URL, nargs='?',
help="Elasticsearch URL (default: \'{}\')".format(ELASTICSEARCH_URL))
help="Elasticsearch URL.")
return parser
@@ -253,7 +283,7 @@ def delete_index(index_name, quiet=False):
res = requests.delete(url=ELASTICSEARCH_URL + index_name)
if res.status_code == 200:
if not quiet:
print(bcolors.OKGREEN + "Index deleted!" + bcolors.ENDC)
print(bcolors.OKGREEN + "Index '{}' deleted!".format(index_name) + bcolors.ENDC)
else:
print(bcolors.FAIL + "An error occured" + bcolors.ENDC)
if res.json()['error']['type'] == 'index_not_found_exception':
@@ -276,7 +306,7 @@ def put_mapping(index_name, mapping_file, quiet=False):
print(res.text + bcolors.ENDC)
else:
if not quiet:
print(bcolors.OKGREEN + "Mapping sent" + bcolors.ENDC)
print(bcolors.OKGREEN + "Mapping for '{}' sent".format(index_name) + bcolors.ENDC)
put_setting(index_name, 0, quiet)

93
suggester.es Normal file
View File

@@ -0,0 +1,93 @@
DELETE itunes-suggest
PUT /itunes-suggest
!./mapping.suggest.json
// 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
GET itunes-suggest/_search
{
"_source" : "artist",
"suggest": {
"name-suggest": {
"prefix": "sou",
"completion": {
"field": "artist_suggest"
}
}
}
}
GET itunes-suggest/_search
{
"_source" : "album",
"suggest": {
"name-suggest": {
"prefix": "new",
"completion": {
"field": "album_suggest",
"size": 20
}
}
}
}
GET itunes-suggest/_search
{
"_source": ["album", "artist"],
"suggest": {
"alb-suggest": {
"prefix": "sou",
"completion": {
"field": "album_suggest"
}
},
"ar-suggest": {
"prefix": "sou",
"completion": {
"field": "artist_suggest"
}
}
}
}
GET itunes-suggest/_search
{
"_source": ["album", "artist"],
"suggest": {
"alb-suggest": {
"prefix": "Francois",
"completion": {
"field": "album_suggest"
}
},
"ar-suggest": {
"prefix": "Francois",
"completion": {
"field": "artist_suggest"
}
}
}
}
GET itunes-suggest/_search
{
"suggest": {
"ar-suggest": {
"prefix": "Femme",
"completion": {
"field": "artist_suggest"
}
}
}
}

142
suggester.py Normal file
View File

@@ -0,0 +1,142 @@
"""
Process files generated by iTunesParser to fill a suggester index.
Suggester index in ELS must be created before use.
Found suggester.es query to create index.
"""
import sys
import json
import requests
ELS_URL = 'http://localhost:9200'
INDEX = 'itunes-suggest'
class NoGoodDataException(Exception):
""" Raise when data can't be correctly analyzed """
def get_tokens(data: str) -> list:
"""
Query Elasticsearch to get token for a string with a specific analyzer.
Throw an exception if no token found in ELS response.
Parameters
----------
data: string
String to be analysed to obtain the tokens
Returns
-------
list
A list of token
Raises
------
NoGoodDataException
If no tokens are found in the ELS responses, consider that the data is not correct for analysis.
"""
if not data:
return []
query = {
"analyzer": "names", # TODO Parameterize analyzer ?
"text" : data
}
url = '{}/{}/_analyze'.format(ELS_URL, INDEX)
req = requests.get(url, json=query)
if not 'tokens' in req.json():
print('ERROR: Not tokens in result')
print('Input: ' + str(data))
print('Request: ' + str(req.json()))
raise NoGoodDataException('Data is not correct to get tokens')
return [t['token'] for t in req.json()['tokens']]
def post_document(main_field_value: str, input_terms: list, main_field_name: str) -> str:
"""
Create suggestion document in Elasticsearch.
Parameters
----------
main_field_value : str
Value to put in the main field named by `main_field_name`
input_terms : list
List of suggestion term to put in document
main_field_name : str
Name of the main field, to fill with `main_field_value`
Returns
-------
str
Success: ID of created document
Fail (ret. status <> 201): None
"""
suggest_name = main_field_name + '_suggest'
element = {
main_field_name: main_field_value,
suggest_name: input_terms
}
# 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 None
el_id = resp.json()['_id']
# print('Post_element - Element created: ' + el_id)
return el_id
def process_file(file_name: str, field_name: str, array_file: str = None) -> int:
"""
Process a JSON file with data
Parameters
----------
file_name: string
Path and name of file to analyze
field_name: string
Name of the field where to find the data to create the suggestion entries
array_file: string, Default: None
Name of an array field to analyze to create more suggestion entries.
Nothing if None
"""
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 not "index" in data: # Exclude index line
try:
suggests_entries = get_tokens(data[field_name])
if array_file and array_file in data and data[array_file]:
for key in data[array_file]:
suggests_entries.extend(get_tokens(key))
# TODO Input have the same value several times ==> use to process a score
post_document(main_field_value=data[field_name], input_terms=suggests_entries, main_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__':
created_docs = 0
created_docs += process_file('/home/budd/workspace/iTunes/es-albums.json', 'Album')
print('Created documents: ' + str(created_docs))
created_docs += process_file('/home/budd/workspace/iTunes/es-artists.json', 'Artist', 'Album Artist')
print('Created documents: ' + str(created_docs))
# TODO Created doc <> nb doc in ELS